Skip to main content

max / makenotwork

8.4 KB · 239 lines History Blame Raw
1 import assert from "node:assert/strict";
2 import { test } from "node:test";
3
4 import {
5 ChatLedger,
6 MAX_MESSAGE_LEN,
7 MAX_NONCE_LEN,
8 checkBody,
9 newNonce,
10 parseFrame,
11 streamUrl,
12 } from "./chat.logic.ts";
13
14 // Body validation. The mirror of MessageBody::parse; the tests below are the
15 // same cases livechat's message.rs asserts, so a divergence shows up on one
16 // side or the other rather than as a message the composer accepts and the
17 // server refuses.
18
19 test("a body is trimmed before anything else happens to it", () => {
20 const check = checkBody(" hi ");
21 assert.equal(check.kind, "ok");
22 assert.equal(check.kind === "ok" && check.body, "hi");
23 });
24
25 test("whitespace alone is empty", () => {
26 assert.equal(checkBody("").kind, "empty");
27 assert.equal(checkBody(" \n\t ").kind, "empty");
28 });
29
30 test("the length boundary is inclusive", () => {
31 assert.equal(checkBody("x".repeat(MAX_MESSAGE_LEN)).kind, "ok");
32
33 const over = checkBody("x".repeat(MAX_MESSAGE_LEN + 1));
34 assert.equal(over.kind, "too-long");
35 assert.equal(over.kind === "too-long" && over.length, MAX_MESSAGE_LEN + 1);
36 });
37
38 test("length is measured after trimming", () => {
39 const padded = ` ${"x".repeat(MAX_MESSAGE_LEN)} `;
40 assert.equal(checkBody(padded).kind, "ok");
41 });
42
43 test("length counts code points, not UTF-16 units", () => {
44 // The whole reason checkBody spreads the string. An astral character is two
45 // UTF-16 units and one Rust `char`, so a body of these at the limit is 1000
46 // by String.length and 500 to the server. Counting the wrong one refuses a
47 // message the server would have accepted.
48 const astral = "\u{1F600}".repeat(MAX_MESSAGE_LEN);
49 assert.equal(astral.length, MAX_MESSAGE_LEN * 2);
50 assert.equal(checkBody(astral).kind, "ok");
51
52 assert.equal(checkBody("\u{1F600}".repeat(MAX_MESSAGE_LEN + 1)).kind, "too-long");
53 });
54
55 // Nonces.
56
57 test("a nonce fits inside the server's cap", () => {
58 for (const r of [0, 0.5, 0.999999]) {
59 const nonce = newNonce(() => r);
60 assert.ok(nonce.length > 0, "empty nonces are dropped by the server");
61 assert.ok(nonce.length <= MAX_NONCE_LEN, `${nonce} is ${nonce.length} chars`);
62 }
63 });
64
65 test("nonces differ across sends", () => {
66 const seen = new Set<string>();
67 for (let i = 0; i < 200; i++) seen.add(newNonce());
68 assert.equal(seen.size, 200);
69 });
70
71 // Frame parsing.
72
73 const message = (id: number, extra: Record<string, unknown> = {}): string =>
74 JSON.stringify({
75 type: "message",
76 id,
77 room_id: "00000000-0000-0000-0000-000000000000",
78 author_id: "11111111-1111-1111-1111-111111111111",
79 body_html: "hi",
80 created_at: 0,
81 ...extra,
82 });
83
84 test("a message frame is flat, tag and all", () => {
85 // serde's internally tagged enum writes `type` into the message object rather
86 // than nesting it, so the parsed frame carries the fields directly.
87 const frame = parseFrame(message(7));
88 assert.equal(frame?.type, "message");
89 assert.equal(frame?.type === "message" && frame.id, 7);
90 assert.equal(frame?.type === "message" && frame.body_html, "hi");
91 });
92
93 test("the other three frames parse", () => {
94 assert.deepEqual(parseFrame('{"type":"delete","id":3}'), { type: "delete", id: 3 });
95 assert.deepEqual(parseFrame('{"type":"purge","author_id":"abc"}'), {
96 type: "purge",
97 author_id: "abc",
98 });
99 assert.deepEqual(parseFrame('{"type":"gap"}'), { type: "gap" });
100 });
101
102 test("an author rides along when the host resolved one", () => {
103 const frame = parseFrame(message(1, { author: { display_name: "max", flair: "owner" } }));
104 assert.equal(frame?.type === "message" && frame.author?.display_name, "max");
105 });
106
107 test("anything unparseable is null rather than a thrown error", () => {
108 // One bad frame must not tear down a working stream.
109 for (const bad of [
110 "",
111 "not json",
112 "null",
113 "[]",
114 '"a string"',
115 '{"type":"typing"}',
116 '{"id":1}',
117 '{"type":"delete"}',
118 '{"type":"delete","id":"3"}',
119 '{"type":"purge"}',
120 '{"type":"message","id":"7","author_id":"a","body_html":"x","created_at":0}',
121 '{"type":"message","id":7,"author_id":"a","created_at":0}',
122 ]) {
123 assert.equal(parseFrame(bad), null, `expected null for ${bad || "(empty)"}`);
124 }
125 });
126
127 // The ledger: cursor tracking, overlap dedup, nonce reconciliation.
128
129 test("a fresh room starts at the server-rendered cursor", () => {
130 assert.equal(new ChatLedger(42).cursor, 42);
131 });
132
133 test("a cursor that is not a message id starts from the beginning", () => {
134 // It arrives as a data attribute, so Number() can hand back 0 or NaN, and a
135 // NaN in the query string would fail the server's `after` parse on every
136 // single reconnect.
137 for (const bad of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
138 assert.equal(new ChatLedger(bad).cursor, 0);
139 }
140 });
141
142 test("new messages append and advance the cursor", () => {
143 const ledger = new ChatLedger(10);
144 assert.deepEqual(ledger.receive({ id: 11 }), { kind: "append" });
145 assert.equal(ledger.cursor, 11);
146 assert.deepEqual(ledger.receive({ id: 12 }), { kind: "append" });
147 assert.equal(ledger.cursor, 12);
148 });
149
150 test("the replay overlap is deduplicated by the high-water mark", () => {
151 // ChatStream::open subscribes before it fetches the backlog, so a reconnect
152 // can be handed messages it already has. That overlap is the correct trade
153 // (the alternative loses messages) and this is what pays for it.
154 const ledger = new ChatLedger(10);
155 ledger.receive({ id: 11 });
156
157 assert.deepEqual(ledger.receive({ id: 11 }), { kind: "duplicate" });
158 assert.deepEqual(ledger.receive({ id: 5 }), { kind: "duplicate" });
159 assert.equal(ledger.cursor, 11, "a duplicate must not move the cursor");
160 });
161
162 test("our own echo reconciles instead of appending twice", () => {
163 const ledger = new ChatLedger(10);
164 ledger.track("n1");
165 assert.equal(ledger.pending, 1);
166
167 assert.deepEqual(ledger.receive({ id: 11, nonce: "n1" }), { kind: "reconcile", nonce: "n1" });
168 assert.equal(ledger.pending, 0);
169 assert.equal(ledger.cursor, 11);
170 });
171
172 test("someone else's nonce is not ours", () => {
173 // Every recipient but the sender gets the frame without a nonce, but a client
174 // must not reconcile against one it never sent even if it somehow sees it.
175 const ledger = new ChatLedger(10);
176 ledger.track("mine");
177 assert.deepEqual(ledger.receive({ id: 11, nonce: "theirs" }), { kind: "append" });
178 assert.equal(ledger.pending, 1, "our send is still in flight");
179 });
180
181 test("an echo below the cursor still reconciles", () => {
182 // Two people sending at once can have their inserts ordered one way and their
183 // broadcasts the other, so our own message can arrive after a higher id has
184 // already been displayed. Reading the cursor first would call this a
185 // duplicate and leave the sender's own message greyed out forever.
186 const ledger = new ChatLedger(10);
187 ledger.track("mine");
188 ledger.receive({ id: 12 });
189
190 assert.deepEqual(ledger.receive({ id: 11, nonce: "mine" }), { kind: "reconcile", nonce: "mine" });
191 assert.equal(ledger.cursor, 12, "reconciling an older id must not rewind the cursor");
192 });
193
194 test("an echo delivered twice reconciles once", () => {
195 const ledger = new ChatLedger(10);
196 ledger.track("n1");
197 ledger.receive({ id: 11, nonce: "n1" });
198
199 assert.deepEqual(ledger.receive({ id: 11, nonce: "n1" }), { kind: "duplicate" });
200 });
201
202 test("a failed send stops waiting for an echo", () => {
203 const ledger = new ChatLedger(10);
204 ledger.track("n1");
205 ledger.untrack("n1");
206 assert.equal(ledger.pending, 0);
207
208 // And if the send did land after all, the message is new, not a reconcile.
209 assert.deepEqual(ledger.receive({ id: 11, nonce: "n1" }), { kind: "append" });
210 });
211
212 test("several sends are in flight at once", () => {
213 const ledger = new ChatLedger(0);
214 ledger.track("a");
215 ledger.track("b");
216 ledger.track("c");
217
218 assert.deepEqual(ledger.receive({ id: 3, nonce: "c" }), { kind: "reconcile", nonce: "c" });
219 assert.deepEqual(ledger.receive({ id: 1, nonce: "a" }), { kind: "reconcile", nonce: "a" });
220 assert.equal(ledger.pending, 1);
221 assert.equal(ledger.cursor, 3);
222 });
223
224 // Where the stream is opened.
225
226 test("a first connection asks for no cursor", () => {
227 // Absent means "the recent window"; after=0 would ask the server to replay
228 // the entire retention window a page at a time.
229 assert.equal(streamUrl("rust", 0), "/p/rust/chat/stream");
230 });
231
232 test("a reconnect resumes after the last message handled", () => {
233 assert.equal(streamUrl("rust", 91), "/p/rust/chat/stream?after=91");
234 });
235
236 test("the slug is escaped into the path", () => {
237 assert.equal(streamUrl("a b/c", 0), "/p/a%20b%2Fc/chat/stream");
238 });
239