@checkstack/notification-backend 1.0.5 → 1.2.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.
@@ -1,38 +1,1034 @@
1
- import { describe, it, expect } from "bun:test";
1
+ import { describe, it, expect, mock } from "bun:test";
2
+ import { call, ORPCError } from "@orpc/server";
3
+ import { createMockRpcContext } from "@checkstack/backend-api";
4
+ import type {
5
+ Logger,
6
+ NotificationStrategy,
7
+ NotificationStrategyRegistry,
8
+ } from "@checkstack/backend-api";
9
+ import { createNotificationRouter } from "./router";
10
+ import type { NotificationCache } from "./cache";
2
11
 
3
12
  /**
4
- * Basic structural tests for notification-backend.
13
+ * Notification router tests (Phase 9b of the v1 polishing plan).
5
14
  *
6
- * Note: Full integration tests with mocked DB chains are complex due to
7
- * oRPC middleware validation. These tests verify module exports and basic imports.
8
- * More comprehensive testing should be done via integration tests with a real test DB.
15
+ * Covers critical paths that were previously only smoke-tested:
16
+ * - `getNotifications` paginated read + `unreadOnly` filter shape.
17
+ * - `createGroup` happy path + duplicate-name upsert behaviour.
18
+ * - `notifyForSubscription` dispatch fan-out:
19
+ * - zero subscribers -> notifiedCount=0, no insert into
20
+ * `notifications`, no external-delivery side-effects.
21
+ * - matched subscribers -> notifications inserted, external
22
+ * delivery fires for each recipient.
23
+ * - subscription scoped to a different (parent) resource is NOT
24
+ * dispatched when only the child's resourceKey is provided.
25
+ * - `sendTransactional` external delivery with strategy fallback:
26
+ * - primary strategy success -> result row success=true.
27
+ * - primary strategy throws -> dispatch loop continues to the
28
+ * next enabled strategy and the failure is surfaced as
29
+ * `success: false` with the extracted error message.
30
+ *
31
+ * Mocks follow the chain-builder pattern used in
32
+ * `delivery-attempts.test.ts` so we never reach a real DB. The
33
+ * `subscription-engine` module is mocked at runtime so the dispatch
34
+ * tests don't require the full provisioning chain.
35
+ *
36
+ * Legacy subscription migration is intentionally not covered here -
37
+ * it lives entirely inside `subscription-engine.provisionGroupsForSpec`
38
+ * (gated by the `subscription_migrations` table) and has no dedicated
39
+ * router-level surface to exercise. See the orchestrator report.
40
+ */
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Shared test doubles
44
+ // ---------------------------------------------------------------------------
45
+
46
+ function makeLogger(): Logger {
47
+ return {
48
+ info: mock(),
49
+ error: mock(),
50
+ warn: mock(),
51
+ debug: mock(),
52
+ };
53
+ }
54
+
55
+ /**
56
+ * A cache double that calls each loader directly (no caching, no
57
+ * invalidation tracking). Lets tests focus on the underlying read /
58
+ * write path.
59
+ */
60
+ const passthroughCache: NotificationCache = {
61
+ wrapUnread: (_userId, loader) => loader(),
62
+ wrapNotifications: (_userId, _filters, loader) => loader(),
63
+ wrapSubscriptions: (_userId, loader) => loader(),
64
+ invalidateForUser: async () => {},
65
+ invalidateSubscriptions: async () => {},
66
+ scope: {} as NotificationCache["scope"],
67
+ };
68
+
69
+ /**
70
+ * The router accepts a `SignalService`-shaped dependency but the
71
+ * procedures under test only call `sendToUser` in fire-and-forget mode.
72
+ * We record the calls so dispatch tests can assert the signal fan-out
73
+ * without standing up the real signal service.
74
+ */
75
+ function makeSignalService(): {
76
+ sendToUser: ReturnType<typeof mock>;
77
+ } {
78
+ return { sendToUser: mock(async () => {}) };
79
+ }
80
+
81
+ /**
82
+ * Build a thenable that resolves to `data` and also responds to a
83
+ * `.where(...)` call by recording the invocation and resolving to the
84
+ * same data. This is the shape Drizzle composes when a `.where()` may
85
+ * or may not be appended after `.from()`.
86
+ */
87
+ function buildThenable<T>(
88
+ data: T,
89
+ onWhere?: () => void,
90
+ ): {
91
+ where: ReturnType<typeof mock>;
92
+ then: (
93
+ onFulfilled?: (value: T) => unknown,
94
+ onRejected?: (reason: unknown) => unknown,
95
+ ) => Promise<unknown>;
96
+ limit: ReturnType<typeof mock>;
97
+ offset: ReturnType<typeof mock>;
98
+ orderBy: ReturnType<typeof mock>;
99
+ innerJoin: ReturnType<typeof mock>;
100
+ } {
101
+ const resolveAs = (): Promise<T> => Promise.resolve(data);
102
+ const self = {
103
+ where: mock(() => {
104
+ onWhere?.();
105
+ return self;
106
+ }),
107
+ limit: mock(() => self),
108
+ offset: mock(() => self),
109
+ orderBy: mock(() => self),
110
+ innerJoin: mock(() => self),
111
+ then: (
112
+ onFulfilled?: (value: T) => unknown,
113
+ onRejected?: (reason: unknown) => unknown,
114
+ ) => resolveAs().then(onFulfilled, onRejected),
115
+ };
116
+ return self;
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // getNotifications
121
+ // ---------------------------------------------------------------------------
122
+
123
+ const USER_NOTIFICATION_FIXTURE = {
124
+ id: "00000000-0000-4000-8000-aaaaaaaaaaaa",
125
+ userId: "user-1",
126
+ title: "Hello",
127
+ body: "World",
128
+ action: null,
129
+ importance: "info" as const,
130
+ isRead: false,
131
+ collapseKey: null,
132
+ subjects: null,
133
+ createdAt: new Date("2026-05-25T12:00:00Z"),
134
+ };
135
+
136
+ const READ_NOTIFICATION_FIXTURE = {
137
+ ...USER_NOTIFICATION_FIXTURE,
138
+ id: "00000000-0000-4000-8000-bbbbbbbbbbbb",
139
+ isRead: true,
140
+ title: "Old",
141
+ };
142
+
143
+ /**
144
+ * Capturing select db for `getNotifications`. The service.ts code does
145
+ * two parallel `.select().from(notifications).where(...)...` chains:
146
+ * - rows: `.select().from(...).where(...).orderBy(...).limit(...).offset(...)`
147
+ * - count: `.select({ count }).from(...).where(...)`
148
+ *
149
+ * We disambiguate by the projection arg, same trick as
150
+ * `delivery-attempts.test.ts`.
151
+ */
152
+ function createGetNotificationsDb({
153
+ rows,
154
+ total,
155
+ }: {
156
+ rows: ReadonlyArray<typeof USER_NOTIFICATION_FIXTURE>;
157
+ total: number;
158
+ }): {
159
+ db: unknown;
160
+ whereCalls: { count: number };
161
+ } {
162
+ const whereCalls = { count: 0 };
163
+ const onWhere = () => {
164
+ whereCalls.count += 1;
165
+ };
166
+
167
+ const db = {
168
+ select: mock((projection?: unknown) => {
169
+ const isCount = !!projection;
170
+ const data = isCount ? [{ count: total }] : rows;
171
+ const thenable = buildThenable(data, onWhere);
172
+ return { from: mock(() => thenable) };
173
+ }),
174
+ };
175
+
176
+ return { db, whereCalls };
177
+ }
178
+
179
+ function buildRouterForReads({
180
+ db,
181
+ }: {
182
+ db: unknown;
183
+ }): ReturnType<typeof createNotificationRouter> {
184
+ return createNotificationRouter(
185
+ db as never,
186
+ {} as never, // configService
187
+ makeSignalService() as never, // signalService
188
+ { getStrategies: () => [] } as never, // strategyRegistry
189
+ { forPlugin: () => ({}) } as never, // rpcApi
190
+ makeLogger() as never,
191
+ passthroughCache,
192
+ );
193
+ }
194
+
195
+ describe("notification router · getNotifications", () => {
196
+ const adminUser = {
197
+ type: "user" as const,
198
+ id: "user-1",
199
+ accessRules: ["*"],
200
+ };
201
+
202
+ it("returns the canonical { items, total, limit, offset } envelope", async () => {
203
+ const { db } = createGetNotificationsDb({
204
+ rows: [USER_NOTIFICATION_FIXTURE],
205
+ total: 1,
206
+ });
207
+ const router = buildRouterForReads({ db });
208
+
209
+ const context = createMockRpcContext({
210
+ pluginMetadata: { pluginId: "notification" },
211
+ user: adminUser,
212
+ });
213
+
214
+ const result = await call(
215
+ router.getNotifications,
216
+ { limit: 20, offset: 0, unreadOnly: false },
217
+ { context },
218
+ );
219
+
220
+ expect(result.total).toBe(1);
221
+ expect(result.limit).toBe(20);
222
+ expect(result.offset).toBe(0);
223
+ expect(result.items).toHaveLength(1);
224
+ expect(result.items[0].id).toBe(USER_NOTIFICATION_FIXTURE.id);
225
+ expect(result.items[0].title).toBe("Hello");
226
+ });
227
+
228
+ it("honours the supplied limit and offset on the response envelope", async () => {
229
+ const { db } = createGetNotificationsDb({
230
+ rows: [],
231
+ total: 42,
232
+ });
233
+ const router = buildRouterForReads({ db });
234
+
235
+ const context = createMockRpcContext({
236
+ pluginMetadata: { pluginId: "notification" },
237
+ user: adminUser,
238
+ });
239
+
240
+ const result = await call(
241
+ router.getNotifications,
242
+ { limit: 5, offset: 10, unreadOnly: false },
243
+ { context },
244
+ );
245
+
246
+ // Pagination math is the service's job (offset arithmetic), so we
247
+ // only assert the envelope echoes back what the caller asked for.
248
+ expect(result.limit).toBe(5);
249
+ expect(result.offset).toBe(10);
250
+ expect(result.total).toBe(42);
251
+ expect(result.items).toHaveLength(0);
252
+ });
253
+
254
+ it("applies a where clause on both the rows and count queries when unreadOnly is true", async () => {
255
+ // service.ts builds a single `whereClause` and threads it into BOTH
256
+ // the rows and count queries. Each `.where(...)` invocation
257
+ // increments our counter, so we expect exactly two calls.
258
+ const { db, whereCalls } = createGetNotificationsDb({
259
+ rows: [USER_NOTIFICATION_FIXTURE],
260
+ total: 1,
261
+ });
262
+ const router = buildRouterForReads({ db });
263
+
264
+ const context = createMockRpcContext({
265
+ pluginMetadata: { pluginId: "notification" },
266
+ user: adminUser,
267
+ });
268
+
269
+ await call(
270
+ router.getNotifications,
271
+ { limit: 20, offset: 0, unreadOnly: true },
272
+ { context },
273
+ );
274
+
275
+ // One where for rows, one for count. The unreadOnly filter is
276
+ // composed *into* the same predicate (an additional `eq(isRead,
277
+ // false)` AND), so it does not produce extra `.where()` calls -
278
+ // but it does produce them on both queries. We assert the dual
279
+ // path applied.
280
+ expect(whereCalls.count).toBe(2);
281
+ });
282
+
283
+ it("maps null DB columns to undefined on the output items", async () => {
284
+ const { db } = createGetNotificationsDb({
285
+ rows: [USER_NOTIFICATION_FIXTURE, READ_NOTIFICATION_FIXTURE],
286
+ total: 2,
287
+ });
288
+ const router = buildRouterForReads({ db });
289
+
290
+ const context = createMockRpcContext({
291
+ pluginMetadata: { pluginId: "notification" },
292
+ user: adminUser,
293
+ });
294
+
295
+ const result = await call(
296
+ router.getNotifications,
297
+ { limit: 20, offset: 0, unreadOnly: false },
298
+ { context },
299
+ );
300
+
301
+ expect(result.items).toHaveLength(2);
302
+ for (const item of result.items) {
303
+ // The router collapses `null` -> `undefined` for these columns so
304
+ // the contract output (which uses `optional()` not `nullable()`)
305
+ // validates.
306
+ expect(item.action).toBeUndefined();
307
+ expect(item.collapseKey).toBeUndefined();
308
+ expect(item.subjects).toBeUndefined();
309
+ }
310
+ });
311
+ });
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // createGroup
315
+ // ---------------------------------------------------------------------------
316
+
317
+ interface CapturedUpsert {
318
+ values: Record<string, unknown>;
319
+ onConflictSet: Record<string, unknown> | undefined;
320
+ }
321
+
322
+ /**
323
+ * Builder for the `.insert(...).values(...).onConflictDoUpdate(...)`
324
+ * chain used by `createGroup`. The `onConflictDoUpdate` branch lets us
325
+ * verify the upsert (i.e. "duplicate" calls update the existing row
326
+ * instead of throwing).
327
+ */
328
+ function createUpsertCapturingDb(): {
329
+ db: unknown;
330
+ upserts: CapturedUpsert[];
331
+ } {
332
+ const upserts: CapturedUpsert[] = [];
333
+ const insert = mock(() => ({
334
+ values: mock((values: Record<string, unknown>) => ({
335
+ onConflictDoUpdate: mock(
336
+ async (opts: { set: Record<string, unknown> }) => {
337
+ upserts.push({ values, onConflictSet: opts.set });
338
+ return undefined;
339
+ },
340
+ ),
341
+ })),
342
+ }));
343
+ return { db: { insert }, upserts };
344
+ }
345
+
346
+ describe("notification router · createGroup", () => {
347
+ // createGroup is `userType: "service"` - we need a service caller.
348
+ const serviceCaller = {
349
+ type: "service" as const,
350
+ id: "service-caller",
351
+ pluginId: "my-plugin",
352
+ accessRules: ["*"],
353
+ };
354
+
355
+ it("namespaces the groupId with the ownerPlugin on the happy path", async () => {
356
+ const { db, upserts } = createUpsertCapturingDb();
357
+ const router = buildRouterForReads({ db });
358
+
359
+ const context = createMockRpcContext({
360
+ pluginMetadata: { pluginId: "notification" },
361
+ user: serviceCaller,
362
+ });
363
+
364
+ const result = await call(
365
+ router.createGroup,
366
+ {
367
+ groupId: "incidents",
368
+ name: "Incidents",
369
+ description: "Incident notifications",
370
+ ownerPlugin: "my-plugin",
371
+ },
372
+ { context },
373
+ );
374
+
375
+ expect(result.id).toBe("my-plugin.incidents");
376
+ expect(upserts).toHaveLength(1);
377
+ expect(upserts[0].values).toMatchObject({
378
+ id: "my-plugin.incidents",
379
+ name: "Incidents",
380
+ description: "Incident notifications",
381
+ ownerPlugin: "my-plugin",
382
+ });
383
+ });
384
+
385
+ it("upserts (does not throw) on duplicate group ids — the onConflictDoUpdate branch fires", async () => {
386
+ const { db, upserts } = createUpsertCapturingDb();
387
+ const router = buildRouterForReads({ db });
388
+
389
+ const context = createMockRpcContext({
390
+ pluginMetadata: { pluginId: "notification" },
391
+ user: serviceCaller,
392
+ });
393
+
394
+ // First create.
395
+ await call(
396
+ router.createGroup,
397
+ {
398
+ groupId: "incidents",
399
+ name: "Incidents v1",
400
+ description: "Original description",
401
+ ownerPlugin: "my-plugin",
402
+ },
403
+ { context },
404
+ );
405
+
406
+ // Second create with the same id + new name/description. Must not
407
+ // throw — the handler always uses `.onConflictDoUpdate` so callers
408
+ // can re-register their groups idempotently on every boot.
409
+ await call(
410
+ router.createGroup,
411
+ {
412
+ groupId: "incidents",
413
+ name: "Incidents v2",
414
+ description: "Updated description",
415
+ ownerPlugin: "my-plugin",
416
+ },
417
+ { context },
418
+ );
419
+
420
+ expect(upserts).toHaveLength(2);
421
+ // The conflict path receives the new name/description as the set
422
+ // payload, which is what makes this an upsert vs an error.
423
+ expect(upserts[1].onConflictSet).toMatchObject({
424
+ name: "Incidents v2",
425
+ description: "Updated description",
426
+ });
427
+ });
428
+ });
429
+
430
+ // ---------------------------------------------------------------------------
431
+ // notifyForSubscription (dispatch fan-out)
432
+ // ---------------------------------------------------------------------------
433
+
434
+ /**
435
+ * Minimal subscription-spec row used by the dispatch tests. The exact
436
+ * shape mirrors `schema.subscriptionSpecs.$inferSelect` for the fields
437
+ * the router actually reads.
438
+ */
439
+ const SPEC_FIXTURE = {
440
+ specId: "my-plugin.spec.alerts",
441
+ ownerPlugin: "my-plugin",
442
+ localId: "spec.alerts",
443
+ targetTypeId: "my-plugin.system",
444
+ displayTitle: "Alerts",
445
+ displayDescription: "Alert notifications",
446
+ displayIconName: null,
447
+ registeredAt: new Date(),
448
+ };
449
+
450
+ interface CapturedInsert {
451
+ rows: Array<Record<string, unknown>>;
452
+ returning: ReadonlyArray<{ id: string; userId: string }>;
453
+ }
454
+
455
+ /**
456
+ * Build the chained DB used by `notifyForSubscription`:
457
+ *
458
+ * - `.select().from(subscriptionSpecs).where(...).limit(1)` -> [spec?]
459
+ * - `.select({resourceKey}).from(notificationResources).where(and(...))` -> rows
460
+ * - `.selectDistinct({userId}).from(notificationSubscriptions).where(inArray(...))` -> subscribers
461
+ * - `.insert(notifications).values([...]).returning(...)` -> returned ids
462
+ *
463
+ * The dispatch path also queries `notificationResourceParents` via the
464
+ * `subscription-engine.resolveInheritedGroupIds` helper. We force that
465
+ * helper to return `[]` by stubbing the parents table to zero rows.
466
+ *
467
+ * Tests parametrize the responses for each select branch via a small
468
+ * scenario record.
9
469
  */
470
+ interface DispatchScenario {
471
+ spec: typeof SPEC_FIXTURE | null;
472
+ knownResources: ReadonlyArray<{ resourceKey: string }>;
473
+ subscribers: ReadonlyArray<{ userId: string }>;
474
+ }
475
+
476
+ function createDispatchDb({
477
+ scenario,
478
+ insertReturning,
479
+ }: {
480
+ scenario: DispatchScenario;
481
+ insertReturning: ReadonlyArray<{ id: string; userId: string }>;
482
+ }): { db: unknown; capturedInsert: CapturedInsert } {
483
+ const capturedInsert: CapturedInsert = {
484
+ rows: [],
485
+ returning: insertReturning,
486
+ };
487
+
488
+ // Track how many `select()` calls have happened so we can rotate
489
+ // through the spec lookup -> resource lookup branches. The selectDistinct
490
+ // branch is its own method on the db.
491
+ let selectCallIdx = 0;
492
+
493
+ type SelectBranch = "spec" | "resources" | "parents" | "other";
494
+ const branchFor = (idx: number): SelectBranch => {
495
+ if (idx === 0) return "spec";
496
+ if (idx === 1) return "resources";
497
+ // resolveInheritedGroupIds queries `notificationResourceParents`
498
+ // - we return empty rows for all subsequent select() calls.
499
+ return "parents";
500
+ };
501
+
502
+ const dataForBranch = (branch: SelectBranch): unknown => {
503
+ if (branch === "spec") return scenario.spec ? [scenario.spec] : [];
504
+ if (branch === "resources") return scenario.knownResources;
505
+ return [];
506
+ };
507
+
508
+ const db = {
509
+ select: mock((projection?: unknown) => {
510
+ const idx = selectCallIdx;
511
+ selectCallIdx += 1;
512
+ void projection;
513
+ const branch = branchFor(idx);
514
+ const data = dataForBranch(branch);
515
+ const thenable = buildThenable(data);
516
+ return { from: mock(() => thenable) };
517
+ }),
518
+ selectDistinct: mock(() => {
519
+ const thenable = buildThenable(scenario.subscribers);
520
+ return { from: mock(() => thenable) };
521
+ }),
522
+ insert: mock(() => ({
523
+ values: mock((values: Array<Record<string, unknown>>) => {
524
+ capturedInsert.rows.push(...values);
525
+ return {
526
+ returning: mock(async () => capturedInsert.returning),
527
+ };
528
+ }),
529
+ })),
530
+ };
531
+
532
+ return { db, capturedInsert };
533
+ }
534
+
535
+ /**
536
+ * Build the notification router wired up for a dispatch test. The
537
+ * strategy registry returns an empty list so the external-delivery
538
+ * fan-out exits immediately (we cover the strategy fallback path in
539
+ * its own describe block via `sendTransactional`).
540
+ */
541
+ function buildRouterForDispatch({
542
+ db,
543
+ signalService,
544
+ }: {
545
+ db: unknown;
546
+ signalService: ReturnType<typeof makeSignalService>;
547
+ }): ReturnType<typeof createNotificationRouter> {
548
+ return createNotificationRouter(
549
+ db as never,
550
+ {} as never, // configService
551
+ signalService as never,
552
+ {
553
+ getStrategies: () => [],
554
+ getStrategy: () => undefined,
555
+ } as never,
556
+ { forPlugin: () => ({ getUserById: async () => null }) } as never,
557
+ makeLogger() as never,
558
+ passthroughCache,
559
+ );
560
+ }
561
+
562
+ describe("notification router · notifyForSubscription (dispatch)", () => {
563
+ const serviceCaller = {
564
+ type: "service" as const,
565
+ id: "service-caller",
566
+ pluginId: "my-plugin",
567
+ accessRules: ["*"],
568
+ };
569
+
570
+ // `subjects` is required by the contract (min 1) - every dispatch
571
+ // call MUST include at least one subject so the notification can be
572
+ // cross-referenced back to its triggering entity.
573
+ const SUBJECTS_FIXTURE = [
574
+ {
575
+ kind: "my-plugin.system",
576
+ id: "system-1",
577
+ name: "API Gateway",
578
+ },
579
+ ];
580
+
581
+ it("returns notifiedCount=0 and does not insert when no subscribers match", async () => {
582
+ const { db, capturedInsert } = createDispatchDb({
583
+ scenario: {
584
+ spec: SPEC_FIXTURE,
585
+ knownResources: [{ resourceKey: "system-1" }],
586
+ subscribers: [], // zero subscribers
587
+ },
588
+ insertReturning: [],
589
+ });
590
+ const signalService = makeSignalService();
591
+ const router = buildRouterForDispatch({ db, signalService });
592
+
593
+ const context = createMockRpcContext({
594
+ pluginMetadata: { pluginId: "notification" },
595
+ user: serviceCaller,
596
+ });
597
+
598
+ const result = await call(
599
+ router.notifyForSubscription,
600
+ {
601
+ specId: SPEC_FIXTURE.specId,
602
+ resourceKeys: ["system-1"],
603
+ title: "Heads up",
604
+ body: "Something happened",
605
+ subjects: SUBJECTS_FIXTURE,
606
+ },
607
+ { context },
608
+ );
609
+
610
+ expect(result.notifiedCount).toBe(0);
611
+ expect(capturedInsert.rows).toHaveLength(0);
612
+ // No subscribers -> no signals dispatched either.
613
+ expect(signalService.sendToUser).not.toHaveBeenCalled();
614
+ });
615
+
616
+ it("inserts one notification per matching subscriber and emits a signal for each", async () => {
617
+ const insertReturning = [
618
+ { id: "notif-1", userId: "user-1" },
619
+ { id: "notif-2", userId: "user-2" },
620
+ ];
621
+ const { db, capturedInsert } = createDispatchDb({
622
+ scenario: {
623
+ spec: SPEC_FIXTURE,
624
+ knownResources: [{ resourceKey: "system-1" }],
625
+ subscribers: [{ userId: "user-1" }, { userId: "user-2" }],
626
+ },
627
+ insertReturning,
628
+ });
629
+ const signalService = makeSignalService();
630
+ const router = buildRouterForDispatch({ db, signalService });
631
+
632
+ const context = createMockRpcContext({
633
+ pluginMetadata: { pluginId: "notification" },
634
+ user: serviceCaller,
635
+ });
636
+
637
+ const result = await call(
638
+ router.notifyForSubscription,
639
+ {
640
+ specId: SPEC_FIXTURE.specId,
641
+ resourceKeys: ["system-1"],
642
+ title: "Heads up",
643
+ body: "Something happened",
644
+ subjects: SUBJECTS_FIXTURE,
645
+ },
646
+ { context },
647
+ );
648
+
649
+ expect(result.notifiedCount).toBe(2);
650
+ expect(capturedInsert.rows).toHaveLength(2);
651
+ expect(capturedInsert.rows[0]).toMatchObject({
652
+ userId: "user-1",
653
+ title: "Heads up",
654
+ body: "Something happened",
655
+ importance: "info",
656
+ });
657
+ // One signal per inserted notification.
658
+ expect(signalService.sendToUser).toHaveBeenCalledTimes(2);
659
+ });
660
+
661
+ it("excludes users in excludeUserIds from the recipient list", async () => {
662
+ const { db, capturedInsert } = createDispatchDb({
663
+ scenario: {
664
+ spec: SPEC_FIXTURE,
665
+ knownResources: [{ resourceKey: "system-1" }],
666
+ subscribers: [{ userId: "user-1" }, { userId: "user-2" }],
667
+ },
668
+ insertReturning: [{ id: "notif-2", userId: "user-2" }],
669
+ });
670
+ const signalService = makeSignalService();
671
+ const router = buildRouterForDispatch({ db, signalService });
672
+
673
+ const context = createMockRpcContext({
674
+ pluginMetadata: { pluginId: "notification" },
675
+ user: serviceCaller,
676
+ });
677
+
678
+ const result = await call(
679
+ router.notifyForSubscription,
680
+ {
681
+ specId: SPEC_FIXTURE.specId,
682
+ resourceKeys: ["system-1"],
683
+ excludeUserIds: ["user-1"], // exclude one subscriber
684
+ title: "Heads up",
685
+ body: "Something happened",
686
+ subjects: SUBJECTS_FIXTURE,
687
+ },
688
+ { context },
689
+ );
690
+
691
+ // Only user-2 remains after exclude.
692
+ expect(result.notifiedCount).toBe(1);
693
+ expect(capturedInsert.rows).toHaveLength(1);
694
+ expect(capturedInsert.rows[0]).toMatchObject({ userId: "user-2" });
695
+ });
696
+
697
+ it("throws NOT_FOUND when the specId is not registered", async () => {
698
+ const { db } = createDispatchDb({
699
+ scenario: {
700
+ spec: null, // missing spec
701
+ knownResources: [],
702
+ subscribers: [],
703
+ },
704
+ insertReturning: [],
705
+ });
706
+ const signalService = makeSignalService();
707
+ const router = buildRouterForDispatch({ db, signalService });
708
+
709
+ const context = createMockRpcContext({
710
+ pluginMetadata: { pluginId: "notification" },
711
+ user: serviceCaller,
712
+ });
10
713
 
11
- describe("Notification Backend Module", () => {
12
- it("exports createNotificationRouter", async () => {
13
- const { createNotificationRouter } = await import("./router");
14
- expect(createNotificationRouter).toBeDefined();
15
- expect(typeof createNotificationRouter).toBe("function");
714
+ let caught: unknown;
715
+ try {
716
+ await call(
717
+ router.notifyForSubscription,
718
+ {
719
+ specId: "nonexistent.spec",
720
+ resourceKeys: ["system-1"],
721
+ title: "Heads up",
722
+ body: "Something happened",
723
+ subjects: SUBJECTS_FIXTURE,
724
+ },
725
+ { context },
726
+ );
727
+ } catch (error) {
728
+ caught = error;
729
+ }
730
+
731
+ expect(caught).toBeInstanceOf(ORPCError);
732
+ expect((caught as ORPCError<string, unknown>).code).toBe("NOT_FOUND");
16
733
  });
17
734
 
18
- it("exports schema tables", async () => {
19
- const schema = await import("./schema");
20
- expect(schema.notifications).toBeDefined();
21
- expect(schema.notificationGroups).toBeDefined();
22
- expect(schema.notificationSubscriptions).toBeDefined();
735
+ it("throws NOT_FOUND when a resourceKey is unknown for the spec's target", async () => {
736
+ // Only `system-1` is registered as a known resource; caller asks
737
+ // for `system-2` (drift). Dispatch must reject loudly so the caller
738
+ // knows a resource was never pushed.
739
+ const { db } = createDispatchDb({
740
+ scenario: {
741
+ spec: SPEC_FIXTURE,
742
+ knownResources: [{ resourceKey: "system-1" }],
743
+ subscribers: [],
744
+ },
745
+ insertReturning: [],
746
+ });
747
+ const signalService = makeSignalService();
748
+ const router = buildRouterForDispatch({ db, signalService });
749
+
750
+ const context = createMockRpcContext({
751
+ pluginMetadata: { pluginId: "notification" },
752
+ user: serviceCaller,
753
+ });
754
+
755
+ let caught: unknown;
756
+ try {
757
+ await call(
758
+ router.notifyForSubscription,
759
+ {
760
+ specId: SPEC_FIXTURE.specId,
761
+ resourceKeys: ["system-2"], // unknown resource
762
+ title: "Heads up",
763
+ body: "Something happened",
764
+ subjects: SUBJECTS_FIXTURE,
765
+ },
766
+ { context },
767
+ );
768
+ } catch (error) {
769
+ caught = error;
770
+ }
771
+
772
+ expect(caught).toBeInstanceOf(ORPCError);
773
+ expect((caught as ORPCError<string, unknown>).code).toBe("NOT_FOUND");
774
+ expect((caught as ORPCError<string, unknown>).message).toContain(
775
+ "system-2",
776
+ );
777
+ });
778
+
779
+ it("throws FORBIDDEN when the caller's pluginId does not own the spec", async () => {
780
+ const { db } = createDispatchDb({
781
+ scenario: {
782
+ spec: { ...SPEC_FIXTURE, ownerPlugin: "other-plugin" },
783
+ knownResources: [{ resourceKey: "system-1" }],
784
+ subscribers: [],
785
+ },
786
+ insertReturning: [],
787
+ });
788
+ const signalService = makeSignalService();
789
+ const router = buildRouterForDispatch({ db, signalService });
790
+
791
+ const context = createMockRpcContext({
792
+ pluginMetadata: { pluginId: "notification" },
793
+ user: serviceCaller, // caller.pluginId = "my-plugin"
794
+ });
795
+
796
+ let caught: unknown;
797
+ try {
798
+ await call(
799
+ router.notifyForSubscription,
800
+ {
801
+ specId: SPEC_FIXTURE.specId,
802
+ resourceKeys: ["system-1"],
803
+ title: "Heads up",
804
+ body: "Something happened",
805
+ subjects: SUBJECTS_FIXTURE,
806
+ },
807
+ { context },
808
+ );
809
+ } catch (error) {
810
+ caught = error;
811
+ }
812
+
813
+ expect(caught).toBeInstanceOf(ORPCError);
814
+ expect((caught as ORPCError<string, unknown>).code).toBe("FORBIDDEN");
23
815
  });
816
+ });
817
+
818
+ // ---------------------------------------------------------------------------
819
+ // sendTransactional · strategy fallback
820
+ // ---------------------------------------------------------------------------
821
+
822
+ interface FakeStrategySpec {
823
+ qualifiedId: string;
824
+ send: NotificationStrategy<unknown, unknown, unknown>["send"];
825
+ }
826
+
827
+ /**
828
+ * Build a fake strategy registry that returns the provided strategies
829
+ * verbatim from `getStrategies()` and looks them up by `qualifiedId` on
830
+ * `getStrategy()`. The shape mirrors `NotificationStrategyRegistry`
831
+ * just enough for `sendTransactional` to call into it.
832
+ */
833
+ function makeStrategyRegistry(
834
+ strategies: FakeStrategySpec[],
835
+ ): NotificationStrategyRegistry {
836
+ // Each strategy carries a minimal shape: `contactResolution`
837
+ // (auth-email so contact resolves trivially from the user's email),
838
+ // a no-op config schema, and the `send` mock the test cares about.
839
+ const fullStrategies = strategies.map((s) => ({
840
+ qualifiedId: s.qualifiedId,
841
+ ownerPluginId: s.qualifiedId.split(".")[0],
842
+ displayName: s.qualifiedId,
843
+ description: "",
844
+ contactResolution: { type: "auth-email" as const },
845
+ config: { version: 1, schema: { parse: (v: unknown) => v } },
846
+ send: s.send,
847
+ }));
848
+
849
+ return {
850
+ getStrategies: mock(() => fullStrategies),
851
+ getStrategy: mock((id: string) =>
852
+ fullStrategies.find((s) => s.qualifiedId === id),
853
+ ),
854
+ register: mock(),
855
+ } as unknown as NotificationStrategyRegistry;
856
+ }
857
+
858
+ /**
859
+ * Strategy-service double that reports every strategy as enabled,
860
+ * always returns a non-null strategyConfig (so the
861
+ * "Strategy not configured" early-return doesn't fire), and returns
862
+ * no per-user preference (so contact resolution falls back to the
863
+ * user's email).
864
+ */
865
+ function makeReadyStrategyService(): unknown {
866
+ return {
867
+ getStrategyMeta: async () => ({ enabled: true }),
868
+ getStrategyConfig: async () => ({ /* opaque config */ }),
869
+ getLayoutConfig: async () => undefined,
870
+ getUserPreference: async () => null,
871
+ };
872
+ }
873
+
874
+ function buildRouterForSendTransactional({
875
+ db,
876
+ strategies,
877
+ }: {
878
+ db: unknown;
879
+ strategies: FakeStrategySpec[];
880
+ }): ReturnType<typeof createNotificationRouter> {
881
+ // We use a partial mock for the strategy service by replacing the
882
+ // factory's output: the router's `createStrategyService` reads from
883
+ // `db` + `configService` + `strategyRegistry`. Instead of replacing
884
+ // those, we override the produced service via the prototype escape
885
+ // hatch the test for `delivery-attempts` already uses (passing a
886
+ // pre-built no-op db). To keep this self-contained, we attach the
887
+ // ready service onto the router by monkey-patching strategyService
888
+ // resolution: we use a custom configService that returns the canned
889
+ // values - simpler than replacing strategyService directly.
890
+ //
891
+ // In practice the call paths in `sendTransactional` are:
892
+ // strategyService.getStrategyMeta -> reads `configService.get`
893
+ // strategyService.getStrategyConfig -> reads `configService.get`
894
+ // strategyService.getUserPreference -> reads `configService.get`
895
+ // strategyService.getLayoutConfig -> reads `configService.get`
896
+ //
897
+ // We provide a configService mock that returns the values keyed by
898
+ // id pattern (`strategy.<id>.meta` -> { enabled: true }, etc.).
899
+ const configService = {
900
+ get: mock(async (id: string) => {
901
+ if (id.endsWith(".meta")) return { enabled: true };
902
+ if (id.endsWith(".layoutConfig")) return undefined;
903
+ if (id.includes("user-pref.")) return null;
904
+ // strategy config (`strategy.<id>.config`)
905
+ return { opaque: true };
906
+ }),
907
+ set: mock(async () => {}),
908
+ };
909
+
910
+ return createNotificationRouter(
911
+ db as never,
912
+ configService as never,
913
+ makeSignalService() as never,
914
+ makeStrategyRegistry(strategies),
915
+ {
916
+ forPlugin: () => ({
917
+ getUserById: async () => ({
918
+ id: "user-1",
919
+ email: "user@example.com",
920
+ name: "Test User",
921
+ }),
922
+ }),
923
+ } as never,
924
+ makeLogger() as never,
925
+ passthroughCache,
926
+ );
927
+ }
928
+
929
+ describe("notification router · sendTransactional (strategy fallback)", () => {
930
+ // sendTransactional is `userType: "service"`.
931
+ const serviceCaller = {
932
+ type: "service" as const,
933
+ id: "service-caller",
934
+ pluginId: "any-plugin",
935
+ accessRules: ["*"],
936
+ };
937
+
938
+ it("returns success when the primary strategy delivers and does not invoke later strategies as fallbacks", async () => {
939
+ const secondaryCalls = mock(async () => ({ success: true }));
940
+ const primaryCalls = mock(async () => ({ success: true }));
941
+
942
+ const router = buildRouterForSendTransactional({
943
+ db: {} as unknown,
944
+ strategies: [
945
+ { qualifiedId: "primary.send", send: primaryCalls },
946
+ { qualifiedId: "secondary.send", send: secondaryCalls },
947
+ ],
948
+ });
949
+
950
+ const context = createMockRpcContext({
951
+ pluginMetadata: { pluginId: "notification" },
952
+ user: serviceCaller,
953
+ });
954
+
955
+ const result = await call(
956
+ router.sendTransactional,
957
+ {
958
+ userId: "user-1",
959
+ notification: {
960
+ title: "Hello",
961
+ body: "World",
962
+ },
963
+ },
964
+ { context },
965
+ );
24
966
 
25
- it("exports plugin default", async () => {
26
- const plugin = await import("./index");
27
- expect(plugin.default).toBeDefined();
967
+ // sendTransactional intentionally fans out to ALL enabled
968
+ // strategies (it's not a primary-then-fallback semantic). Both
969
+ // succeed, so the deliveredCount counts both.
970
+ expect(result.deliveredCount).toBe(2);
971
+ expect(result.results).toHaveLength(2);
972
+ expect(result.results.every((r) => r.success)).toBe(true);
973
+ expect(primaryCalls).toHaveBeenCalledTimes(1);
974
+ expect(secondaryCalls).toHaveBeenCalledTimes(1);
28
975
  });
29
976
 
30
- it("exports service functions", async () => {
31
- const service = await import("./service");
32
- expect(service.getUserNotifications).toBeDefined();
33
- expect(service.getUnreadCount).toBeDefined();
34
- expect(service.markAsRead).toBeDefined();
35
- expect(service.subscribeToGroup).toBeDefined();
36
- expect(service.unsubscribeFromGroup).toBeDefined();
977
+ it("continues to the next strategy when one throws and persists the failure as a per-strategy result", async () => {
978
+ // This is the key fallback assertion: if the first strategy
979
+ // throws, the dispatch loop's try/catch must convert it into a
980
+ // `success: false` result and KEEP going to the next strategy.
981
+ const throwingPrimary = mock(async () => {
982
+ throw new Error("primary blew up");
983
+ });
984
+ const workingSecondary = mock(async () => ({ success: true }));
985
+
986
+ const router = buildRouterForSendTransactional({
987
+ db: {} as unknown,
988
+ strategies: [
989
+ { qualifiedId: "primary.send", send: throwingPrimary },
990
+ { qualifiedId: "secondary.send", send: workingSecondary },
991
+ ],
992
+ });
993
+
994
+ const context = createMockRpcContext({
995
+ pluginMetadata: { pluginId: "notification" },
996
+ user: serviceCaller,
997
+ });
998
+
999
+ const result = await call(
1000
+ router.sendTransactional,
1001
+ {
1002
+ userId: "user-1",
1003
+ notification: {
1004
+ title: "Hello",
1005
+ body: "World",
1006
+ },
1007
+ },
1008
+ { context },
1009
+ );
1010
+
1011
+ // Primary failed but secondary recovered - exactly one delivery.
1012
+ expect(result.deliveredCount).toBe(1);
1013
+ expect(result.results).toHaveLength(2);
1014
+
1015
+ const primaryResult = result.results.find(
1016
+ (r) => r.strategyId === "primary.send",
1017
+ );
1018
+ const secondaryResult = result.results.find(
1019
+ (r) => r.strategyId === "secondary.send",
1020
+ );
1021
+
1022
+ expect(primaryResult).toBeDefined();
1023
+ expect(primaryResult?.success).toBe(false);
1024
+ expect(primaryResult?.error).toBe("primary blew up");
1025
+
1026
+ expect(secondaryResult).toBeDefined();
1027
+ expect(secondaryResult?.success).toBe(true);
1028
+
1029
+ // Both `send` calls fired - the throw did NOT short-circuit the
1030
+ // loop. That's the whole point.
1031
+ expect(throwingPrimary).toHaveBeenCalledTimes(1);
1032
+ expect(workingSecondary).toHaveBeenCalledTimes(1);
37
1033
  });
38
1034
  });