@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.
- package/CHANGELOG.md +206 -0
- package/drizzle/0007_funny_hobgoblin.sql +14 -0
- package/drizzle/meta/0007_snapshot.json +644 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +8 -8
- package/src/delivery-attempts.test.ts +482 -0
- package/src/delivery-attempts.ts +146 -0
- package/src/index.ts +8 -0
- package/src/post-json.test.ts +133 -0
- package/src/post-json.ts +86 -0
- package/src/render.ts +29 -0
- package/src/router.test.ts +1021 -25
- package/src/router.ts +81 -9
- package/src/schema.ts +52 -0
package/src/router.test.ts
CHANGED
|
@@ -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
|
-
*
|
|
13
|
+
* Notification router tests (Phase 9b of the v1 polishing plan).
|
|
5
14
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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("
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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("
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
});
|