@checkstack/anomaly-backend 0.2.1 → 1.0.1

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.
@@ -43,6 +43,14 @@ function createMockCatalogClient() {
43
43
  };
44
44
  }
45
45
 
46
+ function createMockNotificationClient(subscriberIds: string[] = ["user-1"]) {
47
+ return {
48
+ notifyForSubscription: mock(async () => ({
49
+ notifiedCount: subscriberIds.length,
50
+ })),
51
+ };
52
+ }
53
+
46
54
  function createMockLogger() {
47
55
  return {
48
56
  debug: mock(() => {}),
@@ -248,6 +256,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
248
256
  cache,
249
257
  logger: createMockLogger() as never,
250
258
  catalogClient: createMockCatalogClient() as never,
259
+ notificationClient: createMockNotificationClient() as never,
251
260
  });
252
261
  expect(cache.get).not.toHaveBeenCalled();
253
262
  });
@@ -263,6 +272,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
263
272
  cache,
264
273
  logger: createMockLogger() as never,
265
274
  catalogClient: createMockCatalogClient() as never,
275
+ notificationClient: createMockNotificationClient() as never,
266
276
  });
267
277
  expect(cache.get).not.toHaveBeenCalled();
268
278
  });
@@ -280,6 +290,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
280
290
  cache,
281
291
  logger: createMockLogger() as never,
282
292
  catalogClient: createMockCatalogClient() as never,
293
+ notificationClient: createMockNotificationClient() as never,
283
294
  });
284
295
  // Two fields (statusText, isRunning) means cache.get should be called twice
285
296
  expect(cache.get).toHaveBeenCalledTimes(2);
@@ -301,6 +312,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
301
312
  cache,
302
313
  logger: createMockLogger() as never,
303
314
  catalogClient: createMockCatalogClient() as never,
315
+ notificationClient: createMockNotificationClient() as never,
304
316
  });
305
317
 
306
318
  expect(db._insertCalls.length).toBe(1);
@@ -326,6 +338,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
326
338
  cache,
327
339
  logger: createMockLogger() as never,
328
340
  catalogClient: createMockCatalogClient() as never,
341
+ notificationClient: createMockNotificationClient() as never,
329
342
  });
330
343
  expect(cache.get).toHaveBeenCalledTimes(1);
331
344
  expect(db._insertCalls.length).toBe(0);
@@ -345,6 +358,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
345
358
  cache,
346
359
  logger: createMockLogger() as never,
347
360
  catalogClient: createMockCatalogClient() as never,
361
+ notificationClient: createMockNotificationClient() as never,
348
362
  });
349
363
  expect(db._insertCalls.length).toBe(0);
350
364
  });
@@ -363,6 +377,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
363
377
  cache,
364
378
  logger: createMockLogger() as never,
365
379
  catalogClient: createMockCatalogClient() as never,
380
+ notificationClient: createMockNotificationClient() as never,
366
381
  });
367
382
  expect(db._insertCalls.length).toBe(1);
