aps-agent/tests/node/session-message-persistence...

164 lines
5.9 KiB
JavaScript

import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { setImmediate } from 'node:timers/promises';
import test from 'node:test';
const requireWeb = createRequire(new URL('../../apps/web/package.json', import.meta.url));
const ts = requireWeb('typescript');
const source = readFileSync(new URL('../../apps/web/src/projects/store.ts', import.meta.url), 'utf8');
const compiled = ts.transpileModule(source, { compilerOptions: {
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022,
} }).outputText;
function deferred() {
let resolve, reject;
const promise = new Promise((ok, fail) => { resolve = ok; reject = fail; });
return { promise, resolve, reject };
}
function loadStore(putSessionMessages, fetchSessionMessages = async () => ({ messages: [] })) {
const exported = {};
new Function('require', 'exports', compiled)((id) => {
if (id === '../api/client') return { putSessionMessages, fetchSessionMessages };
if (id === './sessionTitle') return {};
throw new Error(`Unexpected dependency: ${id}`);
}, exported);
return exported;
}
test('delayed old PUT completes before the confirmed snapshot can persist', async () => {
const oldResponse = deferred();
const requests = [];
let persisted;
const store = loadStore(async (_id, messages) => {
requests.push(messages);
if (requests.length === 1) await oldResponse.promise;
persisted = messages;
});
store.saveMessages('session', [{ confirmHandled: false }]);
await setImmediate();
const confirmed = store.persistMessages('session', [{ confirmHandled: true }]);
await setImmediate();
assert.equal(requests.length, 1);
oldResponse.resolve();
await confirmed;
assert.equal(requests.length, 2);
assert.deepEqual(persisted, [{ confirmHandled: true }]);
});
test('queued payload is a call-time snapshot including nested fields', async () => {
const gate = deferred();
const requests = [];
const store = loadStore(async (_id, messages) => {
requests.push(messages);
if (requests.length === 1) await gate.promise;
});
const first = store.persistMessages('session', [{ text: 'old' }]);
const messages = [{ block: { handled: true } }];
const second = store.persistMessages('session', messages);
messages[0].block.handled = false;
messages.push({ text: 'changed after enqueue' });
gate.resolve();
await Promise.all([first, second]);
assert.deepEqual(requests[1], [{ block: { handled: true } }]);
});
test('a failed PUT rejects its caller but does not block the next write', async () => {
const gate = deferred();
const requests = [];
const store = loadStore(async (_id, messages) => {
requests.push(messages);
if (requests.length === 1) await gate.promise;
});
const first = store.persistMessages('session', [{ text: 'old' }]);
const failed = assert.rejects(first, /network/);
const second = store.persistMessages('session', [{ text: 'retry' }]);
await setImmediate();
gate.reject(new Error('network'));
await failed;
await second;
assert.deepEqual(requests[1], [{ text: 'retry' }]);
});
test('different sessions do not wait for each other', async () => {
const gate = deferred();
const written = [];
const store = loadStore(async (id) => {
if (id === 'slow') await gate.promise;
written.push(id);
});
const slow = store.persistMessages('slow', []);
await store.persistMessages('fast', []);
assert.deepEqual(written, ['fast']);
gate.resolve();
await slow;
assert.deepEqual(written, ['fast', 'slow']);
});
test('clearMessages follows earlier saves through the same session queue', async () => {
const gate = deferred();
const cleared = deferred();
const requests = [];
let persisted;
const store = loadStore(async (_id, messages) => {
requests.push(messages);
if (requests.length === 1) await gate.promise;
persisted = messages;
if (requests.length === 2) cleared.resolve();
});
store.saveMessages('session', [{ text: 'old' }]);
store.clearMessages('session');
await setImmediate();
assert.equal(requests.length, 1);
gate.resolve();
await cleared.promise;
assert.deepEqual(persisted, []);
});
test('history load waits for both a delayed old PUT and the queued confirmation', async () => {
const gate = deferred();
let written = [], writeCount = 0, readCount = 0;
const store = loadStore(async (_id, messages) => {
writeCount += 1;
if (writeCount === 1) await gate.promise;
written = messages;
}, async () => {
readCount += 1;
return { messages: written };
});
store.saveMessages('session', [{ confirmHandled: false }]);
await setImmediate();
const confirmed = store.persistMessages('session', [{ confirmHandled: true }]);
const loaded = store.loadMessagesPreferServer('session');
await setImmediate();
assert.equal(readCount, 0);
gate.resolve();
await confirmed;
assert.deepEqual(await loaded, [{ confirmHandled: true }]);
assert.equal(readCount, 1);
});
test('history GET overlapping a completed write is discarded and read again', async () => {
const oldGet = deferred();
let written = [{ confirmHandled: false }], readCount = 0;
const store = loadStore(async (_id, messages) => { written = messages; }, async () => {
readCount += 1;
const snapshot = structuredClone(written);
if (readCount === 1) await oldGet.promise;
return { messages: snapshot };
});
const loaded = store.loadMessagesPreferServer('session');
await setImmediate();
assert.equal(readCount, 1);
await store.persistMessages('session', [{ confirmHandled: true }]);
oldGet.resolve();
assert.deepEqual(await loaded, [{ confirmHandled: true }]);
assert.equal(readCount, 2);
});
test('history read errors remain null instead of an empty conversation', async () => {
const store = loadStore(async () => {}, async () => { throw new Error('network'); });
assert.equal(await store.loadMessagesPreferServer('session'), null);
});