import assert from "node:assert/strict"; import { test } from "node:test"; import { ChatLedger, MAX_MESSAGE_LEN, MAX_NONCE_LEN, checkBody, newNonce, parseFrame, streamUrl, } from "./chat.logic.ts"; // Body validation. The mirror of MessageBody::parse; the tests below are the // same cases livechat's message.rs asserts, so a divergence shows up on one // side or the other rather than as a message the composer accepts and the // server refuses. test("a body is trimmed before anything else happens to it", () => { const check = checkBody(" hi "); assert.equal(check.kind, "ok"); assert.equal(check.kind === "ok" && check.body, "hi"); }); test("whitespace alone is empty", () => { assert.equal(checkBody("").kind, "empty"); assert.equal(checkBody(" \n\t ").kind, "empty"); }); test("the length boundary is inclusive", () => { assert.equal(checkBody("x".repeat(MAX_MESSAGE_LEN)).kind, "ok"); const over = checkBody("x".repeat(MAX_MESSAGE_LEN + 1)); assert.equal(over.kind, "too-long"); assert.equal(over.kind === "too-long" && over.length, MAX_MESSAGE_LEN + 1); }); test("length is measured after trimming", () => { const padded = ` ${"x".repeat(MAX_MESSAGE_LEN)} `; assert.equal(checkBody(padded).kind, "ok"); }); test("length counts code points, not UTF-16 units", () => { // The whole reason checkBody spreads the string. An astral character is two // UTF-16 units and one Rust `char`, so a body of these at the limit is 1000 // by String.length and 500 to the server. Counting the wrong one refuses a // message the server would have accepted. const astral = "\u{1F600}".repeat(MAX_MESSAGE_LEN); assert.equal(astral.length, MAX_MESSAGE_LEN * 2); assert.equal(checkBody(astral).kind, "ok"); assert.equal(checkBody("\u{1F600}".repeat(MAX_MESSAGE_LEN + 1)).kind, "too-long"); }); // Nonces. test("a nonce fits inside the server's cap", () => { for (const r of [0, 0.5, 0.999999]) { const nonce = newNonce(() => r); assert.ok(nonce.length > 0, "empty nonces are dropped by the server"); assert.ok(nonce.length <= MAX_NONCE_LEN, `${nonce} is ${nonce.length} chars`); } }); test("nonces differ across sends", () => { const seen = new Set(); for (let i = 0; i < 200; i++) seen.add(newNonce()); assert.equal(seen.size, 200); }); // Frame parsing. const message = (id: number, extra: Record = {}): string => JSON.stringify({ type: "message", id, room_id: "00000000-0000-0000-0000-000000000000", author_id: "11111111-1111-1111-1111-111111111111", body_html: "hi", created_at: 0, ...extra, }); test("a message frame is flat, tag and all", () => { // serde's internally tagged enum writes `type` into the message object rather // than nesting it, so the parsed frame carries the fields directly. const frame = parseFrame(message(7)); assert.equal(frame?.type, "message"); assert.equal(frame?.type === "message" && frame.id, 7); assert.equal(frame?.type === "message" && frame.body_html, "hi"); }); test("the other three frames parse", () => { assert.deepEqual(parseFrame('{"type":"delete","id":3}'), { type: "delete", id: 3 }); assert.deepEqual(parseFrame('{"type":"purge","author_id":"abc"}'), { type: "purge", author_id: "abc", }); assert.deepEqual(parseFrame('{"type":"gap"}'), { type: "gap" }); }); test("an author rides along when the host resolved one", () => { const frame = parseFrame(message(1, { author: { display_name: "max", flair: "owner" } })); assert.equal(frame?.type === "message" && frame.author?.display_name, "max"); }); test("anything unparseable is null rather than a thrown error", () => { // One bad frame must not tear down a working stream. for (const bad of [ "", "not json", "null", "[]", '"a string"', '{"type":"typing"}', '{"id":1}', '{"type":"delete"}', '{"type":"delete","id":"3"}', '{"type":"purge"}', '{"type":"message","id":"7","author_id":"a","body_html":"x","created_at":0}', '{"type":"message","id":7,"author_id":"a","created_at":0}', ]) { assert.equal(parseFrame(bad), null, `expected null for ${bad || "(empty)"}`); } }); // The ledger: cursor tracking, overlap dedup, nonce reconciliation. test("a fresh room starts at the server-rendered cursor", () => { assert.equal(new ChatLedger(42).cursor, 42); }); test("a cursor that is not a message id starts from the beginning", () => { // It arrives as a data attribute, so Number() can hand back 0 or NaN, and a // NaN in the query string would fail the server's `after` parse on every // single reconnect. for (const bad of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { assert.equal(new ChatLedger(bad).cursor, 0); } }); test("new messages append and advance the cursor", () => { const ledger = new ChatLedger(10); assert.deepEqual(ledger.receive({ id: 11 }), { kind: "append" }); assert.equal(ledger.cursor, 11); assert.deepEqual(ledger.receive({ id: 12 }), { kind: "append" }); assert.equal(ledger.cursor, 12); }); test("the replay overlap is deduplicated by the high-water mark", () => { // ChatStream::open subscribes before it fetches the backlog, so a reconnect // can be handed messages it already has. That overlap is the correct trade // (the alternative loses messages) and this is what pays for it. const ledger = new ChatLedger(10); ledger.receive({ id: 11 }); assert.deepEqual(ledger.receive({ id: 11 }), { kind: "duplicate" }); assert.deepEqual(ledger.receive({ id: 5 }), { kind: "duplicate" }); assert.equal(ledger.cursor, 11, "a duplicate must not move the cursor"); }); test("our own echo reconciles instead of appending twice", () => { const ledger = new ChatLedger(10); ledger.track("n1"); assert.equal(ledger.pending, 1); assert.deepEqual(ledger.receive({ id: 11, nonce: "n1" }), { kind: "reconcile", nonce: "n1" }); assert.equal(ledger.pending, 0); assert.equal(ledger.cursor, 11); }); test("someone else's nonce is not ours", () => { // Every recipient but the sender gets the frame without a nonce, but a client // must not reconcile against one it never sent even if it somehow sees it. const ledger = new ChatLedger(10); ledger.track("mine"); assert.deepEqual(ledger.receive({ id: 11, nonce: "theirs" }), { kind: "append" }); assert.equal(ledger.pending, 1, "our send is still in flight"); }); test("an echo below the cursor still reconciles", () => { // Two people sending at once can have their inserts ordered one way and their // broadcasts the other, so our own message can arrive after a higher id has // already been displayed. Reading the cursor first would call this a // duplicate and leave the sender's own message greyed out forever. const ledger = new ChatLedger(10); ledger.track("mine"); ledger.receive({ id: 12 }); assert.deepEqual(ledger.receive({ id: 11, nonce: "mine" }), { kind: "reconcile", nonce: "mine" }); assert.equal(ledger.cursor, 12, "reconciling an older id must not rewind the cursor"); }); test("an echo delivered twice reconciles once", () => { const ledger = new ChatLedger(10); ledger.track("n1"); ledger.receive({ id: 11, nonce: "n1" }); assert.deepEqual(ledger.receive({ id: 11, nonce: "n1" }), { kind: "duplicate" }); }); test("a failed send stops waiting for an echo", () => { const ledger = new ChatLedger(10); ledger.track("n1"); ledger.untrack("n1"); assert.equal(ledger.pending, 0); // And if the send did land after all, the message is new, not a reconcile. assert.deepEqual(ledger.receive({ id: 11, nonce: "n1" }), { kind: "append" }); }); test("several sends are in flight at once", () => { const ledger = new ChatLedger(0); ledger.track("a"); ledger.track("b"); ledger.track("c"); assert.deepEqual(ledger.receive({ id: 3, nonce: "c" }), { kind: "reconcile", nonce: "c" }); assert.deepEqual(ledger.receive({ id: 1, nonce: "a" }), { kind: "reconcile", nonce: "a" }); assert.equal(ledger.pending, 1); assert.equal(ledger.cursor, 3); }); // Where the stream is opened. test("a first connection asks for no cursor", () => { // Absent means "the recent window"; after=0 would ask the server to replay // the entire retention window a page at a time. assert.equal(streamUrl("rust", 0), "/p/rust/chat/stream"); }); test("a reconnect resumes after the last message handled", () => { assert.equal(streamUrl("rust", 91), "/p/rust/chat/stream?after=91"); }); test("the slug is escaped into the path", () => { assert.equal(streamUrl("a b/c", 0), "/p/a%20b%2Fc/chat/stream"); });