368
383
  expect(db._insertCalls[0]).toMatchObject({
@@ -380,6 +395,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
380
395
  const baseline = createBaseline({ mean: 100, stdDev: 10 });
381
396
  const cache = createMockCache(new Map([[cacheKeyPrefix, baseline]]));
382
397
  const catalogClient = createMockCatalogClient();
398
+ const notificationClient = createMockNotificationClient(["user-1"]);
383
399
  const db = createMockDb({
384
400
  existingAnomaly: {
385
401
  id: "anomaly-existing",
@@ -400,18 +416,19 @@ describe("Anomaly Detector — processCheckCompleted", () => {
400
416
  cache,
401
417
  logger: createMockLogger() as never,
402
418
  catalogClient: catalogClient as never,
419
+ notificationClient: notificationClient as never,
403
420
  });
404
421
 
405
422
  expect(db._updateCalls.length).toBe(1);
406
423
  expect(db._updateCalls[0]).toMatchObject({ state: "anomaly" });
407
424
 
408
- expect(catalogClient.notifySystemSubscribers).toHaveBeenCalledTimes(1);
409
- const notifArgs = (catalogClient.notifySystemSubscribers as Mock<(...args: unknown[]) => unknown>).mock.calls[0] as unknown[];
425
+ expect(notificationClient.notifyForSubscription).toHaveBeenCalledTimes(1);
426
+ const notifArgs = (notificationClient.notifyForSubscription as Mock<(...args: unknown[]) => unknown>).mock.calls[0] as unknown[];
410
427
  const notifPayload = notifArgs[0] as Record<string, unknown>;
411
428
  expect(notifPayload).toMatchObject({
412
- systemId,
429
+ specId: "anomaly.system",
430
+ resourceKeys: [systemId],
413
431
  importance: "warning",
414
- includeGroupSubscribers: true,
415
432
  });
416
433
  expect(notifPayload.title).toContain("Anomaly Detected");
417
434
  });
@@ -422,6 +439,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
422
439
  const baseline = createBaseline({ mean: 100, stdDev: 10 });
423
440
  const cache = createMockCache(new Map([[cacheKeyPrefix, baseline]]));
424
441
  const catalogClient = createMockCatalogClient();
442
+ const notificationClient = createMockNotificationClient(["user-1"]);
425
443
  const db = createMockDb({
426
444
  existingAnomaly: {
427
445
  id: "anomaly-confirmed",
@@ -442,16 +460,18 @@ describe("Anomaly Detector — processCheckCompleted", () => {
442
460
  cache,
443
461
  logger: createMockLogger() as never,
444
462
  catalogClient: catalogClient as never,
463
+ notificationClient: notificationClient as never,
445
464
  });
446
465
 
447
466
  expect(db._updateCalls.length).toBe(1);
448
467
  expect(db._updateCalls[0]).toMatchObject({ state: "recovered" });
449
468
 
450
- expect(catalogClient.notifySystemSubscribers).toHaveBeenCalledTimes(1);
451
- const notifArgs = (catalogClient.notifySystemSubscribers as Mock<(...args: unknown[]) => unknown>).mock.calls[0] as unknown[];
469
+ expect(notificationClient.notifyForSubscription).toHaveBeenCalledTimes(1);
470
+ const notifArgs = (notificationClient.notifyForSubscription as Mock<(...args: unknown[]) => unknown>).mock.calls[0] as unknown[];
452
471
  const notifPayload = notifArgs[0] as Record<string, unknown>;
453
472
  expect(notifPayload).toMatchObject({
454
- systemId,
473
+ specId: "anomaly.system",
474
+ resourceKeys: [systemId],
455
475
  importance: "info",
456
476
  });
457
477
  expect(notifPayload.title).toContain("Recovered");
@@ -482,6 +502,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
482
502
  cache,
483
503
  logger: createMockLogger() as never,
484
504
  catalogClient: createMockCatalogClient() as never,
505
+ notificationClient: createMockNotificationClient() as never,
485
506
  });
486
507
 
487
508
  expect(db._deleteCalls.length).toBe(1);
@@ -507,6 +528,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
507
528
  cache,
508
529
  logger: createMockLogger() as never,
509
530
  catalogClient: createMockCatalogClient() as never,
531
+ notificationClient: createMockNotificationClient() as never,
510
532
  });
511
533
 
512
534
  expect(db._insertCalls.length).toBe(0);
@@ -535,6 +557,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
535
557
  cache,
536
558
  logger: createMockLogger() as never,
537
559
  catalogClient: createMockCatalogClient() as never,
560
+ notificationClient: createMockNotificationClient() as never,
538
561
  });
539
562
 
540
563
  expect(cache.get).toHaveBeenCalledTimes(2);
@@ -566,6 +589,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
566
589
  cache,
567
590
  logger: createMockLogger() as never,
568
591
  catalogClient: createMockCatalogClient() as never,
592
+ notificationClient: createMockNotificationClient() as never,
569
593
  });
570
594
 
571
595
  expect(cache.set).toHaveBeenCalledTimes(1);
@@ -593,6 +617,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
593
617
  cache,
594
618
  logger: createMockLogger() as never,
595
619
  catalogClient: createMockCatalogClient() as never,
