| 1 | import assert from "node:assert/strict"; |
| 2 | import { readFileSync, readdirSync } from "node:fs"; |
| 3 | import { DatabaseSync } from "node:sqlite"; |
| 4 | import { test } from "node:test"; |
| 5 | |
| 6 | import { G1T_KEY } from "@g1t/contracts/chat"; |
| 7 | |
| 8 | import { markReadUpTo } from "./read.ts"; |
| 9 | import { tally } from "./unread.ts"; |
| 10 | |
| 11 | /** D1, as far as the read mark uses it, over node's SQLite with chat's migrations. */ |
| 12 | function fakeD1(): D1Database & { raw: DatabaseSync } { |
| 13 | const db = new DatabaseSync(":memory:"); |
| 14 | const dir = new URL("../migrations/", import.meta.url); |
| 15 | for (const file of readdirSync(dir).sort()) db.exec(readFileSync(new URL(file, dir), "utf8")); |
| 16 | const statement = (sql: string, params: unknown[] = []): Record<string, unknown> => ({ |
| 17 | bind: (...values: unknown[]) => statement(sql, values), |
| 18 | run: async () => ({ success: true, meta: { changes: Number(db.prepare(sql).run(...(params as never[])).changes) }, results: [] }), |
| 19 | }); |
| 20 | return { raw: db, prepare: (sql: string) => statement(sql) } as unknown as D1Database & { raw: DatabaseSync }; |
| 21 | } |
| 22 | |
| 23 | const AT = "2026-10-20T12:00:00.000Z"; |
| 24 | const ANA = "user:u_ana"; |
| 25 | |
| 26 | /** Ana's direct message with g1t: her question (msg_01) and g1t's answer (msg_02), unread from msg_00 on. */ |
| 27 | function dm() { |
| 28 | const d1 = fakeD1(); |
| 29 | d1.raw |
| 30 | .prepare("INSERT INTO channels (id, workspace_id, kind, name, created_by, created_at, last_message_at) VALUES ('chn_dm', 'wsp_1', 'dm', NULL, ?, ?, ?)") |
| 31 | .run(ANA, AT, AT); |
| 32 | for (const principal of [ANA, G1T_KEY]) |
| 33 | d1.raw.prepare("INSERT INTO channel_members (channel_id, principal, last_read_id, joined_at) VALUES ('chn_dm', ?, 'msg_00', ?)").run(principal, AT); |
| 34 | for (const [id, author] of [ |
| 35 | ["msg_01", ANA], |
| 36 | ["msg_02", G1T_KEY], |
| 37 | ]) |
| 38 | d1.raw.prepare("INSERT INTO messages (id, channel_id, author, body, created_at) VALUES (?, 'chn_dm', ?, 'text', ?)").run(id, author, AT); |
| 39 | return d1; |
| 40 | } |
| 41 | |
| 42 | const mark = (d1: ReturnType<typeof dm>) => |
| 43 | (d1.raw.prepare("SELECT last_read_id FROM channel_members WHERE channel_id = 'chn_dm' AND principal = ?").get(ANA) as { last_read_id: string }).last_read_id; |
| 44 | |
| 45 | const unread = (d1: ReturnType<typeof dm>) => { |
| 46 | const rows = d1.raw.prepare("SELECT channel_id, id, author, NULL AS mentions FROM messages WHERE id > ?").all(mark(d1)) as never[]; |
| 47 | return tally(rows, ANA, "ana", new Map([["chn_dm", mark(d1)]])).get("chn_dm")?.unread ?? 0; |
| 48 | }; |
| 49 | |
| 50 | test("a read from any surface (Home's Summarize, the Ask panel, Chat) moves the read mark, and the answer is no longer unread", async () => { |
| 51 | const d1 = dm(); |
| 52 | assert.equal(unread(d1), 1, "g1t's answer is unread; Ana's own question is not"); |
| 53 | assert.equal(await markReadUpTo(d1, "chn_dm", ANA, "msg_02"), true); |
| 54 | assert.equal(mark(d1), "msg_02"); |
| 55 | assert.equal(unread(d1), 0); |
| 56 | }); |
| 57 | |
| 58 | test("the read mark only moves forward, and says when it did not move", async () => { |
| 59 | const d1 = dm(); |
| 60 | await markReadUpTo(d1, "chn_dm", ANA, "msg_02"); |
| 61 | assert.equal(await markReadUpTo(d1, "chn_dm", ANA, "msg_01"), false, "an older surface reading late does not mark the answer unread"); |
| 62 | assert.equal(await markReadUpTo(d1, "chn_dm", ANA, "msg_02"), false, "the same read twice is told once"); |
| 63 | assert.equal(mark(d1), "msg_02"); |
| 64 | }); |
| 65 | |
| 66 | test("a read marks only the reader's own place", async () => { |
| 67 | const d1 = dm(); |
| 68 | assert.equal(await markReadUpTo(d1, "chn_dm", "user:u_ben", "msg_02"), false, "not a member: nothing moves"); |
| 69 | await markReadUpTo(d1, "chn_dm", ANA, "msg_02"); |
| 70 | const g1t = d1.raw.prepare("SELECT last_read_id FROM channel_members WHERE principal = ?").get(G1T_KEY) as { last_read_id: string }; |
| 71 | assert.equal(g1t.last_read_id, "msg_00"); |
| 72 | }); |