177 lines
7.3 KiB
TypeScript
177 lines
7.3 KiB
TypeScript
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();
|
|
});
|
|
});
|