refactor(font): absorb FetchFontsByIds store into Font entity

FetchFontsByIds was data-access for the Font aggregate wearing a feature
costume: a single FontsByIdsStore (BaseQueryStore over Font's proxy API),
no UI, consumed only by comparisonStore. Move it beside fontCatalogStore
in the Font entity, collapse its deep $entities/Font imports to relative
paths, expose via the Font public API, and delete the feature slice.
This commit is contained in:
Ilia Mashkov
2026-05-31 17:49:59 +03:00
parent 4d8dcf52e0
commit a651d3d16f
6 changed files with 14 additions and 13 deletions
@@ -0,0 +1,92 @@
import { fontKeys } from '$shared/api/queryKeys';
import { BaseQueryStore } from '$shared/lib';
import {
fetchFontsByIds,
seedFontCache,
} from '../../../api/proxy/proxyFonts';
import {
FontNetworkError,
FontResponseError,
} from '../../../lib/errors/errors';
import type { UnifiedFont } from '../../types';
/**
* Internal fetcher that seeds the cache and handles error wrapping.
* Standalone function to avoid 'this' issues during construction.
*/
async function fetchAndSeed(ids: string[]): Promise<UnifiedFont[]> {
if (ids.length === 0) {
return [];
}
let response: UnifiedFont[];
try {
response = await fetchFontsByIds(ids);
} catch (cause) {
throw new FontNetworkError(cause);
}
if (!response || !Array.isArray(response)) {
throw new FontResponseError('batchResponse', response);
}
seedFontCache(response);
return response;
}
/**
* Reactive store for fetching specific fonts by ID via the proxy batch endpoint.
* Wraps TanStack Query and seeds the detail cache for sibling consumers.
*/
export class FontsByIdsStore extends BaseQueryStore<UnifiedFont[]> {
constructor(initialIds: string[] = []) {
super({
queryKey: fontKeys.batch(initialIds),
queryFn: () => fetchAndSeed(initialIds),
enabled: initialIds.length > 0,
retry: false,
});
}
/**
* Updates the IDs to fetch. Triggers a new query.
*
* @param ids - Array of font IDs
*/
setIds(ids: string[]): void {
this.updateOptions({
queryKey: fontKeys.batch(ids),
queryFn: () => fetchAndSeed(ids),
enabled: ids.length > 0,
retry: false,
});
}
/**
* Array of fetched fonts
*/
get fonts(): UnifiedFont[] {
return this.result.data ?? [];
}
/**
* Whether the query is currently loading
*/
get isLoading(): boolean {
return this.result.isLoading;
}
/**
* Whether the query encountered an error
*/
get isError(): boolean {
return this.result.isError;
}
/**
* The error object if the query failed
*/
get error(): Error | null {
return (this.result.error as Error) ?? null;
}
}
@@ -0,0 +1,107 @@
import { queryClient } from '$shared/api/queryClient';
import { fontKeys } from '$shared/api/queryKeys';
import {
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import * as api from '../../../api/proxy/proxyFonts';
import {
FontNetworkError,
FontResponseError,
} from '../../../lib/errors/errors';
import { FontsByIdsStore } from './fontsByIdsStore.svelte';
describe('FontsByIdsStore', () => {
beforeEach(() => {
queryClient.clear();
vi.clearAllMocks();
});
describe('Fetch Behavior', () => {
it('should skip fetch when initialized with empty IDs', async () => {
const spy = vi.spyOn(api, 'fetchFontsByIds');
const store = new FontsByIdsStore([]);
expect(spy).not.toHaveBeenCalled();
expect(store.fonts).toEqual([]);
});
it('should fetch and seed cache for valid IDs', async () => {
const fonts = [{ id: 'a', name: 'A' }] as any[];
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts);
const store = new FontsByIdsStore(['a']);
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
expect(queryClient.getQueryData(fontKeys.detail('a'))).toEqual(fonts[0]);
});
});
describe('Loading States', () => {
it('should transition through loading state', async () => {
vi.spyOn(api, 'fetchFontsByIds').mockImplementation(() =>
new Promise(r => setTimeout(() => r([{ id: 'a' }] as any), 50))
);
const store = new FontsByIdsStore(['a']);
expect(store.isLoading).toBe(true);
await vi.waitFor(() => expect(store.isLoading).toBe(false), { timeout: 1000 });
});
});
describe('Error Handling', () => {
it('should wrap network failures in FontNetworkError', async () => {
vi.spyOn(api, 'fetchFontsByIds').mockRejectedValue(new Error('Network fail'));
const store = new FontsByIdsStore(['a']);
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
expect(store.error).toBeInstanceOf(FontNetworkError);
});
it('should handle malformed API responses with FontResponseError', async () => {
// Mocking a malformed response that the store should validate
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(null as any);
const store = new FontsByIdsStore(['a']);
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
expect(store.error).toBeInstanceOf(FontResponseError);
});
it('should have null error in success state', async () => {
const fonts = [{ id: 'a' }] as any[];
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts);
const store = new FontsByIdsStore(['a']);
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
expect(store.error).toBeNull();
});
});
describe('Disable Behavior', () => {
it('should return empty fonts and not fetch when setIds is called with empty array', async () => {
const fonts1 = [{ id: 'a' }] as any[];
const spy = vi.spyOn(api, 'fetchFontsByIds').mockResolvedValueOnce(fonts1);
const store = new FontsByIdsStore(['a']);
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
spy.mockClear();
store.setIds([]);
await vi.waitFor(() => expect(store.fonts).toEqual([]), { timeout: 1000 });
expect(spy).not.toHaveBeenCalled();
});
});
describe('Reactivity', () => {
it('should refetch when setIds is called', async () => {
const fonts1 = [{ id: 'a' }] as any[];
const fonts2 = [{ id: 'b' }] as any[];
vi.spyOn(api, 'fetchFontsByIds')
.mockResolvedValueOnce(fonts1)
.mockResolvedValueOnce(fonts2);
const store = new FontsByIdsStore(['a']);
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
store.setIds(['b']);
await vi.waitFor(() => expect(store.fonts).toEqual(fonts2), { timeout: 1000 });
});
});
});
+3
View File
@@ -7,3 +7,6 @@ export {
FontCatalogStore,
fontCatalogStore,
} from './fontCatalogStore/fontCatalogStore.svelte';
// Batch fetch by IDs (detail-cache seeding)
export { FontsByIdsStore } from './fontsByIdsStore/fontsByIdsStore.svelte';