| 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 |
mentionsViewer, |
| 10 |
newNonce, |
| 11 |
parseFrame, |
| 12 |
streamUrl, |
| 13 |
} from "./chat.logic.ts"; |
| 14 |
|
| 15 |
// Body validation. The mirror of MessageBody::parse; the tests below are the |
| 16 |
// same cases livechat's message.rs asserts, so a divergence shows up on one |
| 17 |
// side or the other rather than as a message the composer accepts and the |
| 18 |
// server refuses. |
| 19 |
|
| 20 |
test("a body is trimmed before anything else happens to it", () => { |
| 21 |
const check = checkBody(" hi "); |
| 22 |
assert.equal(check.kind, "ok"); |
| 23 |
assert.equal(check.kind === "ok" && check.body, "hi"); |
| 24 |
}); |
| 25 |
|
| 26 |
test("whitespace alone is empty", () => { |
| 27 |
assert.equal(checkBody("").kind, "empty"); |
| 28 |
assert.equal(checkBody(" \n\t ").kind, "empty"); |
| 29 |
}); |
| 30 |
|
| 31 |
test("the length boundary is inclusive", () => { |
| 32 |
assert.equal(checkBody("x".repeat(MAX_MESSAGE_LEN)).kind, "ok"); |
| 33 |
|
| 34 |
const over = checkBody("x".repeat(MAX_MESSAGE_LEN + 1)); |
| 35 |
assert.equal(over.kind, "too-long"); |
| 36 |
assert.equal(over.kind === "too-long" && over.length, MAX_MESSAGE_LEN + 1); |
| 37 |
}); |
| 38 |
|
| 39 |
test("length is measured after trimming", () => { |
| 40 |
const padded = ` ${"x".repeat(MAX_MESSAGE_LEN)} `; |
| 41 |
assert.equal(checkBody(padded).kind, "ok"); |
| 42 |
}); |
| 43 |
|
| 44 |
test("length counts code points, not UTF-16 units", () => { |
| 45 |
// The whole reason checkBody spreads the string. An astral character is two |
| 46 |
// UTF-16 units and one Rust `char`, so a body of these at the limit is 1000 |
| 47 |
// by String.length and 500 to the server. Counting the wrong one refuses a |
| 48 |
// message the server would have accepted. |
| 49 |
const astral = "\u{1F600}".repeat(MAX_MESSAGE_LEN); |
| 50 |
assert.equal(astral.length, MAX_MESSAGE_LEN * 2); |
| 51 |
assert.equal(checkBody(astral).kind, "ok"); |
| 52 |
|
| 53 |
assert.equal(checkBody("\u{1F600}".repeat(MAX_MESSAGE_LEN + 1)).kind, "too-long"); |
| 54 |
}); |
| 55 |
|
| 56 |
// Nonces. |
| 57 |
|
| 58 |
test("a nonce fits inside the server's cap", () => { |
| 59 |
for (const r of [0, 0.5, 0.999999]) { |
| 60 |
const nonce = newNonce(() => r); |
| 61 |
assert.ok(nonce.length > 0, "empty nonces are dropped by the server"); |
| 62 |
assert.ok(nonce.length <= MAX_NONCE_LEN, `${nonce} is ${nonce.length} chars`); |
| 63 |
} |
| 64 |
}); |
| 65 |
|
| 66 |
test("nonces differ across sends", () => { |
| 67 |
const seen = new Set<string>(); |
| 68 |
for (let i = 0; i < 200; i++) seen.add(newNonce()); |
| 69 |
assert.equal(seen.size, 200); |
| 70 |
}); |
| 71 |
|
| 72 |
// Frame parsing. |
| 73 |
|
| 74 |
const message = (id: number, extra: Record<string, unknown> = {}): string => |
| 75 |
JSON.stringify({ |
| 76 |
type: "message", |
| 77 |
id, |
| 78 |
room_id: "00000000-0000-0000-0000-000000000000", |
| 79 |
author_id: "11111111-1111-1111-1111-111111111111", |
| 80 |
body_html: "hi", |
| 81 |
created_at: 0, |
| 82 |
...extra, |
| 83 |
}); |
| 84 |
|
| 85 |
test("a message frame is flat, tag and all", () => { |
| 86 |
// serde's internally tagged enum writes `type` into the message object rather |
| 87 |
// than nesting it, so the parsed frame carries the fields directly. |
| 88 |
const frame = parseFrame(message(7)); |
| 89 |
assert.equal(frame?.type, "message"); |
| 90 |
assert.equal(frame?.type === "message" && frame.id, 7); |
| 91 |
assert.equal(frame?.type === "message" && frame.body_html, "hi"); |
| 92 |
}); |
| 93 |
|
| 94 |
test("the other four frames parse", () => { |
| 95 |
assert.deepEqual(parseFrame('{"type":"delete","id":3}'), { type: "delete", id: 3 }); |
| 96 |
assert.deepEqual(parseFrame('{"type":"purge","author_id":"abc"}'), { |
| 97 |
type: "purge", |
| 98 |
author_id: "abc", |
| 99 |
}); |
| 100 |
assert.deepEqual(parseFrame('{"type":"wipe"}'), { type: "wipe" }); |
| 101 |
assert.deepEqual(parseFrame('{"type":"gap"}'), { type: "gap" }); |
| 102 |
}); |
| 103 |
|
| 104 |
test("an author rides along when the host resolved one", () => { |
| 105 |
const frame = parseFrame(message(1, { author: { display_name: "max", flair: "owner" } })); |
| 106 |
assert.equal(frame?.type === "message" && frame.author?.display_name, "max"); |
| 107 |
}); |
| 108 |
|
| 109 |
test("anything unparseable is null rather than a thrown error", () => { |
| 110 |
// One bad frame must not tear down a working stream. |
| 111 |
for (const bad of [ |
| 112 |
"", |
| 113 |
"not json", |
| 114 |
"null", |
| 115 |
"[]", |
| 116 |
'"a string"', |
| 117 |
'{"type":"typing"}', |
| 118 |
'{"id":1}', |
| 119 |
'{"type":"delete"}', |
| 120 |
'{"type":"delete","id":"3"}', |
| 121 |
'{"type":"purge"}', |
| 122 |
'{"type":"message","id":"7","author_id":"a","body_html":"x","created_at":0}', |
| 123 |
'{"type":"message","id":7,"author_id":"a","created_at":0}', |
| 124 |
]) { |
| 125 |
assert.equal(parseFrame(bad), null, `expected null for ${bad || "(empty)"}`); |
| 126 |
} |
| 127 |
}); |
| 128 |
|
| 129 |
// The ledger: cursor tracking, overlap dedup, nonce reconciliation. |
| 130 |
|
| 131 |
test("a fresh room starts at the server-rendered cursor", () => { |
| 132 |
assert.equal(new ChatLedger(42).cursor, 42); |
| 133 |
}); |
| 134 |
|
| 135 |
test("a cursor that is not a message id starts from the beginning", () => { |
| 136 |
// It arrives as a data attribute, so Number() can hand back 0 or NaN, and a |
| 137 |
// NaN in the query string would fail the server's `after` parse on every |
| 138 |
// single reconnect. |
| 139 |
for (const bad of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { |
| 140 |
assert.equal(new ChatLedger(bad).cursor, 0); |
| 141 |
} |
| 142 |
}); |
| 143 |
|
| 144 |
test("new messages append and advance the cursor", () => { |
| 145 |
const ledger = new ChatLedger(10); |
| 146 |
assert.deepEqual(ledger.receive({ id: 11 }), { kind: "append" }); |
| 147 |
assert.equal(ledger.cursor, 11); |
| 148 |
assert.deepEqual(ledger.receive({ id: 12 }), { kind: "append" }); |
| 149 |
assert.equal(ledger.cursor, 12); |
| 150 |
}); |
| 151 |
|
| 152 |
test("the replay overlap is deduplicated by the high-water mark", () => { |
| 153 |
// ChatStream::open subscribes before it fetches the backlog, so a reconnect |
| 154 |
// can be handed messages it already has. That overlap is the correct trade |
| 155 |
// (the alternative loses messages) and this is what pays for it. |
| 156 |
const ledger = new ChatLedger(10); |
| 157 |
ledger.receive({ id: 11 }); |
| 158 |
|
| 159 |
assert.deepEqual(ledger.receive({ id: 11 }), { kind: "duplicate" }); |
| 160 |
assert.deepEqual(ledger.receive({ id: 5 }), { kind: "duplicate" }); |
| 161 |
assert.equal(ledger.cursor, 11, "a duplicate must not move the cursor"); |
| 162 |
}); |
| 163 |
|
| 164 |
test("our own echo reconciles instead of appending twice", () => { |
| 165 |
const ledger = new ChatLedger(10); |
| 166 |
ledger.track("n1"); |
| 167 |
assert.equal(ledger.pending, 1); |
| 168 |
|
| 169 |
assert.deepEqual(ledger.receive({ id: 11, nonce: "n1" }), { kind: "reconcile", nonce: "n1" }); |
| 170 |
assert.equal(ledger.pending, 0); |
| 171 |
assert.equal(ledger.cursor, 11); |
| 172 |
}); |
| 173 |
|
| 174 |
test("someone else's nonce is not ours", () => { |
| 175 |
// Every recipient but the sender gets the frame without a nonce, but a client |
| 176 |
// must not reconcile against one it never sent even if it somehow sees it. |
| 177 |
const ledger = new ChatLedger(10); |
| 178 |
ledger.track("mine"); |
| 179 |
assert.deepEqual(ledger.receive({ id: 11, nonce: "theirs" }), { kind: "append" }); |
| 180 |
assert.equal(ledger.pending, 1, "our send is still in flight"); |
| 181 |
}); |
| 182 |
|
| 183 |
test("an echo below the cursor still reconciles", () => { |
| 184 |
// Two people sending at once can have their inserts ordered one way and their |
| 185 |
// broadcasts the other, so our own message can arrive after a higher id has |
| 186 |
// already been displayed. Reading the cursor first would call this a |
| 187 |
// duplicate and leave the sender's own message greyed out forever. |
| 188 |
const ledger = new ChatLedger(10); |
| 189 |
ledger.track("mine"); |
| 190 |
ledger.receive({ id: 12 }); |
| 191 |
|
| 192 |
assert.deepEqual(ledger.receive({ id: 11, nonce: "mine" }), { kind: "reconcile", nonce: "mine" }); |
| 193 |
assert.equal(ledger.cursor, 12, "reconciling an older id must not rewind the cursor"); |
| 194 |
}); |
| 195 |
|
| 196 |
test("an echo delivered twice reconciles once", () => { |
| 197 |
const ledger = new ChatLedger(10); |
| 198 |
ledger.track("n1"); |
| 199 |
ledger.receive({ id: 11, nonce: "n1" }); |
| 200 |
|
| 201 |
assert.deepEqual(ledger.receive({ id: 11, nonce: "n1" }), { kind: "duplicate" }); |
| 202 |
}); |
| 203 |
|
| 204 |
test("a failed send stops waiting for an echo", () => { |
| 205 |
const ledger = new ChatLedger(10); |
| 206 |
ledger.track("n1"); |
| 207 |
ledger.untrack("n1"); |
| 208 |
assert.equal(ledger.pending, 0); |
| 209 |
|
| 210 |
// And if the send did land after all, the message is new, not a reconcile. |
| 211 |
assert.deepEqual(ledger.receive({ id: 11, nonce: "n1" }), { kind: "append" }); |
| 212 |
}); |
| 213 |
|
| 214 |
test("several sends are in flight at once", () => { |
| 215 |
const ledger = new ChatLedger(0); |
| 216 |
ledger.track("a"); |
| 217 |
ledger.track("b"); |
| 218 |
ledger.track("c"); |
| 219 |
|
| 220 |
assert.deepEqual(ledger.receive({ id: 3, nonce: "c" }), { kind: "reconcile", nonce: "c" }); |
| 221 |
assert.deepEqual(ledger.receive({ id: 1, nonce: "a" }), { kind: "reconcile", nonce: "a" }); |
| 222 |
assert.equal(ledger.pending, 1); |
| 223 |
assert.equal(ledger.cursor, 3); |
| 224 |
}); |
| 225 |
|
| 226 |
// Where the stream is opened. |
| 227 |
|
| 228 |
test("a first connection asks for no cursor", () => { |
| 229 |
// Absent means "the recent window"; after=0 would ask the server to replay |
| 230 |
// the entire retention window a page at a time. |
| 231 |
assert.equal(streamUrl("rust", 0), "/p/rust/chat/stream"); |
| 232 |
}); |
| 233 |
|
| 234 |
test("a reconnect resumes after the last message handled", () => { |
| 235 |
assert.equal(streamUrl("rust", 91), "/p/rust/chat/stream?after=91"); |
| 236 |
}); |
| 237 |
|
| 238 |
test("the slug is escaped into the path", () => { |
| 239 |
assert.equal(streamUrl("a b/c", 0), "/p/a%20b%2Fc/chat/stream"); |
| 240 |
}); |
| 241 |
|
| 242 |
// Mention highlight. In-room only, never a notification, and it has to agree |
| 243 |
// with docengine's `extract_mentions` about what a username is: the server |
| 244 |
// leaves `@name` as plain text in the rendered body precisely so this can find |
| 245 |
// it, and a client with a different idea of the boundaries would highlight |
| 246 |
// messages the forum's own mention handling does not consider mentions. |
| 247 |
|
| 248 |
test("a message naming the viewer is a mention", () => { |
| 249 |
assert.equal(mentionsViewer("<p>hey @max, look</p>", "max"), true); |
| 250 |
}); |
| 251 |
|
| 252 |
test("a message naming somebody else is not", () => { |
| 253 |
assert.equal(mentionsViewer("<p>hey @alice</p>", "max"), false); |
| 254 |
}); |
| 255 |
|
| 256 |
test("a longer name starting with the viewer's is not a mention", () => { |
| 257 |
// The regex is greedy, so `@maxwell` yields the single name `maxwell`. |
| 258 |
// A prefix match here would highlight for the wrong person on every message. |
| 259 |
assert.equal(mentionsViewer("<p>@maxwell said so</p>", "max"), false); |
| 260 |
}); |
| 261 |
|
| 262 |
test("case does not decide whether you were named", () => { |
| 263 |
assert.equal(mentionsViewer("<p>@MAX</p>", "max"), true); |
| 264 |
assert.equal(mentionsViewer("<p>@max</p>", "MAX"), true); |
| 265 |
}); |
| 266 |
|
| 267 |
test("the username charset stops where docengine's does", () => { |
| 268 |
// `[A-Za-z0-9_-]`, so a dot ends the name rather than continuing it. |
| 269 |
assert.equal(mentionsViewer("<p>@max.johnson</p>", "max"), true); |
| 270 |
assert.equal(mentionsViewer("<p>@max_j</p>", "max_j"), true); |
| 271 |
assert.equal(mentionsViewer("<p>@max-j</p>", "max-j"), true); |
| 272 |
}); |
| 273 |
|
| 274 |
test("a name inside a code span is written about, not addressed", () => { |
| 275 |
assert.equal(mentionsViewer("<p>grep for <code>@max</code></p>", "max"), false); |
| 276 |
}); |
| 277 |
|
| 278 |
test("a code span does not swallow the rest of the message", () => { |
| 279 |
assert.equal(mentionsViewer("<p><code>@alice</code> and @max</p>", "max"), true); |
| 280 |
}); |
| 281 |
|
| 282 |
test("markup inside a code span does not end the exclusion early", () => { |
| 283 |
assert.equal(mentionsViewer("<p><code><em>x</em> @max</code></p>", "max"), false); |
| 284 |
}); |
| 285 |
|
| 286 |
test("an at-sign inside an attribute is not a mention", () => { |
| 287 |
// A link is the common case: nothing in an href addresses anyone in the room. |
| 288 |
assert.equal( |
| 289 |
mentionsViewer('<p><a href="mailto:@max" rel="nofollow noopener">mail</a></p>', "max"), |
| 290 |
false, |
| 291 |
); |
| 292 |
}); |
| 293 |
|
| 294 |
test("a name split by markup stays split", () => { |
| 295 |
// `@ali*ce*` renders as `@ali<em>ce</em>`, and docengine reading the source |
| 296 |
// finds `ali`. Rejoining the halves would find `alice`. |
| 297 |
assert.equal(mentionsViewer("<p>@ali<em>ce</em></p>", "alice"), false); |
| 298 |
assert.equal(mentionsViewer("<p>@ali<em>ce</em></p>", "ali"), true); |
| 299 |
}); |
| 300 |
|
| 301 |
test("a logged-out reader is never mentioned", () => { |
| 302 |
assert.equal(mentionsViewer("<p>@max</p>", null), false); |
| 303 |
assert.equal(mentionsViewer("<p>@max</p>", ""), false); |
| 304 |
}); |
| 305 |
|