refactor: extract BatchFontStore into new FetchFontsByIds feature

The byId font fetch was a verb-oriented capability with a single
consumer driven by a feature need (materializing comparison picks).
That shape belongs at the feature layer, not on the entity.

Move:
- entities/Font/model/store/batchFontStore -> features/FetchFontsByIds/model/store/fontsByIdsStore
- Class BatchFontStore -> FontsByIdsStore

entities/Font retains the transport primitives (fetchFontsByIds,
seedFontCache) and the keyspace (fontKeys); the feature wraps them in
the reactive store. comparisonStore now imports FontsByIdsStore from
the new feature. The proxy API is imported via direct path so vi.spyOn
on the source module still observes the call.
This commit is contained in:
Ilia Mashkov
2026-05-24 19:41:40 +03:00
parent df59dfda02
commit 07d044f4d6
8 changed files with 37 additions and 42 deletions
+1
View File
@@ -9,6 +9,7 @@ export {
fetchFontsByIds,
fetchProxyFontById,
fetchProxyFonts,
seedFontCache,
} from './proxy/proxyFonts';
export type {
ProxyFontsParams,
@@ -1,93 +0,0 @@
import { fontKeys } from '$shared/api/queryKeys';
import { BaseQueryStore } from '$shared/lib/helpers/BaseQueryStore.svelte';
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 and caching batches of fonts by ID.
* Integrates with TanStack Query via BaseQueryStore and handles
* normalized cache seeding.
*/
export class BatchFontStore 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;
}
}
@@ -1,107 +0,0 @@
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 { BatchFontStore } from './batchFontStore.svelte';
describe('BatchFontStore', () => {
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 BatchFontStore([]);
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 BatchFontStore(['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 BatchFontStore(['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 BatchFontStore(['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 BatchFontStore(['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 BatchFontStore(['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 BatchFontStore(['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 BatchFontStore(['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
@@ -1,9 +1,6 @@
// Applied fonts manager
export * from './appliedFontsStore/appliedFontsStore.svelte';
// Batch font store
export { BatchFontStore } from './batchFontStore/batchFontStore.svelte';
// Single FontStore
export {
createFontStore,