620
+ notificationClient: createMockNotificationClient() as never,
596
621
  });
597
622
 
598
623
  expect(db._insertCalls.length).toBe(1);
@@ -618,6 +643,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
618
643
  cache,
619
644
  logger: createMockLogger() as never,
620
645
  catalogClient: createMockCatalogClient() as never,
646
+ notificationClient: createMockNotificationClient() as never,
621
647
  });
622
648
 
623
649
  expect(db._insertCalls.length).toBe(0);
@@ -639,6 +665,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
639
665
  cache,
640
666
  logger: createMockLogger() as never,
641
667
  catalogClient: createMockCatalogClient() as never,
668
+ notificationClient: createMockNotificationClient() as never,
642
669
  });
643
670
 
644
671
  expect(db._insertCalls.length).toBe(1);
@@ -661,6 +688,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
661
688
  cache,
662
689
  logger: createMockLogger() as never,
663
690
  catalogClient: createMockCatalogClient() as never,
691
+ notificationClient: createMockNotificationClient() as never,
664
692
  });
665
693
 
666
694
  // Baseline lookup happens, but direction resolution short-circuits before insert.
@@ -687,6 +715,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
687
715
  cache,
688
716
  logger: createMockLogger() as never,
689
717
  catalogClient: createMockCatalogClient() as never,
718
+ notificationClient: createMockNotificationClient() as never,
690
719
  });
691
720
 
692
721
  // No schema direction available, no config direction → silently skipped
@@ -718,6 +747,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
718
747
  cache,
719
748
  logger: createMockLogger() as never,
720
749
  catalogClient: createMockCatalogClient() as never,
750
+ notificationClient: createMockNotificationClient() as never,
721
751
  });
722
752
 
723
753
  expect(db._updateCalls.length).toBe(1);
@@ -730,6 +760,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
730
760
  const baseline = createBaseline({ mean: 100, stdDev: 10 });
731
761
  const cache = createMockCache(new Map([[cacheKeyPrefix, baseline]]));
732
762
  const catalogClient = createMockCatalogClient();
763
+ const notificationClient = createMockNotificationClient();
733
764
  const db = createMockDb({
734
765
  existingAnomaly: {
735
766
  id: "anomaly-confirmed",
@@ -750,11 +781,12 @@ describe("Anomaly Detector — processCheckCompleted", () => {
750
781
  cache,
751
782
  logger: createMockLogger() as never,
752
783
  catalogClient: catalogClient as never,
784
+ notificationClient: notificationClient as never,
753
785
  });
754
786
 
755
787
  // Should update observed value but not send another notification
756
788
  expect(db._updateCalls.length).toBe(1);
757
- expect(catalogClient.notifySystemSubscribers).not.toHaveBeenCalled();
789
+ expect(notificationClient.notifyForSubscription).not.toHaveBeenCalled();
758
790
  });
759
791
 
760
792
  // ─── Signal emission (F8) ─────────────────────────────────────────────
@@ -774,6 +806,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
774
806
  cache,
775
807
  logger: createMockLogger() as never,
776
808
  catalogClient: createMockCatalogClient() as never,
809
+ notificationClient: createMockNotificationClient() as never,
777
810
  signalService,
778
811
  });
779
812
 
@@ -808,6 +841,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
808
841
  cache,
809
842
  logger: createMockLogger() as never,
810
843
  catalogClient: createMockCatalogClient() as never,
844
+ notificationClient: createMockNotificationClient() as never,
811
845
  signalService,
812
846
  });
813
847
 
@@ -842,6 +876,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
842
876
  cache,
843
877
  logger: createMockLogger() as never,
844
878
  catalogClient: createMockCatalogClient() as never,
879
+ notificationClient: createMockNotificationClient() as never,
845
880
  signalService,
846
881
  });
847
882
 
@@ -883,6 +918,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
883
918
  cache,
884
919
  logger: logger as never,
885
920
  catalogClient: failingClient as never,
921
+ notificationClient: createMockNotificationClient() as never,
886
922
  });
887
923
 
888
924
  // State transition still happened despite notification failure
