import assert from 'node:assert/strict'; import test from 'node:test'; import { __resetApiAvailabilityForTests } from './api'; import { loadServerData, saveServerData, ServerDataConflictError, SERVER_DATA_SAVE_ERROR_EVENT, type ServerDataSaveErrorDetail, } from './server-data'; type MockResponse = { status: number; body: unknown; }; test('server data saves include the latest loaded AppData version', async () => { const originalFetch = globalThis.fetch; const calls: Array<{ url: string; method: string; body?: unknown }> = []; const responses: MockResponse[] = [ { status: 200, body: {} }, { status: 200, body: { key: 'dev-tasks', value: [{ id: 'old' }], version: 'version-1' }, }, { status: 200, body: { key: 'dev-tasks', value: [{ id: 'next' }], version: 'version-2' }, }, { status: 200, body: { key: 'dev-tasks', value: [{ id: 'again' }], version: 'version-3' }, }, { status: 200, body: { key: 'bugs', value: [], version: 'bugs-version-1' }, }, { status: 409, body: { code: 'APP_DATA_CONFLICT', key: 'dev-tasks', currentValue: [{ id: 'server-copy' }], currentVersion: 'version-4', }, }, ]; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const response = responses.shift(); if (!response) throw new Error('Unexpected fetch call'); calls.push({ url: String(input), method: init?.method ?? 'GET', body: init?.body ? JSON.parse(String(init.body)) : undefined, }); return new Response(JSON.stringify(response.body), { status: response.status, headers: { 'Content-Type': 'application/json' }, }); }) as typeof fetch; try { await assert.doesNotReject(async () => { const loaded = await loadServerData>('dev-tasks'); assert.deepEqual(loaded, [{ id: 'old' }]); }); await saveServerData('dev-tasks', [{ id: 'next' }]); assert.deepEqual(calls[2].body, { value: [{ id: 'next' }], version: 'version-1', }); await saveServerData('dev-tasks', [{ id: 'again' }]); assert.deepEqual(calls[3].body, { value: [{ id: 'again' }], version: 'version-2', }); await saveServerData('bugs', []); assert.deepEqual(calls[4].body, { value: [] }); await assert.rejects( () => saveServerData('dev-tasks', [{ id: 'stale' }]), (error: unknown) => { if (!(error instanceof ServerDataConflictError)) return false; return error.key === 'dev-tasks' && error.currentVersion === 'version-4'; }, ); assert.deepEqual(calls[5].body, { value: [{ id: 'stale' }], version: 'version-3', }); } finally { globalThis.fetch = originalFetch; } }); test('server data shares an in-flight load for the same AppData key', async () => { const originalFetch = globalThis.fetch; let dataRequests = 0; let releaseDataResponse!: () => void; const dataResponseReady = new Promise((resolve) => { releaseDataResponse = resolve; }); globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); if (url.endsWith('/config/ai')) { return new Response(JSON.stringify({}), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } if (url.endsWith('/data/test-cases')) { dataRequests += 1; await dataResponseReady; return new Response(JSON.stringify({ key: 'test-cases', value: [{ id: 'tc-1' }], version: 'test-cases-version-1', }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } throw new Error(`Unexpected fetch ${init?.method ?? 'GET'} ${url}`); }) as typeof fetch; try { const first = loadServerData>('test-cases'); const second = loadServerData>('test-cases'); releaseDataResponse(); assert.deepEqual(await first, [{ id: 'tc-1' }]); assert.deepEqual(await second, [{ id: 'tc-1' }]); assert.equal(dataRequests, 1); } finally { globalThis.fetch = originalFetch; } }); test('server data serializes same-key saves so later writes use the latest AppData version', async () => { const originalFetch = globalThis.fetch; const saveCalls: Array<{ value: unknown; version?: string | null }> = []; let releaseFirstSave!: () => void; let markFirstSaveStarted!: () => void; const firstSaveStarted = new Promise((resolve) => { markFirstSaveStarted = resolve; }); const firstSaveCanFinish = new Promise((resolve) => { releaseFirstSave = resolve; }); __resetApiAvailabilityForTests(); globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); if (url.endsWith('/config/ai')) { return new Response(JSON.stringify({}), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } if (url.endsWith('/data/requirements') && (!init?.method || init.method === 'GET')) { return new Response(JSON.stringify({ key: 'requirements', value: { requirements: [] }, version: 'requirements-version-1', }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } if (url.endsWith('/data/requirements') && init?.method === 'PUT') { const body = JSON.parse(String(init.body)); saveCalls.push(body); if (body.value.marker === 'first') { markFirstSaveStarted(); await firstSaveCanFinish; return new Response(JSON.stringify({ key: 'requirements', value: body.value, version: 'requirements-version-2', }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } return new Response(JSON.stringify({ key: 'requirements', value: body.value, version: 'requirements-version-3', }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } throw new Error(`Unexpected fetch ${init?.method ?? 'GET'} ${url}`); }) as typeof fetch; try { await loadServerData('requirements', { force: true }); const firstSave = saveServerData('requirements', { marker: 'first' }); await firstSaveStarted; const secondSave = saveServerData('requirements', { marker: 'second' }); await new Promise((resolve) => setTimeout(resolve, 0)); assert.equal(saveCalls.length, 1); releaseFirstSave(); await Promise.all([firstSave, secondSave]); assert.deepEqual(saveCalls, [ { value: { marker: 'first' }, version: 'requirements-version-1' }, { value: { marker: 'second' }, version: 'requirements-version-2' }, ]); } finally { globalThis.fetch = originalFetch; __resetApiAvailabilityForTests(); } }); test('server data reuses a fresh cached load for repeated AppData reads', async () => { const originalFetch = globalThis.fetch; let dataRequests = 0; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); if (url.endsWith('/config/ai')) { return new Response(JSON.stringify({}), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } if (url.endsWith('/data/version-plans')) { dataRequests += 1; return new Response(JSON.stringify({ key: 'version-plans', value: [{ id: 'plan-1' }], version: 'version-plans-version-1', }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } throw new Error(`Unexpected fetch ${init?.method ?? 'GET'} ${url}`); }) as typeof fetch; try { const first = await loadServerData>('version-plans'); const second = await loadServerData>('version-plans'); assert.deepEqual(first, [{ id: 'plan-1' }]); assert.deepEqual(second, [{ id: 'plan-1' }]); assert.equal(dataRequests, 1); } finally { globalThis.fetch = originalFetch; } }); test('server data save failures dispatch a browser-visible failure event', async () => { const originalFetch = globalThis.fetch; const originalWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window'); const events: Array> = []; __resetApiAvailabilityForTests(); Object.defineProperty(globalThis, 'window', { configurable: true, value: { dispatchEvent: (event: Event) => { events.push(event as CustomEvent); return true; }, }, }); globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); if (url.endsWith('/config/ai')) { return new Response(JSON.stringify({}), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } if (url.endsWith('/data/requirements') && init?.method === 'PUT') { return new Response(JSON.stringify({ message: 'offline' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } throw new Error(`Unexpected fetch ${init?.method ?? 'GET'} ${url}`); }) as typeof fetch; try { await assert.rejects(() => saveServerData('requirements', []), /offline/); assert.equal(events.length, 1); assert.equal(events[0].type, SERVER_DATA_SAVE_ERROR_EVENT); assert.deepEqual(events[0].detail, { key: 'requirements', message: 'offline', }); } finally { globalThis.fetch = originalFetch; __resetApiAvailabilityForTests(); if (originalWindowDescriptor) { Object.defineProperty(globalThis, 'window', originalWindowDescriptor); } else { Reflect.deleteProperty(globalThis, 'window'); } } });