Skip to content
96 linesCodeBlameRaw
1import assert from "node:assert/strict";
2import { test } from "node:test";
3
4import type { MemberProfile } from "@g1t/contracts";
5
6import { conversationHref, countsAfterRead, messageDeliveries, preview, recipients, type Person } from "./notify.ts";
7
8const person = (id: string, username: string, muted = false): Person => ({ key: `user:${id}`, user_id: id, username, muted });
9const ana = person("u1", "ana");
10const bo = person("u2", "bo");
11const cy = person("u3", "cy", true);
12
13const kinds = (list: ReturnType<typeof recipients>) => Object.fromEntries(list.map((r) => [r.user_id, r.kind]));
14
15test("a direct message notifies everyone else in it, muted or not, never the author", () => {
16 const out = recipients({ author: "user:u1", channelKind: "dm", people: [ana, bo, cy], mentioned: [], thread: null });
17 assert.deepEqual(kinds(out), { u2: "dm", u3: "dm" });
18});
19
20test("in a channel, a mention notifies the person named even when muted; the rest only count", () => {
21 const out = recipients({ author: "user:u1", channelKind: "channel", people: [ana, bo, cy], mentioned: ["CY"], thread: null });
22 assert.deepEqual(kinds(out), { u2: null, u3: "mention" });
23 assert.deepEqual(
24 out.map((r) => [r.user_id, r.mentioned, r.muted]),
25 [
26 ["u2", false, false],
27 ["u3", true, true],
28 ],
29 );
30});
31
32test("a reply notifies the people in its thread unless they muted the conversation", () => {
33 const out = recipients({ author: "user:u1", channelKind: "channel", people: [ana, bo, cy], mentioned: [], thread: new Set(["user:u1", "user:u2", "user:u3"]) });
34 assert.deepEqual(kinds(out), { u2: "thread_reply", u3: null });
35});
36
37test("an agent's message notifies the people in its DM and counts for everyone", () => {
38 const out = recipients({ author: "agent:a1", channelKind: "dm", people: [ana], mentioned: [], thread: null });
39 assert.deepEqual(kinds(out), { u1: "dm" });
40});
41
42test("deliveries: counts for everyone, a notification for those it is for, the author's count cleared", () => {
43 const author: MemberProfile = { kind: "user", id: "u1", name: "ana", display_name: "Ana Lima", avatar: "abc", role: null, avatar_seed: null };
44 const items = messageDeliveries({
45 slug: "acme",
46 channel: { id: "chn_1", kind: "channel", name: "design" },
47 message: { id: "msg_1", author: "user:u1", body: "hey **@bo** look", card_title: null, thread_root: null, created_at: "2026-10-08T00:00:00Z" },
48 author,
49 recipients: recipients({ author: "user:u1", channelKind: "channel", people: [ana, bo, cy], mentioned: ["bo"], thread: null }),
50 });
51 assert.equal(items.length, 3);
52 const [toBo, toCy, self] = items;
53 assert.deepEqual(toBo.counts, { channel_id: "chn_1", unread: 1, mentions: 1, muted: false });
54 assert.equal(toBo.notification?.kind, "mention");
55 assert.equal(toBo.notification?.title, "Ana Lima in #design");
56 assert.equal(toBo.notification?.body, "hey @bo look");
57 assert.equal(toBo.notification?.href, "/acme/-/chat/design");
58 assert.equal(toBo.notification?.actor.avatar, "abc");
59 assert.deepEqual(toCy.counts, { channel_id: "chn_1", unread: 1, mentions: 0, muted: true });
60 assert.equal(toCy.notification, null);
61 assert.deepEqual(self, { user_id: "u1", workspace: "acme", counts: { channel_id: "chn_1", unread: 0, mentions: 0, set: true }, notification: null });
62});
63
64test("a person without a display name is named by their username as they wrote it", () => {
65 const author: MemberProfile = { kind: "user", id: "u1", name: "ana", display_username: "Ana", display_name: "", avatar: null, role: null, avatar_seed: null };
66 const [toBo] = messageDeliveries({
67 slug: "acme",
68 channel: { id: "chn_9", kind: "dm", name: null },
69 message: { id: "msg_2", author: "user:u1", body: "hi", card_title: null, thread_root: null, created_at: "2026-10-08T00:00:00Z" },
70 author,
71 recipients: recipients({ author: "user:u1", channelKind: "dm", people: [ana, bo], mentioned: [], thread: null }),
72 });
73 assert.equal(toBo.notification?.title, "Ana");
74 assert.equal(toBo.notification?.actor.name, "Ana");
75});
76
77test("a reply links to its thread; a DM to the DM", () => {
78 assert.equal(conversationHref("acme", { id: "chn_9", kind: "dm", name: null }, null), "/acme/-/chat/dm/chn_9");
79 assert.equal(conversationHref("acme", { id: "chn_1", kind: "channel", name: "ops" }, "msg_1"), "/acme/-/chat/ops?thread=msg_1");
80});
81
82test("previews are one short line without markup", () => {
83 assert.equal(preview("**bold** and `code`\n\nnext [link](https://x)"), "bold and code next link");
84 assert.equal(preview("```\nlong code\n```"), "[code]");
85 assert.equal(preview("x".repeat(300)).length, 140);
86});
87
88test("counts after a read: what is left past the mark, mentions included, never your own", () => {
89 const rows = [
90 { channel_id: "c", id: "m3", author: "user:u2", mentions: " ana " },
91 { channel_id: "c", id: "m4", author: "user:u1", mentions: "" },
92 { channel_id: "c", id: "m5", author: "agent:a1", mentions: "" },
93 ];
94 assert.deepEqual(countsAfterRead(rows, "c", "user:u1", "ana", "m2"), { unread: 2, mentions: 1 });
95 assert.deepEqual(countsAfterRead([], "c", "user:u1", "ana", "m9"), { unread: 0, mentions: 0 });
96});