package/src/detector.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  } from "@checkstack/anomaly-common";
12
12
  import type { Logger } from "@checkstack/backend-api";
13
13
  import type { CatalogApi } from "@checkstack/catalog-common";
14
+ import type { NotificationApi } from "@checkstack/notification-common";
14
15
  import type { InferClient } from "@checkstack/common";
15
16
  import { AnomalyService } from "./service";
16
17
  import type { AnomalySettings, AnomalyDirection } from "@checkstack/anomaly-common";
@@ -38,6 +39,7 @@ export async function processCheckCompleted({
38
39
  routerCache,
39
40
  logger,
40
41
  catalogClient,
42
+ notificationClient,
41
43
  signalService,
42
44
  collectorRegistry,
43
45
  }: {
@@ -54,6 +56,7 @@ export async function processCheckCompleted({
54
56
  };
55
57
  logger: Logger;
56
58
  catalogClient: InferClient<typeof CatalogApi>;
59
+ notificationClient: InferClient<typeof NotificationApi>;
57
60
  signalService?: SignalService;
58
61
  collectorRegistry: CollectorRegistry;
59
62
  }) {
@@ -295,6 +298,8 @@ export async function processCheckCompleted({
295
298
  observedValue: value,
296
299
  baselineMean: baseline.mean,
297
300
  catalogClient,
301
+ notificationClient,
302
+ db,
298
303
  logger,
299
304
  });
300
305
  } else {
@@ -352,6 +357,8 @@ export async function processCheckCompleted({
352
357
  observedValue: value,
353
358
  baselineMean: baseline.mean,
354
359
  catalogClient,
360
+ notificationClient,
361
+ db,
355
362
  logger,
356
363
  });
357
364
  }
@@ -21,6 +21,14 @@ function createMockCatalogClient() {
21
21
  };
22
22
  }
23
23
 
