@vex-chat/spire 1.10.3 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -230,4 +230,128 @@ describe("Database", () => {
230
230
  });
231
231
  });
232
232
  });
233
+
234
+ describe("notification subscriptions", () => {
235
+ it("upserts and filters Expo push subscriptions by event", async () => {
236
+ expect.assertions(7);
237
+
238
+ const provider = new Database(options);
239
+ await new Promise<void>((resolve, reject) => {
240
+ provider.once("ready", () => {
241
+ void (async () => {
242
+ try {
243
+ const created =
244
+ await provider.saveNotificationSubscription({
245
+ channel: "expo",
246
+ deviceID,
247
+ events: ["mail"],
248
+ platform: "ios",
249
+ token: "ExponentPushToken[test]",
250
+ userID,
251
+ });
252
+ expect(created.events).toEqual(["mail"]);
253
+ expect(created.enabled).toBe(true);
254
+
255
+ const mailSubs =
256
+ await provider.retrieveNotificationSubscriptions(
257
+ { deviceID, event: "mail", userID },
258
+ );
259
+ expect(mailSubs).toHaveLength(1);
260
+
261
+ const deviceSubs =
262
+ await provider.retrieveNotificationSubscriptions(
263
+ {
264
+ deviceID,
265
+ event: "deviceRequest",
266
+ userID,
267
+ },
268
+ );
269
+ expect(deviceSubs).toHaveLength(0);
270
+
271
+ const updated =
272
+ await provider.saveNotificationSubscription({
273
+ channel: "expo",
274
+ deviceID,
275
+ events: ["deviceRequest"],
276
+ platform: "ios",
277
+ token: "ExponentPushToken[test]",
278
+ userID,
279
+ });
280
+ expect(updated.subscriptionID).toBe(
281
+ created.subscriptionID,
282
+ );
283
+
284
+ const mailSubsAfterUpdate =
285
+ await provider.retrieveNotificationSubscriptions(
286
+ { deviceID, event: "mail", userID },
287
+ );
288
+ expect(mailSubsAfterUpdate).toHaveLength(0);
289
+
290
+ const deviceSubsAfterUpdate =
291
+ await provider.retrieveNotificationSubscriptions(
292
+ {
293
+ deviceID,
294
+ event: "deviceRequest",
295
+ userID,
296
+ },
297
+ );
298
+ expect(deviceSubsAfterUpdate).toHaveLength(1);
299
+
300
+ await provider.close();
301
+ resolve();
302
+ } catch (e: unknown) {
303
+ reject(
304
+ e instanceof Error ? e : new Error(String(e)),
305
+ );
306
+ }
307
+ })();
308
+ });
309
+ });
310
+ });
311
+
312
+ it("stores one Expo push subscription for concurrent identical saves", async () => {
313
+ expect.assertions(3);
314
+
315
+ const provider = new Database(options);
316
+ await new Promise<void>((resolve, reject) => {
317
+ provider.once("ready", () => {
318
+ void (async () => {
319
+ try {
320
+ const input = {
321
+ channel: "expo" as const,
322
+ deviceID,
323
+ events: ["mail"],
324
+ platform: "android",
325
+ token: "ExponentPushToken[dedupe]",
326
+ userID,
327
+ };
328
+ const [first, second] = await Promise.all([
329
+ provider.saveNotificationSubscription(input),
330
+ provider.saveNotificationSubscription(input),
331
+ ]);
332
+ expect(second.subscriptionID).toBe(
333
+ first.subscriptionID,
334
+ );
335
+
336
+ const mailSubs =
337
+ await provider.retrieveNotificationSubscriptions(
338
+ { deviceID, event: "mail", userID },
339
+ );
340
+ expect(mailSubs).toHaveLength(1);
341
+ expect(mailSubs[0]?.subscriptionID).toBe(
342
+ first.subscriptionID,
343
+ );
344
+
345
+ await provider.close();
346
+ resolve();
347
+ } catch (e: unknown) {
348
+ reject(
349
+ e instanceof Error ? e : new Error(String(e)),
350
+ );
351
+ }
352
+ })();
353
+ });
354
+ });
355
+ });
356
+ });
233
357
  });
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Copyright (c) 2020-2026 Vex Heavy Industries LLC
3
+ * Licensed under AGPL-3.0. See LICENSE for details.
4
+ * Commercial licenses available at vex.wtf
5
+ */
6
+
7
+ import type { Device, ReceiptMsg } from "@vex-chat/types";
8
+
9
+ import { describe, expect, it, vi } from "vitest";
10
+
11
+ import { ClientManager } from "../ClientManager.ts";
12
+
13
+ const RECEIPT_NONCE = new Uint8Array([1, 2, 3]);
14
+
15
+ interface ManagerHarness {
16
+ authed: boolean;
17
+ db: { deleteMail: ReturnType<typeof vi.fn> };
18
+ device: Device | null;
19
+ failed: boolean;
20
+ handleReceipt: (msg: ReceiptMsg) => Promise<void>;
21
+ sendErr: ReturnType<typeof vi.fn>;
22
+ }
23
+
24
+ function createManagerHarness({
25
+ authed,
26
+ deleteMail = vi.fn(),
27
+ deviceID,
28
+ }: {
29
+ authed: boolean;
30
+ deleteMail?: ReturnType<typeof vi.fn>;
31
+ deviceID: null | string;
32
+ }) {
33
+ const manager = Object.create(
34
+ ClientManager.prototype,
35
+ ) as unknown as ManagerHarness;
36
+ manager.authed = authed;
37
+ manager.db = { deleteMail };
38
+ manager.device =
39
+ deviceID === null
40
+ ? null
41
+ : ({
42
+ deviceID,
43
+ } as Device);
44
+ manager.failed = false;
45
+ manager.sendErr = vi.fn();
46
+ return manager;
47
+ }
48
+
49
+ function createReceipt(): ReceiptMsg {
50
+ return {
51
+ nonce: RECEIPT_NONCE,
52
+ transmissionID: "00000000-0000-0000-0000-000000000001",
53
+ type: "receipt",
54
+ };
55
+ }
56
+
57
+ describe("ClientManager receipt handling", () => {
58
+ it("rejects receipts before a device is authenticated", async () => {
59
+ const deleteMail = vi.fn();
60
+ const manager = createManagerHarness({
61
+ authed: false,
62
+ deleteMail,
63
+ deviceID: null,
64
+ });
65
+
66
+ await manager.handleReceipt(createReceipt());
67
+
68
+ expect(deleteMail).not.toHaveBeenCalled();
69
+ expect(manager.sendErr).toHaveBeenCalledWith(
70
+ "00000000-0000-0000-0000-000000000001",
71
+ "You are not authenticated.",
72
+ );
73
+ });
74
+
75
+ it("deletes mail receipts for the authenticated device", async () => {
76
+ const deleteMail = vi.fn();
77
+ const manager = createManagerHarness({
78
+ authed: true,
79
+ deleteMail,
80
+ deviceID: "device-a",
81
+ });
82
+
83
+ await manager.handleReceipt(createReceipt());
84
+
85
+ expect(deleteMail).toHaveBeenCalledWith(RECEIPT_NONCE, "device-a");
86
+ expect(manager.sendErr).not.toHaveBeenCalled();
87
+ });
88
+
89
+ it("contains receipt deletion failures", async () => {
90
+ const deleteMail = vi.fn(() =>
91
+ Promise.reject(new Error("database unavailable")),
92
+ );
93
+ const manager = createManagerHarness({
94
+ authed: true,
95
+ deleteMail,
96
+ deviceID: "device-a",
97
+ });
98
+
99
+ await manager.handleReceipt(createReceipt());
100
+
101
+ expect(manager.sendErr).toHaveBeenCalledWith(
102
+ "00000000-0000-0000-0000-000000000001",
103
+ "Error: database unavailable",
104
+ );
105
+ });
106
+ });