Files
ftb-project-management/apps/web/lib/server-data.test.ts
2026-07-07 18:24:45 +08:00

315 lines
9.8 KiB
TypeScript

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<Array<{ id: string }>>('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<void>((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<Array<{ id: string }>>('test-cases');
const second = loadServerData<Array<{ id: string }>>('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<void>((resolve) => {
markFirstSaveStarted = resolve;
});
const firstSaveCanFinish = new Promise<void>((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<Array<{ id: string }>>('version-plans');
const second = await loadServerData<Array<{ id: string }>>('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<CustomEvent<ServerDataSaveErrorDetail>> = [];
__resetApiAvailabilityForTests();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
dispatchEvent: (event: Event) => {
events.push(event as CustomEvent<ServerDataSaveErrorDetail>);
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');
}
}
});