24
+ function createMockNotificationClient(subscriberIds: string[] = ["user-1"]) {
25
+ return {
26
+ notifyForSubscription: mock(async () => ({
27
+ notifiedCount: subscriberIds.length,
28
+ })),
29
+ };
30
+ }
31
+
24
32
  function createMockLogger() {
25
33
  return {
26
34
  debug: mock(() => {}),
@@ -132,6 +140,7 @@ describe("evaluateDrift", () => {
132
140
  templateConfig: defaultTemplate,
133
141
  db: db as never,
134
142
  catalogClient: createMockCatalogClient() as never,
143
+ notificationClient: createMockNotificationClient() as never,
135
144
  logger: createMockLogger() as never,
136
145
  });
137
146
  expect(db._insertCalls.length).toBe(0);
@@ -147,6 +156,7 @@ describe("evaluateDrift", () => {
147
156
  templateConfig: defaultTemplate,
148
157
  db: db as never,
149
158
  catalogClient: createMockCatalogClient() as never,
159
+ notificationClient: createMockNotificationClient() as never,
150
160
  logger: createMockLogger() as never,
151
161
  });
152
162
  expect(db._insertCalls.length).toBe(0);
@@ -161,6 +171,7 @@ describe("evaluateDrift", () => {
161
171
  templateConfig: { ...defaultTemplate, driftEnabled: false },
162
172
  db: db as never,
163
173
  catalogClient: createMockCatalogClient() as never,
174
+ notificationClient: createMockNotificationClient() as never,
164
175
  logger: createMockLogger() as never,
165
176
  });
166
177
  expect(db._insertCalls.length).toBe(0);
@@ -175,6 +186,7 @@ describe("evaluateDrift", () => {
175
186
  templateConfig: { ...defaultTemplate, enabled: false },
176
187
  db: db as never,
177
188
  catalogClient: createMockCatalogClient() as never,
189
+ notificationClient: createMockNotificationClient() as never,
178
190
  logger: createMockLogger() as never,
179
191
  });
180
192
  expect(db._insertCalls.length).toBe(0);
@@ -189,6 +201,7 @@ describe("evaluateDrift", () => {
189
201
  templateConfig: defaultTemplate,
190
202
  db: db as never,
191
203
  catalogClient: createMockCatalogClient() as never,
204
+ notificationClient: createMockNotificationClient() as never,
192
205
  logger: createMockLogger() as never,
193
206
  });
194
207
  expect(db._insertCalls.length).toBe(0);
@@ -206,6 +219,7 @@ describe("evaluateDrift", () => {
206
219
  templateConfig: defaultTemplate,
207
220
  db: db as never,
208
221
  catalogClient: createMockCatalogClient() as never,
222
+ notificationClient: createMockNotificationClient() as never,
209
223
  logger: createMockLogger() as never,
210
224
  signalService: signalService as never,
211
225
  });
@@ -234,6 +248,7 @@ describe("evaluateDrift", () => {
234
248
  templateConfig: defaultTemplate,
235
249
  db: db as never,
236
250
  catalogClient: createMockCatalogClient() as never,
251
+ notificationClient: createMockNotificationClient() as never,
237
252
  logger: createMockLogger() as never,
238
253
  });
239
254
  expect(db._updateCalls.length).toBe(1);
@@ -250,6 +265,7 @@ describe("evaluateDrift", () => {
250
265
  };
251
266
  const db = createMockDb({ existingAnomaly: existing });
252
267
  const catalog = createMockCatalogClient();
268
+ const notification = createMockNotificationClient();
253
269
  const signalService = createMockSignalService();
254
270
  await evaluateDrift({
255
271
  ...baseProps,
@@ -258,12 +274,13 @@ describe("evaluateDrift", () => {
258
274
  templateConfig: defaultTemplate,
259
275
  db: db as never,
260
276
  catalogClient: catalog as never,
277
+ notificationClient: notification as never,
261
278
  logger: createMockLogger() as never,
262
279
  signalService: signalService as never,
263
280
  });
264
281
  expect(db._updateCalls.length).toBe(1);
265
282
  expect(db._updateCalls[0].state).toBe("anomaly");
266
- expect(catalog.notifySystemSubscribers).toHaveBeenCalledTimes(1);
283
+ expect(notification.notifyForSubscription).toHaveBeenCalledTimes(1);
267
284
  // Two signals: state change + trend detected
268
285
  expect(signalService.broadcast).toHaveBeenCalledTimes(2);
269
286
  });
@@ -277,6 +294,7 @@ describe("evaluateDrift", () => {
277
294
  };
278
295
  const db = createMockDb({ existingAnomaly: existing });
279
296
  const catalog = createMockCatalogClient();
297
+ const notification = createMockNotificationClient();
280
298
  await evaluateDrift({
281
299
  ...baseProps,
282
300
  baseline: driftingBaseline,
@@ -284,11 +302,12 @@ describe("evaluateDrift", () => {
284
302
  templateConfig: defaultTemplate,
285
303
  db: db as never,
286
304
  catalogClient: catalog as never,
305
+ notificationClient: notification as never,
287
306
  logger: createMockLogger() as never,
288
307
  });
289
308
  expect(db._updateCalls.length).toBe(1);
290
309
  expect(db._updateCalls[0].state).toBeUndefined();
291
- expect(catalog.notifySystemSubscribers).not.toHaveBeenCalled();
310
+ expect(notification.notifyForSubscription).not.toHaveBeenCalled();
292
311
  });
293
312
 
294
313
  test("deletes suspicious row when drift goes away before confirmation", async () => {
@@ -306,6 +325,7 @@ describe("evaluateDrift", () => {
306
325
  templateConfig: defaultTemplate,
307
326
  db: db as never,
308
327
  catalogClient: createMockCatalogClient() as never,
328
+ notificationClient: createMockNotificationClient() as never,
309
329
  logger: createMockLogger() as never,
310
330
  });
311
331
  expect(db._deleteCalls.length).toBe(1);
@@ -320,6 +340,7 @@ describe("evaluateDrift", () => {
320
340
  };
321
341
  const db = createMockDb({ existingAnomaly: existing });
322
342
  const catalog = createMockCatalogClient();
343
+ const notification = createMockNotificationClient();
323
344
  await evaluateDrift({
324
345
  ...baseProps,
325
346
  baseline: stableBaseline,
@@ -327,11 +348,12 @@ describe("evaluateDrift", () => {
327
348
  templateConfig: defaultTemplate,
328
349
  db: db as never,
329
350
  catalogClient: catalog as never,
351
+ notificationClient: notification as never,
330
352
  logger: createMockLogger() as never,
331
353
  });
332
354
  expect(db._updateCalls.length).toBe(1);
333
355
  expect(db._updateCalls[0].state).toBe("recovered");
334
- expect(catalog.notifySystemSubscribers).toHaveBeenCalledTimes(1);
356
+ expect(notification.notifyForSubscription).toHaveBeenCalledTimes(1);
335
357
  });
336
358
 
337
359
  test("does nothing when no row and no drift (steady state)", async () => {
@@ -343,6 +365,7 @@ describe("evaluateDrift", () => {
343
365
  templateConfig: defaultTemplate,
344
366
  db: db as never,
345
367
  catalogClient: createMockCatalogClient() as never,
368
+ notificationClient: createMockNotificationClient() as never,
346
369
  logger: createMockLogger() as never,
347
370
  });
348
371
  expect(db._insertCalls.length).toBe(0);
@@ -362,6 +385,7 @@ describe("evaluateDrift", () => {
362
385
  templateConfig: defaultTemplate,
363
386
  db: db as never,
364
387
  catalogClient: createMockCatalogClient() as never,
388
+ notificationClient: createMockNotificationClient() as never,
365
389
  logger: createMockLogger() as never,
366
390
  });
367
391
  expect(db._insertCalls.length).toBe(0);
@@ -374,6 +398,7 @@ describe("evaluateDrift", () => {
374
398
  templateConfig: defaultTemplate,
375
399
  db: db2 as never,
376
400
  catalogClient: createMockCatalogClient() as never,
401
+ notificationClient: createMockNotificationClient() as never,
377
402
  logger: createMockLogger() as never,
378
403
  });
379
404
  expect(db2._insertCalls.length).toBe(1);
@@ -1,5 +1,6 @@
1
1
  import type { Logger, SafeDatabase } from "@checkstack/backend-api";
2
2
  import type { CatalogApi } from "@checkstack/catalog-common";
3
+ import type { NotificationApi } from "@checkstack/notification-common";
3
4
  import type { InferClient } from "@checkstack/common";
4
5
  import type { SignalService } from "@checkstack/signal-common";
5
6
  import {
@@ -24,6 +25,7 @@ export interface EvaluateDriftInput {
24
25
  db: SafeDatabase<typeof schema>;
25
26
  logger: Logger;
26
27
  catalogClient: InferClient<typeof CatalogApi>;
28
+ notificationClient: InferClient<typeof NotificationApi>;
27
29
  signalService?: SignalService;
28
30
  systemId: string;
29
31
  configurationId: string;
@@ -49,6 +51,7 @@ export async function evaluateDrift({
49
51
  db,
50
52
  logger,
51
53
  catalogClient,
54
+ notificationClient,
52
55
  signalService,
53
56
  systemId,
54
57
  configurationId,
@@ -160,6 +163,8 @@ export async function evaluateDrift({
160
163
  baselineMean: baseline.mean,
161
164
  projectedChange: driftResult.projectedChange,
162
165
  catalogClient,
166
+ notificationClient,
167
+ db,
163
168
  logger,
164
169
  });
165
170
  return;
@@ -225,6 +230,8 @@ export async function evaluateDrift({
225
230
  observedValue: baseline.mean,
226
231
  baselineMean: baseline.mean,
227
232
  catalogClient,
233
+ notificationClient,
234
+ db,
228
235
  logger,
229
236
  });
230
237
  }
@@ -1,6 +1,7 @@
1
1
  import type { CollectorRegistry, Logger, SafeDatabase } from "@checkstack/backend-api";
2
2
  import type { CacheProvider } from "@checkstack/cache-api";
3
3
  import type { CatalogApi } from "@checkstack/catalog-common";
4
+ import type { NotificationApi } from "@checkstack/notification-common";
4
5
  import type { InferClient } from "@checkstack/common";
5
6
  import type { HealthCheckApi, HealthCheckRunResult } from "@checkstack/healthcheck-common";
6
7
  import { getHealthResultMeta } from "@checkstack/healthcheck-common";
@@ -33,6 +34,7 @@ export async function setupBaselineAnalyzerJob({
33
34
  healthCheckClient,
34
35
  signalService,
35
36
  catalogClient,
37
+ notificationClient,
36
38
  collectorRegistry,
37
39
  }: {
38
40
  db: SafeDatabase<typeof schema>;
@@ -42,6 +44,7 @@ export async function setupBaselineAnalyzerJob({
42
44
  healthCheckClient: InferClient<typeof HealthCheckApi>;
43
45
  signalService?: SignalService;
44
46
  catalogClient: InferClient<typeof CatalogApi>;
47
+ notificationClient: InferClient<typeof NotificationApi>;
45
48
  collectorRegistry: CollectorRegistry;
46
49
  }) {
47
50
  const queue = queueManager.getQueue(BASELINE_ANALYZER_QUEUE);
@@ -217,6 +220,7 @@ export async function setupBaselineAnalyzerJob({
217
220
  db,
218
221
  logger,
219
222
  catalogClient,
223
+ notificationClient,
220
224
  signalService,
221
225
  systemId: assignment.systemId,
222
226
  configurationId: assignment.configurationId,
@@ -0,0 +1,155 @@
1
+ import { describe, test, expect, mock } from "bun:test";
2
+ import { dispatchAnomalyNotification } from "./notification";
3
+
4
+ /**
5
+ * The mute filter behaviour is the integration point that the per-field
6
+ * opt-out feature hangs on. These tests exercise dispatchAnomalyNotification
7
+ * end-to-end against an in-memory db mock that controls which userIds appear
8
+ * as muted, since AnomalyService.getMutedUserIds is what the dispatcher
9
+ * relies on. The other suites (detector / drift-evaluator) cover the
10
+ * surrounding state-machine logic.
11
+ */
12
+
13
+ function createMockCatalogClient() {
14
+ return {
15
+ getSystem: mock(async () => ({ name: "Production API" })),
16
+ };
17
+ }
18
+
19
+ function createMockNotificationClient(subscriberIds: string[]) {
20
+ // The new dispatch goes through notifyForSubscription. This mock
21
+ // simulates the server-side resolution by emitting a synthetic
22
+ // "userIds passed to notify" surface that tests assert on. Includes
23
+ // simulated mute filtering since the dispatcher applies excludeUserIds.
24
+ return {
25
+ notifyForSubscription: mock(async (input: {
26
+ excludeUserIds?: string[];
27
+ }) => {
28
+ const excluded = new Set(input.excludeUserIds ?? []);
29
+ const recipients = subscriberIds.filter((id) => !excluded.has(id));
30
+ return { notifiedCount: recipients.length, recipients };
31
+ }),
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Minimal Drizzle-shaped mock that only answers the one query
37
+ * AnomalyService.getMutedUserIds issues. Returns the supplied muted ids.
38
+ */
39
+ function createDbWithMutes(mutedUserIds: string[]) {
40
+ const rows = mutedUserIds.map((userId) => ({ userId }));
41
+ return {
42
+ select: mock(() => ({
43
+ from: mock(() => ({
44
+ where: mock(() => Promise.resolve(rows)),
45
+ })),
46
+ })),
47
+ };
48
+ }
49
+
50
+ const baseInput = {
51
+ systemId: "sys-1",
52
+ fieldPath: "collectors.http.request.responseTimeMs",
53
+ observedValue: 250,
54
+ baselineMean: 100,
55
+ logger: { warn: mock(() => undefined), debug: mock(() => undefined) },
56
+ };
57
+
58
+ describe("dispatchAnomalyNotification — mute filtering", () => {
59
+ test("notifies all subscribers when none have muted the field", async () => {
60
+ const catalogClient = createMockCatalogClient();
61
+ const notificationClient = createMockNotificationClient([
62
+ "alice",
63
+ "bob",
64
+ "carol",
65
+ ]);
66
+ const db = createDbWithMutes([]);
67
+
68
+ await dispatchAnomalyNotification({
69
+ ...baseInput,
70
+ action: "confirmed",
71
+ catalogClient: catalogClient as never,
72
+ notificationClient: notificationClient as never,
73
+ db: db as never,
74
+ logger: baseInput.logger as never,
75
+ });
76
+
77
+ expect(notificationClient.notifyForSubscription).toHaveBeenCalledTimes(1);
78
+ const payload = (
79
+ notificationClient.notifyForSubscription.mock.calls[0] as unknown[]
80
+ )[0] as { excludeUserIds?: string[]; resourceKeys: string[] };
81
+ expect(payload.excludeUserIds ?? []).toEqual([]);
82
+ expect(payload.resourceKeys).toEqual(["sys-1"]);
83
+ });
84
+
85
+ test("excludes users who muted the specific field", async () => {
86
+ const catalogClient = createMockCatalogClient();
87
+ const notificationClient = createMockNotificationClient([
88
+ "alice",
89
+ "bob",
90
+ "carol",
91
+ ]);
92
+ const db = createDbWithMutes(["bob"]);
93
+
94
+ await dispatchAnomalyNotification({
95
+ ...baseInput,
96
+ action: "confirmed",
97
+ catalogClient: catalogClient as never,
98
+ notificationClient: notificationClient as never,
99
+ db: db as never,
100
+ logger: baseInput.logger as never,
101
+ });
102
+
103
+ expect(notificationClient.notifyForSubscription).toHaveBeenCalledTimes(1);
104
+ const payload = (
105
+ notificationClient.notifyForSubscription.mock.calls[0] as unknown[]
106
+ )[0] as { excludeUserIds?: string[] };
107
+ expect(payload.excludeUserIds ?? []).toEqual(["bob"]);
108
+ });
109
+
110
+ test("forwards every muted user as excludeUserIds", async () => {
111
+ const catalogClient = createMockCatalogClient();
112
+ const notificationClient = createMockNotificationClient(["alice", "bob"]);
113
+ const db = createDbWithMutes(["alice", "bob"]);
114
+
115
+ await dispatchAnomalyNotification({
116
+ ...baseInput,
117
+ action: "confirmed",
118
+ catalogClient: catalogClient as never,
119
+ notificationClient: notificationClient as never,
120
+ db: db as never,
121
+ logger: baseInput.logger as never,
122
+ });
123
+
124
+ expect(notificationClient.notifyForSubscription).toHaveBeenCalledTimes(1);
125
+ const payload = (
126
+ notificationClient.notifyForSubscription.mock.calls[0] as unknown[]
127
+ )[0] as { excludeUserIds?: string[] };
128
+ expect((payload.excludeUserIds ?? []).sort()).toEqual(["alice", "bob"]);
129
+ });
130
+
131
+ test("delegates dispatch even when there are no mutes — backend resolves subscribers", async () => {
132
+ const catalogClient = createMockCatalogClient();
133
+ const notificationClient = createMockNotificationClient([]);
134
+ const db = createDbWithMutes([]);
135
+
136
+ await dispatchAnomalyNotification({
137
+ ...baseInput,
138
+ action: "confirmed",
139
+ catalogClient: catalogClient as never,
140
+ notificationClient: notificationClient as never,
141
+ db: db as never,
142
+ logger: baseInput.logger as never,
143
+ });
144
+
145
+ // Anomaly always delegates to the backend now — subscriber-set
146
+ // resolution happens server-side. The mock just needs to have been
147
+ // invoked once with the right specId / resourceKeys.
148
+ expect(notificationClient.notifyForSubscription).toHaveBeenCalledTimes(1);
149
+ const payload = (
150
+ notificationClient.notifyForSubscription.mock.calls[0] as unknown[]
151
+ )[0] as { specId: string; resourceKeys: string[] };
152
+ expect(payload.specId).toBe("anomaly.system");
153
+ expect(payload.resourceKeys).toEqual(["sys-1"]);
154
+ });
155
+ });