import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createProjectTemplateSnapshot } from '../../shared/project-template'; import { useProjectTemplatesStore } from '@/stores/project-templates'; const hostApiFetchMock = vi.fn(); vi.mock('@/lib/host-api', () => ({ hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), ensureHostApiToken: vi.fn().mockResolvedValue('test-token'), })); describe('project template store', () => { beforeEach(async () => { vi.clearAllMocks(); const persistOptions = useProjectTemplatesStore.persist.getOptions(); await persistOptions.storage?.removeItem(persistOptions.name); useProjectTemplatesStore.setState({ snapshotsByProjectId: {}, templateErrorsByProjectId: {}, templatesByProjectId: {}, }); }); it('returns missing results without caching snapshots or errors', async () => { hostApiFetchMock.mockResolvedValue({ status: 'missing' }); const loaded = await useProjectTemplatesStore.getState().loadProjectTemplate('prj_missing'); expect(loaded).toEqual({ status: 'missing' }); expect(useProjectTemplatesStore.getState().snapshotsByProjectId.prj_missing).toBeUndefined(); expect(useProjectTemplatesStore.getState().templateErrorsByProjectId.prj_missing).toBeUndefined(); }); it('loads a valid snapshot into the renderer cache', async () => { const snapshot = createProjectTemplateSnapshot('standard-dev', '2026-07-01T00:00:00.000Z'); hostApiFetchMock.mockResolvedValue({ status: 'valid', snapshot }); const loaded = await useProjectTemplatesStore.getState().loadProjectTemplate('prj_1'); expect(loaded).toEqual({ status: 'valid', snapshot }); expect(useProjectTemplatesStore.getState().snapshotsByProjectId.prj_1).toEqual(snapshot); expect(useProjectTemplatesStore.getState().templateErrorsByProjectId.prj_1).toBeUndefined(); expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/projects/template?projectId=prj_1'); }); it('stores invalid snapshot errors and does not cache a snapshot', async () => { hostApiFetchMock.mockResolvedValue({ status: 'invalid', error: '.niancode/project-template.json: invalid JSON' }); const loaded = await useProjectTemplatesStore.getState().loadProjectTemplate('prj_bad'); expect(loaded).toEqual({ status: 'invalid', error: '.niancode/project-template.json: invalid JSON' }); expect(useProjectTemplatesStore.getState().snapshotsByProjectId.prj_bad).toBeUndefined(); expect(useProjectTemplatesStore.getState().templateErrorsByProjectId.prj_bad).toContain('invalid JSON'); }); it('writes a snapshot through the Host API and caches the returned snapshot', async () => { const snapshot = createProjectTemplateSnapshot('youth-ai-course', '2026-07-01T00:00:00.000Z'); hostApiFetchMock.mockResolvedValue({ success: true, snapshot, scaffold: { status: 'disabled' }, }); const written = await useProjectTemplatesStore.getState().writeProjectTemplate('prj_course', snapshot); expect(written).toEqual({ snapshot, scaffold: { status: 'disabled' }, }); expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/projects/template', { method: 'PUT', body: JSON.stringify({ projectId: 'prj_course', snapshot }), }); expect(useProjectTemplatesStore.getState().snapshotsByProjectId.prj_course).toEqual(snapshot); expect(useProjectTemplatesStore.getState().templateErrorsByProjectId.prj_course).toBeUndefined(); }); it('returns the scaffold result from the Host API while caching the returned snapshot', async () => { const snapshot = createProjectTemplateSnapshot('web-mini-game-course', '2026-07-01T00:00:00.000Z'); hostApiFetchMock.mockResolvedValue({ success: true, snapshot, scaffold: { status: 'generated', files: ['package.json', 'src/main.ts'], }, }); const written = await useProjectTemplatesStore.getState().writeProjectTemplate('prj_game', snapshot); expect(written).toEqual({ snapshot, scaffold: { status: 'generated', files: ['package.json', 'src/main.ts'], }, }); expect(useProjectTemplatesStore.getState().snapshotsByProjectId.prj_game).toEqual(snapshot); }); it('forwards scaffold write options through the store to the Host API layer', async () => { const snapshot = createProjectTemplateSnapshot('web-mini-game-course', '2026-07-01T00:00:00.000Z'); hostApiFetchMock.mockResolvedValue({ success: true, snapshot, scaffold: { status: 'disabled' }, }); const written = await useProjectTemplatesStore.getState().writeProjectTemplate( 'prj_game', snapshot, { scaffold: 'never' }, ); expect(written.scaffold).toEqual({ status: 'disabled' }); expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/projects/template', { method: 'PUT', body: JSON.stringify({ projectId: 'prj_game', snapshot, options: { scaffold: 'never' }, }), }); }); it('does not allow legacy template ids to fabricate authoritative snapshots locally', () => { const beforeState = useProjectTemplatesStore.getState(); expect(() => useProjectTemplatesStore.getState().setProjectTemplate('prj_legacy', 'standard-dev')).toThrow( /deprecated/i, ); expect(useProjectTemplatesStore.getState().snapshotsByProjectId).toEqual(beforeState.snapshotsByProjectId); expect(useProjectTemplatesStore.getState().templateErrorsByProjectId).toEqual(beforeState.templateErrorsByProjectId); expect(useProjectTemplatesStore.getState().templatesByProjectId).toEqual(beforeState.templatesByProjectId); }); it('ignores legacy persisted template ids during rehydration', async () => { const snapshot = createProjectTemplateSnapshot('standard-dev', '2026-07-01T00:00:00.000Z'); const persistOptions = useProjectTemplatesStore.persist.getOptions(); const mergedState = persistOptions.merge?.( { snapshotsByProjectId: { prj_snapshot: snapshot }, templateErrorsByProjectId: { prj_snapshot: 'stale error' }, templatesByProjectId: { prj_snapshot: 'standard-dev', prj_legacy_only: 'youth-ai-course', }, }, { ...useProjectTemplatesStore.getState(), snapshotsByProjectId: {}, templateErrorsByProjectId: { prj_dirty: 'dirty error' }, templatesByProjectId: { prj_dirty: 'youth-ai-course' }, }, ); expect(mergedState).toBeDefined(); expect(mergedState?.snapshotsByProjectId).toEqual({ prj_snapshot: snapshot }); expect(mergedState?.templateErrorsByProjectId).toEqual({}); expect(mergedState?.templatesByProjectId).toEqual({ prj_snapshot: 'standard-dev' }); }); it('clears cached snapshots and errors when removing a project template', () => { const snapshot = createProjectTemplateSnapshot('standard-dev', '2026-07-01T00:00:00.000Z'); useProjectTemplatesStore.setState({ snapshotsByProjectId: { prj_remove: snapshot }, templateErrorsByProjectId: { prj_remove: 'bad snapshot' }, }); useProjectTemplatesStore.getState().removeProjectTemplate('prj_remove'); expect(useProjectTemplatesStore.getState().snapshotsByProjectId.prj_remove).toBeUndefined(); expect(useProjectTemplatesStore.getState().templateErrorsByProjectId.prj_remove).toBeUndefined(); }); });