Files
Dune/tests/client-request.test.ts
T
Quintine 238de3860d Add recoverable administrator room removal and restoration
Preserve room JSON, seats, credentials and messages while removed rooms deny ordinary reads and writes. Add deliberate room-scoped controls, removed-directory filters, persistent player notices and live-authorized atomic version/revision audit receipts. Retain uncertain room/seat requests and hidden recovery drafts through removal; restore the prior pause/join settings without reviving revoked credentials.

Fix AI readiness after a joining human or human faction change so real lobby starts remain legal.

Verified types, lint, 5314 offline tests, 52 HTTP tests and build. Fourteen authenticated HTTP groups and independent privacy/persistence review passed. Desktop/mobile browser QA confirmed a saved removal after a lost response and refresh, one audit on exact retry, persistent notices and recovery draft continuity. All 1,945 baseline rooms, 4,121 seats and related records preserved. Positive authenticated production admin acceptance remains pending authorized QA operator access; full admin and rules gates remain open.
2026-09-22 12:01:22 +10:00

279 lines
9.1 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
ClientRequestError,
requestJson,
requestMayHaveCompleted,
isRoomRemoved,
subscribeRoomRemoval,
} from '../lib/client-request';
const never = () => new Promise<never>(() => {});
const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
void test('typed removal retains post-commit proofs and notifies only the affected public room', async () => {
const rooms: string[] = [];
const stop = subscribeRoomRemoval(room => rooms.push(room));
const fetcher: typeof fetch = async () => Response.json({ code: 'ROOM_REMOVED', error: 'Room removed', private: 'NOT_FOR_LISTENERS' }, { status: 410 });
try {
for (const path of ['/api/rooms/TEST2345', '/api/rooms/TEST2345/control', '/api/rooms/TEST2345/messages', '/api/rooms'])
await assert.rejects(requestJson(path, { method: 'POST' }, { fetcher }), error => isRoomRemoved(error) && error.status === 410 && requestMayHaveCompleted(error));
assert.deepEqual(rooms, ['TEST2345', 'TEST2345', 'TEST2345']);
} finally { stop(); }
await assert.rejects(requestJson('/api/rooms/TEST2345', {}, { fetcher }));
assert.equal(rooms.length, 3, 'Unmounted controllers no longer receive removal events');
assert.equal(requestMayHaveCompleted(new ClientRequestError('Ordinary rejection', 'http', 410)), false);
});
void test('removal observers cannot swallow the typed error or trigger automatic request retries', async () => {
let calls = 0;
const stop = subscribeRoomRemoval(() => { throw new Error('Controller failed'); });
try {
await assert.rejects(requestJson('/api/rooms/TEST2345', {}, { fetcher: async () => {
calls++; return Response.json({ code: 'ROOM_REMOVED', error: 'Removed' }, { status: 410 });
} }), isRoomRemoved);
assert.equal(calls, 1);
} finally { stop(); }
});
void test('successful JSON preserves mutation body, version and credential options, with one fetch', async () => {
let calls = 0;
let signal: AbortSignal | null | undefined;
const body = JSON.stringify({ version: 12, action: { type: 'ready' } });
const fetcher: typeof fetch = async (url, init) => {
calls++;
assert.equal(url, '/api/rooms/TEST2345');
assert.equal(init?.body, body);
assert.equal(init?.credentials, 'same-origin');
assert.equal(init?.method, 'POST');
assert.deepEqual(init?.headers, { 'Content-Type': 'application/json' });
signal = init?.signal;
return Response.json({ version: 13, me: 'private-seat' });
};
assert.deepEqual(
await requestJson(
'/api/rooms/TEST2345',
{
method: 'POST',
body,
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
},
{ fetcher, timeoutMs: 20 },
),
{ version: 13, me: 'private-seat' },
);
await pause(30);
assert.equal(calls, 1);
assert.equal(
signal?.aborted,
false,
'completed requests have no lingering timeout',
);
});
void test('a stalled fetch aborts by the deadline even if transport ignores the signal, without replaying POST', async () => {
let calls = 0;
let signal: AbortSignal | null | undefined;
const fetcher: typeof fetch = async (_, init) => {
calls++;
signal = init?.signal;
return never();
};
await assert.rejects(
requestJson(
'/api/rooms/TEST2345',
{ method: 'POST' },
{ fetcher, timeoutMs: 10 },
),
(error: unknown) =>
error instanceof ClientRequestError && error.kind === 'timeout',
);
assert.equal(signal?.aborted, true);
await pause(20);
assert.equal(calls, 1);
});
void test('the request deadline includes a stalled JSON response body', async () => {
let signal: AbortSignal | null | undefined;
let bodyStarted = false;
const fetcher: typeof fetch = async (_, init) => {
signal = init?.signal;
return {
ok: true,
status: 200,
json: () => {
bodyStarted = true;
return never();
},
} as unknown as Response;
};
await assert.rejects(
requestJson('/api/rooms/TEST2345', {}, { fetcher, timeoutMs: 10 }),
(error: unknown) =>
error instanceof ClientRequestError && error.kind === 'timeout',
);
assert.equal(bodyStarted, true);
assert.equal(signal?.aborted, true);
});
void test('409 rejection retains the server message and HTTP status for reconciliation', async () => {
const fetcher: typeof fetch = async () =>
Response.json(
{ error: 'The table changed. Refresh before trying again.' },
{ status: 409 },
);
await assert.rejects(
requestJson('/api/rooms/TEST2345', {}, { fetcher }),
(error: unknown) =>
error instanceof ClientRequestError &&
error.kind === 'http' &&
error.status === 409 &&
error.message === 'The table changed. Refresh before trying again.',
);
});
void test('non-JSON HTTP failures retain status without exposing returned HTML', async () => {
const fetcher: typeof fetch = async () =>
new Response('<html>internal server detail</html>', { status: 502 });
await assert.rejects(
requestJson('/api/rooms/TEST2345', {}, { fetcher }),
(error: unknown) =>
error instanceof ClientRequestError &&
error.kind === 'http' &&
error.status === 502 &&
error.message === 'Request failed (502).',
);
});
void test('invalid JSON in a successful response remains an uncertain-response error', async () => {
const fetcher: typeof fetch = async () => new Response('truncated');
await assert.rejects(
requestJson('/api/rooms/TEST2345', {}, { fetcher }),
(error: unknown) =>
error instanceof ClientRequestError &&
error.kind === 'invalid-response' &&
error.status === 200,
);
});
void test('network rejection is readable and never automatically retried', async () => {
let calls = 0;
const fetcher: typeof fetch = async () => {
calls++;
throw new TypeError('private transport details');
};
await assert.rejects(
requestJson('/api/rooms', { method: 'POST' }, { fetcher }),
(error: unknown) =>
error instanceof ClientRequestError &&
error.kind === 'network' &&
!error.message.includes('private transport'),
);
assert.equal(calls, 1);
});
void test('caller cancellation aborts an in-progress body without waiting for the deadline', async () => {
const caller = new AbortController();
let transportSignal: AbortSignal | null | undefined;
const fetcher: typeof fetch = async (_, init) => {
transportSignal = init?.signal;
return {
ok: true,
status: 200,
json: () => never(),
} as unknown as Response;
};
const request = requestJson(
'/api/rooms/TEST2345',
{ signal: caller.signal },
{ fetcher },
);
caller.abort();
await assert.rejects(
request,
(error: unknown) =>
error instanceof ClientRequestError && error.kind === 'aborted',
);
assert.equal(transportSignal?.aborted, true);
});
void test('a request canceled before dispatch performs no fetch', async () => {
const caller = new AbortController();
caller.abort();
let calls = 0;
const fetcher: typeof fetch = async () => {
calls++;
return Response.json({});
};
await assert.rejects(
requestJson('/api/rooms', { signal: caller.signal }, { fetcher }),
(error: unknown) =>
error instanceof ClientRequestError && error.kind === 'aborted',
);
assert.equal(calls, 0);
});
void test('non-finite or non-positive deadlines are rejected before dispatch', async () => {
let calls = 0;
const fetcher: typeof fetch = async () => {
calls++;
return Response.json({});
};
for (const timeoutMs of [0, -1, Infinity, NaN])
await assert.rejects(
requestJson('/api/rooms', {}, { fetcher, timeoutMs }),
RangeError,
);
assert.equal(calls, 0);
});
void test('gateway and request-timeout HTTP errors remain uncertain mutation outcomes', () => {
for (const status of [408, 500, 502, 503, 504])
assert.equal(
requestMayHaveCompleted(
new ClientRequestError('Server error', 'http', status),
),
true,
String(status),
);
for (const status of [400, 401, 403, 404, 409, 413, 422, 429])
assert.equal(
requestMayHaveCompleted(
new ClientRequestError('Request rejected', 'http', status),
),
false,
String(status),
);
for (const kind of [
'timeout',
'network',
'invalid-response',
'aborted',
] as const)
assert.equal(
requestMayHaveCompleted(new ClientRequestError('Unconfirmed', kind)),
true,
kind,
);
assert.equal(requestMayHaveCompleted(new Error('Unknown error')), true);
});
void test('a failed gateway response keeps its status and requires reconciliation without replay', async () => {
let calls = 0;
const fetcher: typeof fetch = async () => {
calls++;
return new Response('Gateway timeout after the origin may have committed', {
status: 504,
});
};
await assert.rejects(
requestJson('/api/rooms/TEST2345', { method: 'POST' }, { fetcher }),
(error: unknown) =>
error instanceof ClientRequestError &&
error.status === 504 &&
requestMayHaveCompleted(error),
);
assert.equal(calls, 1);
});