Skip to content
145 linesCodeBlameRaw
1import assert from "node:assert/strict";
2import { test } from "node:test";
3
4import type { FeedNotification } from "@g1t/contracts";
5
6import { ANDROID_CHANNEL, EXPO_BATCH, EXPO_PUSH_URL, cleanDevice, expoMessages, readTickets, sendExpo } from "./expopush.ts";
7import { pushPayload } from "./prefs.ts";
8
9const TOKEN = "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]";
10
11function notification(over: Partial<FeedNotification> = {}): FeedNotification {
12 return {
13 id: "m1",
14 kind: "mention",
15 workspace: "acme",
16 title: "Ana in #general",
17 body: "ship it?",
18 href: "/acme/chat/general",
19 actor: { kind: "user", id: "u1", name: "Ana", avatar: null, avatar_seed: null },
20 channel_id: "chn_1",
21 thread_root: "msg_0",
22 created_at: "2026-10-11T12:00:00Z",
23 ...over,
24 };
25}
26
27test("a device is an Expo push token, a platform and maybe a name", () => {
28 assert.deepEqual(cleanDevice({ token: TOKEN, platform: "android", name: " Pixel 9 " }), { token: TOKEN, platform: "android", name: "Pixel 9" });
29 assert.deepEqual(cleanDevice({ token: "ExpoPushToken[abc-123_x]", platform: "ios" }), { token: "ExpoPushToken[abc-123_x]", platform: "ios", name: null });
30 assert.equal(cleanDevice({ token: TOKEN, platform: "ios", name: "x".repeat(300) })?.name?.length, 100);
31 assert.equal(cleanDevice({ token: TOKEN, platform: "ios", name: 7 })?.name, null);
32 for (const bad of [
33 null,
34 "ExponentPushToken[x]",
35 { token: TOKEN, platform: "web" },
36 { token: TOKEN },
37 { token: "fcm:abcdef", platform: "android" },
38 { token: "ExponentPushToken[]", platform: "android" },
39 { token: "ExponentPushToken[a b]", platform: "android" },
40 { token: "ExponentPushToken[abc]x", platform: "android" },
41 { token: `ExponentPushToken[${"a".repeat(200)}]`, platform: "android" },
42 { token: 42, platform: "android" },
43 ]) {
44 assert.equal(cleanDevice(bad), null, JSON.stringify(bad));
45 }
46});
47
48test("a message says what a browser push says, with where it is in chat", () => {
49 const n = notification({ body: "x".repeat(300) });
50 const [message] = expoMessages([TOKEN], pushPayload(n), n);
51 assert.deepEqual(message, {
52 to: TOKEN,
53 title: "Ana in #general",
54 body: "x".repeat(240),
55 sound: "default",
56 priority: "high",
57 ttl: 12 * 3600,
58 channelId: ANDROID_CHANNEL,
59 threadId: "chat:chn_1",
60 data: { href: "/acme/chat/general", workspace: "acme", kind: "mention", tag: "chat:chn_1", channel_id: "chn_1", thread_root: "msg_0", card: null },
61 });
62 assert.equal(ANDROID_CHANNEL, "messages");
63});
64
65test("what is not said to you goes at normal priority; one with no conversation threads by itself", () => {
66 const inbox = notification({ kind: "inbox", id: "in_9", channel_id: null, thread_root: null });
67 const [message] = expoMessages([TOKEN], pushPayload(inbox), inbox);
68 assert.equal(message.priority, "normal");
69 assert.equal(message.threadId, "inbox:in_9");
70 assert.equal(message.data.channel_id, null);
71 assert.equal(message.data.thread_root, null);
72});
73
74test("a card's place travels with it", () => {
75 const n = notification({
76 card: { channel_id: "chn_1", message_id: "msg_5", actions: [{ id: "open", label: "Open", style: "default", confirm: null, input: null, href: "/x" }] },
77 });
78 const [message] = expoMessages([TOKEN], pushPayload(n), n);
79 assert.deepEqual(message.data.card, { channel_id: "chn_1", message_id: "msg_5" });
80 assert.equal(message.threadId, "mention:m1");
81});
82
83test("tickets: ok counts, DeviceNotRegistered is gone, anything else is an error", () => {
84 const tokens = ["ExpoPushToken[a]", "ExpoPushToken[b]", "ExpoPushToken[c]", "ExpoPushToken[d]"];
85 const result = readTickets(tokens, {
86 data: [
87 { status: "ok", id: "t1" },
88 { status: "error", message: "gone", details: { error: "DeviceNotRegistered" } },
89 { status: "error", message: "too big", details: { error: "MessageTooBig" } },
90 ],
91 });
92 assert.equal(result.sent, 1);
93 assert.deepEqual(result.gone, ["ExpoPushToken[b]"]);
94 assert.deepEqual(result.errors, ["MessageTooBig", "no ticket"]);
95 const refused = readTickets(tokens, { errors: [{ code: "PUSH_TOO_MANY_EXPERIENCE_IDS" }] });
96 assert.equal(refused.sent, 0);
97 assert.deepEqual(refused.gone, []);
98 assert.equal(refused.errors.length, 1);
99});
100
101test("sending: 100 to a request, JSON with Expo's headers, the access token only when set", async () => {
102 const seen: { url: string; init: RequestInit }[] = [];
103 const fetcher = (async (url: string, init: RequestInit) => {
104 seen.push({ url, init });
105 const messages = JSON.parse(String(init.body)) as { to: string }[];
106 const data = messages.map((m) =>
107 m.to.endsWith("[t7]") ? { status: "error", message: "gone", details: { error: "DeviceNotRegistered" } } : { status: "ok", id: m.to },
108 );
109 return Response.json({ data });
110 }) as unknown as typeof fetch;
111 const tokens = Array.from({ length: 250 }, (_, i) => `ExpoPushToken[t${i}]`);
112 const n = notification();
113 const result = await sendExpo(tokens, pushPayload(n), n, { accessToken: "secret" }, fetcher);
114 assert.deepEqual(
115 seen.map((s) => JSON.parse(String(s.init.body)).length),
116 [EXPO_BATCH, EXPO_BATCH, 50],
117 );
118 assert.equal(seen[0].url, EXPO_PUSH_URL);
119 assert.equal(seen[0].init.method, "POST");
120 assert.deepEqual(seen[0].init.headers, { accept: "application/json", "content-type": "application/json", authorization: "Bearer secret" });
121 assert.deepEqual(result, { sent: 249, gone: ["ExpoPushToken[t7]"], errors: [] });
122
123 seen.length = 0;
124 await sendExpo(tokens.slice(0, 1), pushPayload(n), n, {}, fetcher);
125 assert.equal((seen[0].init.headers as Record<string, string>).authorization, undefined);
126});
127
128test("a failed request counts nothing and the others still count", async () => {
129 let call = 0;
130 const fetcher = (async (_url: string, init: RequestInit) => {
131 call++;
132 if (call === 1) throw new Error("network down");
133 if (call === 2) return Response.json({ errors: [{ code: "RATE_LIMIT" }] }, { status: 429 });
134 const messages = JSON.parse(String(init.body)) as unknown[];
135 return Response.json({ data: messages.map(() => ({ status: "ok", id: "x" })) });
136 }) as unknown as typeof fetch;
137 const tokens = Array.from({ length: 210 }, (_, i) => `ExpoPushToken[t${i}]`);
138 const n = notification();
139 const result = await sendExpo(tokens, pushPayload(n), n, {}, fetcher);
140 assert.equal(result.sent, 10);
141 assert.deepEqual(result.gone, []);
142 assert.equal(result.errors.length, 2);
143 assert.match(result.errors.join("\n"), /network down/);
144 assert.match(result.errors.join("\n"), /429/);
145});