140 lines
7.3 KiB
JavaScript
140 lines
7.3 KiB
JavaScript
import assert from "node:assert/strict";
|
|||
|
|
import test from "node:test";
|
||
|
|
import { createAgentOptimisticOutbox } from "../src/lib/agent-optimistic-outbox.ts";
|
||
|
|
|
||
|
|
const canonical = (id, content = "Hello", time = "2026-10-14T12:01:00.000Z") => ({
|
||
|
|
id, content, createdAt: time, role: "user", name: "buzzert",
|
||
|
|
metadata: { kind: "message", entryId: 123, status: "complete", channel: "sybil" },
|
||
|
|
});
|
||
|
|
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; };
|
||
|
|
function fixture(submit, legacy = {}) {
|
||
|
|
let stored = null;
|
||
|
|
let serial = 0;
|
||
|
|
const changes = [];
|
||
|
|
let box;
|
||
|
|
const storage = { read: () => structuredClone(stored), write: rows => { stored = structuredClone(rows); }, ...legacy };
|
||
|
|
const open = () => {
|
||
|
|
box = createAgentOptimisticOutbox({ submit, storage, newId: () => `request-${++serial}`, now: () => Date.parse("2026-10-14T12:00:00.000Z"), changed: () => changes.push(box?.snapshot()) });
|
||
|
|
return box;
|
||
|
|
};
|
||
|
|
return { open, changes, stored: () => stored, storage };
|
||
|
|
}
|
||
|
|
|
||
|
|
test("send publishes and persists one canonical row before POST, retaining it through receipt and echo", async () => {
|
||
|
|
const network = deferred(); let called = false;
|
||
|
|
const state = fixture(async (_content, requestId) => { called = true; assert.equal(requestId, "request-1"); return network.promise; });
|
||
|
|
const box = state.open();
|
||
|
|
const done = box.send("Hello");
|
||
|
|
const first = box.display([])[0];
|
||
|
|
assert.equal(called, false, "network starts after synchronous local publication");
|
||
|
|
assert.equal(first.id, "inbound:sybil:request-1");
|
||
|
|
assert.equal(first.metadata.status, "sending");
|
||
|
|
assert.equal(state.stored()[0].id, first.id);
|
||
|
|
assert.equal(state.changes.length, 1);
|
||
|
|
network.resolve({ status: "queued", messageId: first.id }); await done;
|
||
|
|
assert.equal(box.display([])[0].metadata.status, "queued");
|
||
|
|
assert.equal(box.display([])[0].createdAt, first.createdAt);
|
||
|
|
const echo = canonical(first.id);
|
||
|
|
box.reconcile({ messages: [echo] });
|
||
|
|
const displayed = box.display([echo]);
|
||
|
|
assert.equal(displayed.length, 1);
|
||
|
|
assert.equal(displayed[0].id, first.id);
|
||
|
|
assert.equal(displayed[0].createdAt, first.createdAt, "server receipt time cannot reorder the optimistic bubble");
|
||
|
|
assert.equal(displayed[0].metadata.status, "complete");
|
||
|
|
assert.deepEqual(state.stored(), []);
|
||
|
|
assert.equal(box.display([]).length, 1, "even a stale display snapshot cannot create an acknowledgment gap");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("canonical echo before an HTTP failure wins and never paints a delivered bubble failed", async () => {
|
||
|
|
const network = deferred(); const state = fixture(() => network.promise); const box = state.open();
|
||
|
|
const done = box.send("Hello"); const row = box.display([])[0];
|
||
|
|
const echo = canonical(row.id);
|
||
|
|
box.reconcile({ messages: [echo] });
|
||
|
|
network.reject(new Error("response lost"));
|
||
|
|
await done;
|
||
|
|
assert.equal(box.display([echo])[0].metadata.status, "complete");
|
||
|
|
assert.equal(box.display([echo])[0].metadata.error, undefined);
|
||
|
|
assert.deepEqual(state.stored(), []);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("queue echo before HTTP acknowledgment preserves one row when the queue snapshot disappears", async () => {
|
||
|
|
const network = deferred(); const state = fixture(() => network.promise); const box = state.open();
|
||
|
|
const done = box.send("Hello"); const row = box.display([])[0];
|
||
|
|
const queued = { ...canonical(row.id), metadata: { kind: "message", status: "queued" } };
|
||
|
|
box.reconcile({ messages: [], activity: { queuedMessages: [queued] } });
|
||
|
|
assert.equal(box.display([queued]).length, 1);
|
||
|
|
box.reconcile({ messages: [], activity: { queuedMessages: [] } });
|
||
|
|
assert.equal(box.display([])[0].id, row.id, "queue-to-history transfer must not hide the bubble");
|
||
|
|
network.reject(new Error("HTTP acknowledgment lost")); await done;
|
||
|
|
assert.equal(box.display([])[0].metadata.status, "queued");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("failed retry and reload preserve the message ID, timestamp, and original receipt key", async () => {
|
||
|
|
const requests = []; let fail = true;
|
||
|
|
const state = fixture(async (content, requestId) => {
|
||
|
|
requests.push({ content, requestId });
|
||
|
|
if (fail) throw new Error("network down");
|
||
|
|
return { status: "placed", messageId: `inbound:sybil:${requestId}` };
|
||
|
|
});
|
||
|
|
let box = state.open();
|
||
|
|
await assert.rejects(box.send("Hello"), /network down/);
|
||
|
|
const failed = box.display([])[0];
|
||
|
|
assert.equal(failed.metadata.status, "failed");
|
||
|
|
assert.equal(failed.metadata.error, "network down");
|
||
|
|
box = state.open();
|
||
|
|
assert.equal(requests.length, 1, "restoring drafts never sends automatically");
|
||
|
|
assert.equal(box.display([])[0].metadata.status, "failed");
|
||
|
|
assert.equal(box.display([])[0].createdAt, failed.createdAt);
|
||
|
|
fail = false;
|
||
|
|
const retry = box.retry(failed.id);
|
||
|
|
assert.equal(box.display([])[0].metadata.status, "sending");
|
||
|
|
await retry;
|
||
|
|
assert.deepEqual(requests.map(row => row.requestId), ["request-1", "request-1"]);
|
||
|
|
assert.equal(box.display([]).length, 1);
|
||
|
|
assert.equal(box.display([])[0].metadata.status, "complete");
|
||
|
|
assert.equal(box.display([])[0].createdAt, failed.createdAt);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("in-flight reload becomes unconfirmed and two identical sends remain independent rows", async () => {
|
||
|
|
const first = deferred(), second = deferred(); let calls = 0;
|
||
|
|
const state = fixture(() => ++calls === 1 ? first.promise : second.promise); const box = state.open();
|
||
|
|
const send1 = box.send("Same content"), send2 = box.send("Same content");
|
||
|
|
const rows = box.display([]);
|
||
|
|
assert.equal(rows.length, 2);
|
||
|
|
assert.notEqual(rows[0].id, rows[1].id);
|
||
|
|
assert.ok(rows[0].createdAt < rows[1].createdAt, "same-millisecond sends retain insertion order");
|
||
|
|
const reloaded = state.open();
|
||
|
|
assert.deepEqual(reloaded.display([]).map(row => row.metadata.status), ["unconfirmed", "unconfirmed"]);
|
||
|
|
assert.deepEqual(reloaded.display([]).map(row => row.id), rows.map(row => row.id));
|
||
|
|
first.resolve({ status: "queued" }); second.resolve({ status: "queued" });
|
||
|
|
await Promise.all([send1, send2]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("legacy failed cards join their saved receipt keys without automatic delivery", () => {
|
||
|
|
const state = fixture(() => assert.fail("migration must not submit"), {
|
||
|
|
legacyRows: () => [{ content: "old message", status: "failed", error: "old error" }],
|
||
|
|
legacyIds: () => [{ content: "old message", requestId: "old-request" }],
|
||
|
|
});
|
||
|
|
const box = state.open();
|
||
|
|
assert.equal(box.display([])[0].id, "inbound:sybil:old-request");
|
||
|
|
assert.equal(box.display([])[0].metadata.status, "failed");
|
||
|
|
assert.equal(box.display([])[0].metadata.error, "old error");
|
||
|
|
const migrated = state.stored()[0];
|
||
|
|
assert.equal(migrated.id, "inbound:sybil:old-request");
|
||
|
|
assert.equal(state.open().display([])[0].createdAt, migrated.createdAt, "migration is saved before any retry");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("storage failures still leave a failed inline row and prevent untracked POST", async () => {
|
||
|
|
let calls = 0, visible = 0;
|
||
|
|
const box = createAgentOptimisticOutbox({ submit: async () => { calls++; return { status: "queued" }; },
|
||
|
|
storage: { read: () => null, write: () => { throw new Error("storage unavailable"); } },
|
||
|
|
newId: () => "request", changed: () => visible++,
|
||
|
|
});
|
||
|
|
const sending = box.send("Do not lose this text");
|
||
|
|
assert.equal(box.display([])[0].metadata.status, "failed");
|
||
|
|
assert.equal(box.display([])[0].content, "Do not lose this text");
|
||
|
|
assert.equal(visible, 1);
|
||
|
|
await assert.rejects(sending, /storage unavailable/);
|
||
|
|
assert.equal(calls, 0);
|
||
|
|
});
|