@checkstack/notification-backend 1.1.0 → 1.3.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.
@@ -0,0 +1,482 @@
1
+ import { describe, it, expect, mock } from "bun:test";
2
+ import { call } from "@orpc/server";
3
+ import { createMockRpcContext } from "@checkstack/backend-api";
4
+ import type { Logger, NotificationSendContext } from "@checkstack/backend-api";
5
+ import { ORPCError } from "@orpc/server";
6
+ import {
7
+ dispatchWithAttempt,
8
+ recordDeliveryAttempt,
9
+ type SendableStrategy,
10
+ } from "./delivery-attempts";
11
+ import { createNotificationRouter } from "./router";
12
+ import * as schema from "./schema";
13
+
14
+ /**
15
+ * Tests for Phase 8 delivery-attempt tracking.
16
+ *
17
+ * Covers:
18
+ * - Successful + failed strategy dispatch populates the right row shape.
19
+ * - The insert is best-effort - a thrown DB error MUST NOT propagate.
20
+ * - `getDeliveryAttempts` paginated read shape (items / total / limit / offset).
21
+ * - `getDeliveryAttempts` `notificationId` filter applied vs omitted.
22
+ * - `getDeliveryAttempts` FORBIDDEN without the `notification:manage`
23
+ * access rule (contract-level enforcement, not a client gate).
24
+ */
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Test doubles
28
+ // ---------------------------------------------------------------------------
29
+
30
+ interface CapturedInsert {
31
+ table: unknown;
32
+ values: Array<Record<string, unknown>>;
33
+ }
34
+
35
+ /**
36
+ * Minimal `.insert(table).values(rows)` chain used by the delivery
37
+ * attempts code. Captures each insert so tests can assert the persisted
38
+ * row shape without standing up a real Drizzle client.
39
+ */
40
+ function createInsertCapturingDb({
41
+ insertThrows = false,
42
+ }: { insertThrows?: boolean } = {}) {
43
+ const inserts: CapturedInsert[] = [];
44
+ const insert = mock((table: unknown) => ({
45
+ values: mock(async (values: Record<string, unknown>) => {
46
+ if (insertThrows) {
47
+ throw new Error("simulated DB write failure");
48
+ }
49
+ inserts.push({ table, values: [values] });
50
+ return undefined;
51
+ }),
52
+ }));
53
+ return { inserts, db: { insert } };
54
+ }
55
+
56
+ function makeLogger(): Logger & { error: ReturnType<typeof mock> } {
57
+ return {
58
+ info: mock(),
59
+ error: mock(),
60
+ warn: mock(),
61
+ debug: mock(),
62
+ };
63
+ }
64
+
65
+ // `NotificationSendContext` is a wide structural type; for the dispatch
66
+ // helper tests the actual contents don't matter - only the `send` shape
67
+ // does. Casting an empty object via `unknown` keeps the test focused
68
+ // without leaning on `any`.
69
+ const emptySendContext =
70
+ {} as unknown as NotificationSendContext<unknown, unknown, unknown>;
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // recordDeliveryAttempt + dispatchWithAttempt
74
+ // ---------------------------------------------------------------------------
75
+
76
+ describe("recordDeliveryAttempt", () => {
77
+ it("inserts the row verbatim on the happy path", async () => {
78
+ const { inserts, db } = createInsertCapturingDb();
79
+ const logger = makeLogger();
80
+
81
+ await recordDeliveryAttempt({
82
+ database: db as never,
83
+ logger,
84
+ row: {
85
+ notificationId: "00000000-0000-4000-8000-000000000001",
86
+ strategyQualifiedId: "notification-discord.send",
87
+ status: "success",
88
+ errorMessage: null,
89
+ durationMs: 42,
90
+ },
91
+ });
92
+
93
+ expect(inserts).toHaveLength(1);
94
+ expect(inserts[0].values[0]).toEqual({
95
+ notificationId: "00000000-0000-4000-8000-000000000001",
96
+ strategyQualifiedId: "notification-discord.send",
97
+ status: "success",
98
+ errorMessage: null,
99
+ durationMs: 42,
100
+ });
101
+ expect(logger.error).not.toHaveBeenCalled();
102
+ });
103
+
104
+ it("swallows DB errors so dispatch is not blocked", async () => {
105
+ const { db } = createInsertCapturingDb({ insertThrows: true });
106
+ const logger = makeLogger();
107
+
108
+ // Must not reject - the best-effort guarantee is the whole point.
109
+ await recordDeliveryAttempt({
110
+ database: db as never,
111
+ logger,
112
+ row: {
113
+ notificationId: "00000000-0000-4000-8000-000000000002",
114
+ strategyQualifiedId: "notification-slack.send",
115
+ status: "failure",
116
+ errorMessage: "boom",
117
+ durationMs: 7,
118
+ },
119
+ });
120
+
121
+ expect(logger.error).toHaveBeenCalledTimes(1);
122
+ const [message] = logger.error.mock.calls[0] as [string, unknown];
123
+ expect(message).toContain("Failed to persist delivery attempt");
124
+ expect(message).toContain("notification-slack.send");
125
+ });
126
+ });
127
+
128
+ describe("dispatchWithAttempt", () => {
129
+ it("records a success row with non-negative durationMs when the strategy resolves with success", async () => {
130
+ const { inserts, db } = createInsertCapturingDb();
131
+ const logger = makeLogger();
132
+ const strategy: SendableStrategy = {
133
+ qualifiedId: "notification-test.send",
134
+ send: mock(async () => ({ success: true })),
135
+ };
136
+
137
+ await dispatchWithAttempt({
138
+ database: db as never,
139
+ logger,
140
+ strategy,
141
+ sendContext: emptySendContext,
142
+ notificationId: "00000000-0000-4000-8000-000000000003",
143
+ });
144
+
145
+ expect(inserts).toHaveLength(1);
146
+ const row = inserts[0].values[0];
147
+ expect(row.status).toBe("success");
148
+ expect(row.errorMessage).toBeNull();
149
+ expect(row.strategyQualifiedId).toBe("notification-test.send");
150
+ expect(row.notificationId).toBe("00000000-0000-4000-8000-000000000003");
151
+ expect(typeof row.durationMs).toBe("number");
152
+ expect(row.durationMs as number).toBeGreaterThanOrEqual(0);
153
+ });
154
+
155
+ it("records a failure row using extractErrorMessage when the strategy throws", async () => {
156
+ const { inserts, db } = createInsertCapturingDb();
157
+ const logger = makeLogger();
158
+ const strategy: SendableStrategy = {
159
+ qualifiedId: "notification-discord.send",
160
+ send: mock(async () => {
161
+ throw new Error("invalid webhook URL");
162
+ }),
163
+ };
164
+
165
+ await dispatchWithAttempt({
166
+ database: db as never,
167
+ logger,
168
+ strategy,
169
+ sendContext: emptySendContext,
170
+ notificationId: "00000000-0000-4000-8000-000000000004",
171
+ });
172
+
173
+ expect(inserts).toHaveLength(1);
174
+ const row = inserts[0].values[0];
175
+ expect(row.status).toBe("failure");
176
+ // `extractErrorMessage(Error("invalid webhook URL")) === "invalid webhook URL"`
177
+ expect(row.errorMessage).toBe("invalid webhook URL");
178
+ expect(row.strategyQualifiedId).toBe("notification-discord.send");
179
+ expect(logger.error).toHaveBeenCalled();
180
+ });
181
+
182
+ it("records a failure row when the strategy resolves with `{ success: false }`", async () => {
183
+ const { inserts, db } = createInsertCapturingDb();
184
+ const logger = makeLogger();
185
+ const strategy: SendableStrategy = {
186
+ qualifiedId: "notification-smtp.send",
187
+ send: mock(async () => ({ success: false, error: "smtp rejected" })),
188
+ };
189
+
190
+ await dispatchWithAttempt({
191
+ database: db as never,
192
+ logger,
193
+ strategy,
194
+ sendContext: emptySendContext,
195
+ notificationId: "00000000-0000-4000-8000-000000000005",
196
+ });
197
+
198
+ expect(inserts).toHaveLength(1);
199
+ expect(inserts[0].values[0].status).toBe("failure");
200
+ expect(inserts[0].values[0].errorMessage).toBe("smtp rejected");
201
+ });
202
+
203
+ it("does not propagate when the delivery-attempt insert itself fails", async () => {
204
+ const { db } = createInsertCapturingDb({ insertThrows: true });
205
+ const logger = makeLogger();
206
+ const strategy: SendableStrategy = {
207
+ qualifiedId: "notification-test.send",
208
+ send: mock(async () => ({ success: true })),
209
+ };
210
+
211
+ // The dispatch helper MUST NOT reject - the dispatch loop relies
212
+ // on this as the "best-effort" contract.
213
+ await dispatchWithAttempt({
214
+ database: db as never,
215
+ logger,
216
+ strategy,
217
+ sendContext: emptySendContext,
218
+ notificationId: "00000000-0000-4000-8000-000000000006",
219
+ });
220
+
221
+ // The inner `recordDeliveryAttempt` logged the swallowed error.
222
+ expect(logger.error).toHaveBeenCalled();
223
+ });
224
+ });
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // getDeliveryAttempts read procedure
228
+ // ---------------------------------------------------------------------------
229
+
230
+ const ATTEMPTED_AT_FIXTURE = new Date("2026-05-26T12:00:00Z");
231
+
232
+ const SAMPLE_ROWS = [
233
+ {
234
+ id: "11111111-1111-4111-8111-111111111111",
235
+ notificationId: "22222222-2222-4222-8222-222222222222",
236
+ strategyQualifiedId: "notification-discord.send",
237
+ attemptedAt: ATTEMPTED_AT_FIXTURE,
238
+ status: "failure" as const,
239
+ errorMessage: "invalid webhook URL",
240
+ durationMs: 87,
241
+ },
242
+ {
243
+ id: "33333333-3333-4333-8333-333333333333",
244
+ notificationId: "44444444-4444-4444-8444-444444444444",
245
+ strategyQualifiedId: "notification-smtp.send",
246
+ attemptedAt: ATTEMPTED_AT_FIXTURE,
247
+ status: "success" as const,
248
+ errorMessage: null,
249
+ durationMs: 21,
250
+ },
251
+ ];
252
+
253
+ interface SelectRecorder {
254
+ whereCalls: number;
255
+ rows: ReadonlyArray<unknown>;
256
+ totalRows: ReadonlyArray<{ value: number }>;
257
+ }
258
+
259
+ /**
260
+ * Builds the chained `.select().from().orderBy().limit().offset()`
261
+ * thenable expected by `getDeliveryAttempts`. The `totalQuery` branch
262
+ * uses `.select({ value: count() }).from(...).where?(...)` -> array of
263
+ * `{ value: number }` to match Drizzle's count idiom.
264
+ *
265
+ * We don't validate the actual SQL composition (Drizzle covers that);
266
+ * we only verify the handler maps results to the contract output shape
267
+ * and threads pagination + the optional `where` filter correctly.
268
+ */
269
+ function createSelectMockDb({
270
+ rows,
271
+ totalRows,
272
+ }: {
273
+ rows: ReadonlyArray<unknown>;
274
+ totalRows: ReadonlyArray<{ value: number }>;
275
+ }): { db: unknown; recorder: SelectRecorder } {
276
+ const recorder: SelectRecorder = {
277
+ whereCalls: 0,
278
+ rows,
279
+ totalRows,
280
+ };
281
+
282
+ type Branch = "rows" | "total";
283
+ const branchData = (branch: Branch): unknown =>
284
+ branch === "rows" ? rows : totalRows;
285
+
286
+ const buildThenable = (branch: Branch) => {
287
+ const thenable = {
288
+ where: mock(() => {
289
+ recorder.whereCalls += 1;
290
+ return Promise.resolve(branchData(branch));
291
+ }),
292
+ then: (
293
+ onFulfilled?: (value: unknown) => unknown,
294
+ onRejected?: (reason: unknown) => unknown,
295
+ ) => Promise.resolve(branchData(branch)).then(onFulfilled, onRejected),
296
+ };
297
+ return thenable;
298
+ };
299
+
300
+ const buildFromChain = (branch: Branch) => {
301
+ const offsetReturn = buildThenable(branch);
302
+ const limitReturn = {
303
+ offset: mock(() => offsetReturn),
304
+ where: offsetReturn.where,
305
+ then: offsetReturn.then,
306
+ };
307
+ const orderByReturn = {
308
+ limit: mock(() => limitReturn),
309
+ where: offsetReturn.where,
310
+ then: offsetReturn.then,
311
+ };
312
+ const fromReturn = {
313
+ orderBy: mock(() => orderByReturn),
314
+ where: offsetReturn.where,
315
+ then: offsetReturn.then,
316
+ };
317
+ return { from: mock(() => fromReturn) };
318
+ };
319
+
320
+ const db = {
321
+ // Drizzle's `.select()` and `.select({...})` both reach here; we
322
+ // disambiguate the count branch by inspecting the argument shape.
323
+ select: mock((projection?: unknown) => {
324
+ const branch: Branch = projection ? "total" : "rows";
325
+ return buildFromChain(branch);
326
+ }),
327
+ };
328
+
329
+ return { db, recorder };
330
+ }
331
+
332
+ // Lazily-evaluated factory for the router under test. Every test gets a
333
+ // fresh db + recorder so per-test mock state stays isolated.
334
+ function buildRouterForGetDeliveryAttempts({
335
+ rows,
336
+ totalRows,
337
+ }: {
338
+ rows: ReadonlyArray<unknown>;
339
+ totalRows: ReadonlyArray<{ value: number }>;
340
+ }) {
341
+ const { db, recorder } = createSelectMockDb({ rows, totalRows });
342
+
343
+ // The router constructor wires several services we never reach from
344
+ // `getDeliveryAttempts` (signalService, strategyRegistry, rpcApi,
345
+ // cache, configService). We pass minimal mocks; any access from this
346
+ // procedure would surface as an obvious test failure.
347
+ const router = createNotificationRouter(
348
+ db as never,
349
+ {} as never, // configService
350
+ {} as never, // signalService
351
+ { getStrategies: () => [] } as never, // strategyRegistry
352
+ { forPlugin: () => ({}) } as never, // rpcApi
353
+ makeLogger() as never,
354
+ {} as never, // cache
355
+ );
356
+
357
+ return { router, recorder };
358
+ }
359
+
360
+ describe("getDeliveryAttempts", () => {
361
+ const adminUser = {
362
+ type: "user" as const,
363
+ id: "admin-user",
364
+ accessRules: ["notification.notification.manage"],
365
+ };
366
+
367
+ it("returns the canonical { items, total, limit, offset } envelope", async () => {
368
+ const { router } = buildRouterForGetDeliveryAttempts({
369
+ rows: SAMPLE_ROWS,
370
+ totalRows: [{ value: 2 }],
371
+ });
372
+
373
+ const context = createMockRpcContext({
374
+ pluginMetadata: { pluginId: "notification" },
375
+ user: adminUser,
376
+ });
377
+
378
+ const result = await call(
379
+ router.getDeliveryAttempts,
380
+ { limit: 20, offset: 0 },
381
+ { context },
382
+ );
383
+
384
+ expect(result.total).toBe(2);
385
+ expect(result.limit).toBe(20);
386
+ expect(result.offset).toBe(0);
387
+ expect(result.items).toHaveLength(2);
388
+ // Each item carries through the projected columns; verify the
389
+ // strategy id + status pair so we know the mapper isn't dropping
390
+ // rows.
391
+ expect(result.items.map((i) => i.strategyQualifiedId).sort()).toEqual([
392
+ "notification-discord.send",
393
+ "notification-smtp.send",
394
+ ]);
395
+ expect(
396
+ result.items.find((i) => i.strategyQualifiedId === "notification-smtp.send")
397
+ ?.status,
398
+ ).toBe("success");
399
+ });
400
+
401
+ it("does not apply a `where` clause when no notificationId filter is supplied", async () => {
402
+ const { router, recorder } = buildRouterForGetDeliveryAttempts({
403
+ rows: SAMPLE_ROWS,
404
+ totalRows: [{ value: 2 }],
405
+ });
406
+
407
+ const context = createMockRpcContext({
408
+ pluginMetadata: { pluginId: "notification" },
409
+ user: adminUser,
410
+ });
411
+
412
+ await call(
413
+ router.getDeliveryAttempts,
414
+ { limit: 20, offset: 0 },
415
+ { context },
416
+ );
417
+
418
+ expect(recorder.whereCalls).toBe(0);
419
+ });
420
+
421
+ it("applies the notificationId filter to both the rows query and the count query", async () => {
422
+ const filtered = [SAMPLE_ROWS[0]];
423
+ const { router, recorder } = buildRouterForGetDeliveryAttempts({
424
+ rows: filtered,
425
+ totalRows: [{ value: 1 }],
426
+ });
427
+
428
+ const context = createMockRpcContext({
429
+ pluginMetadata: { pluginId: "notification" },
430
+ user: adminUser,
431
+ });
432
+
433
+ const result = await call(
434
+ router.getDeliveryAttempts,
435
+ {
436
+ limit: 20,
437
+ offset: 0,
438
+ notificationId: "22222222-2222-4222-8222-222222222222",
439
+ },
440
+ { context },
441
+ );
442
+
443
+ // Both the rows and the total queries should have called `.where`
444
+ // - that's how `notificationId` propagates into the SQL.
445
+ expect(recorder.whereCalls).toBe(2);
446
+ expect(result.total).toBe(1);
447
+ expect(result.items).toHaveLength(1);
448
+ expect(result.items[0].notificationId).toBe(
449
+ "22222222-2222-4222-8222-222222222222",
450
+ );
451
+ });
452
+
453
+ it("throws FORBIDDEN when the caller lacks the notification:manage access rule", async () => {
454
+ const { router } = buildRouterForGetDeliveryAttempts({
455
+ rows: [],
456
+ totalRows: [{ value: 0 }],
457
+ });
458
+
459
+ const context = createMockRpcContext({
460
+ pluginMetadata: { pluginId: "notification" },
461
+ user: {
462
+ type: "user" as const,
463
+ id: "regular-user",
464
+ accessRules: [], // no admin rule
465
+ },
466
+ });
467
+
468
+ let caught: unknown;
469
+ try {
470
+ await call(
471
+ router.getDeliveryAttempts,
472
+ { limit: 20, offset: 0 },
473
+ { context },
474
+ );
475
+ } catch (error) {
476
+ caught = error;
477
+ }
478
+
479
+ expect(caught).toBeInstanceOf(ORPCError);
480
+ expect((caught as ORPCError<string, unknown>).code).toBe("FORBIDDEN");
481
+ });
482
+ });
@@ -0,0 +1,146 @@
1
+ import { extractErrorMessage } from "@checkstack/common";
2
+ import type {
3
+ Logger,
4
+ NotificationSendContext,
5
+ SafeDatabase,
6
+ } from "@checkstack/backend-api";
7
+ import * as schema from "./schema";
8
+
9
+ /**
10
+ * Shape of the per-attempt row persisted to
11
+ * `notification_delivery_attempts`. Kept structural (not the Drizzle
12
+ * select type) so callers and tests don't have to know about the
13
+ * underlying ORM type.
14
+ */
15
+ export interface DeliveryAttemptRow {
16
+ notificationId: string;
17
+ strategyQualifiedId: string;
18
+ status: "success" | "failure";
19
+ errorMessage: string | null;
20
+ durationMs: number;
21
+ }
22
+
23
+ /**
24
+ * Best-effort: persist a single delivery-attempt row. If the insert
25
+ * itself errors (e.g. transient DB blip), we log and continue so we
26
+ * don't replace one silent dispatch failure with a new one. Wrapping
27
+ * caller MUST NOT rely on the attempt row existing on the happy
28
+ * path - visibility, not correctness.
29
+ *
30
+ * Exported so the dispatch loop in `router.ts` can compose it with the
31
+ * `strategy.send` call, and so unit tests can exercise the best-effort
32
+ * guarantee directly.
33
+ */
34
+ export const recordDeliveryAttempt = async ({
35
+ database,
36
+ logger,
37
+ row,
38
+ }: {
39
+ database: SafeDatabase<typeof schema>;
40
+ logger: Logger;
41
+ row: DeliveryAttemptRow;
42
+ }): Promise<void> => {
43
+ try {
44
+ await database.insert(schema.notificationDeliveryAttempts).values(row);
45
+ } catch (insertError) {
46
+ logger.error(
47
+ `[external-delivery] Failed to persist delivery attempt for ${row.strategyQualifiedId} (notification ${row.notificationId}):`,
48
+ insertError,
49
+ );
50
+ }
51
+ };
52
+
53
+ /**
54
+ * Minimal subset of `NotificationStrategy` the dispatch loop uses when
55
+ * invoking `.send(...)`. Kept structural so `dispatchWithAttempt` can
56
+ * be unit-tested with a fake strategy and so the type doesn't drag in
57
+ * the full `NotificationStrategy<...>` shape (config schemas,
58
+ * contactResolution, etc.) that's irrelevant to attempt persistence.
59
+ */
60
+ export interface SendableStrategy {
61
+ qualifiedId: string;
62
+ send: (
63
+ context: NotificationSendContext<unknown, unknown, unknown>,
64
+ ) => Promise<{ success: boolean; error?: string }>;
65
+ }
66
+
67
+ /**
68
+ * Invoke `strategy.send(...)` with duration measurement + best-effort
69
+ * attempt persistence on both branches. Centralised so the same
70
+ * "wrap with timing + persist + don't propagate" contract holds for
71
+ * every external delivery path.
72
+ *
73
+ * `strategy.send` may either:
74
+ * - resolve with `{ success: true }` -> persisted as a `"success"` row.
75
+ * - resolve with `{ success: false, error }` -> persisted as a
76
+ * `"failure"` row using the strategy's own (already-sanitised)
77
+ * error string.
78
+ * - throw -> persisted as a `"failure"` row using
79
+ * `extractErrorMessage(error)` so secrets embedded in raw error
80
+ * objects (webhook URLs, tokens) are not stored verbatim.
81
+ *
82
+ * Never throws to the caller - the dispatch loop treats this as a
83
+ * fire-and-forget step.
84
+ */
85
+ export const dispatchWithAttempt = async ({
86
+ database,
87
+ logger,
88
+ strategy,
89
+ sendContext,
90
+ notificationId,
91
+ }: {
92
+ database: SafeDatabase<typeof schema>;
93
+ logger: Logger;
94
+ strategy: SendableStrategy;
95
+ sendContext: NotificationSendContext<unknown, unknown, unknown>;
96
+ notificationId: string;
97
+ }): Promise<void> => {
98
+ const startMs = performance.now();
99
+ try {
100
+ const result = await strategy.send(sendContext);
101
+ const durationMs = Math.round(performance.now() - startMs);
102
+ logger.debug(
103
+ `[external-delivery] Send result for ${strategy.qualifiedId}:`,
104
+ result,
105
+ );
106
+ await recordDeliveryAttempt({
107
+ database,
108
+ logger,
109
+ row: result.success
110
+ ? {
111
+ notificationId,
112
+ strategyQualifiedId: strategy.qualifiedId,
113
+ status: "success",
114
+ errorMessage: null,
115
+ durationMs,
116
+ }
117
+ : {
118
+ notificationId,
119
+ strategyQualifiedId: strategy.qualifiedId,
120
+ status: "failure",
121
+ errorMessage: result.error ?? "Strategy reported failure",
122
+ durationMs,
123
+ },
124
+ });
125
+ } catch (sendError) {
126
+ const durationMs = Math.round(performance.now() - startMs);
127
+ logger.error(
128
+ `[external-delivery] Error sending via ${strategy.qualifiedId}:`,
129
+ sendError,
130
+ );
131
+ await recordDeliveryAttempt({
132
+ database,
133
+ logger,
134
+ row: {
135
+ notificationId,
136
+ strategyQualifiedId: strategy.qualifiedId,
137
+ status: "failure",
138
+ // `extractErrorMessage` sanitises arbitrary thrown values -
139
+ // never persist the raw error object since it may embed
140
+ // webhook URLs / tokens via the strategy's send context.
141
+ errorMessage: extractErrorMessage(sendError),
142
+ durationMs,
143
+ },
144
+ });
145
+ }
146
+ };