From f4a568832aa11cf57a2d8a2dd8564cfda0b9537c Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Wed, 15 Apr 2026 12:25:49 +0300 Subject: [PATCH] feat: implement reactive BatchFontStore --- .../Font/model/store/batchFontStore.svelte.ts | 91 +++++++++++++++++++ .../Font/model/store/batchFontStore.test.ts | 79 ++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 src/entities/Font/model/store/batchFontStore.svelte.ts create mode 100644 src/entities/Font/model/store/batchFontStore.test.ts diff --git a/src/entities/Font/model/store/batchFontStore.svelte.ts b/src/entities/Font/model/store/batchFontStore.svelte.ts new file mode 100644 index 0000000..0ae30e4 --- /dev/null +++ b/src/entities/Font/model/store/batchFontStore.svelte.ts @@ -0,0 +1,91 @@ +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 '../../model/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 { + 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 { + 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; + } +} diff --git a/src/entities/Font/model/store/batchFontStore.test.ts b/src/entities/Font/model/store/batchFontStore.test.ts new file mode 100644 index 0000000..31ac16f --- /dev/null +++ b/src/entities/Font/model/store/batchFontStore.test.ts @@ -0,0 +1,79 @@ +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 } 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 }); + }); + }); + + 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 }); + }); + }); +});