@checkstack/anomaly-backend 0.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.
@@ -0,0 +1,894 @@
1
+ import { describe, test, expect, mock, type Mock } from "bun:test";
2
+ import { processCheckCompleted } from "./detector";
3
+ import * as schema from "./schema";
4
+ import type { FieldBaseline, AnomalySettings } from "@checkstack/anomaly-common";
5
+ import type { CacheProvider } from "@checkstack/cache-api";
6
+ import type { CollectorRegistry, RegisteredCollector } from "@checkstack/backend-api";
7
+ import {
8
+ healthResultSchema,
9
+ healthResultNumber,
10
+ healthResultString,
11
+ healthResultBoolean,
12
+ } from "@checkstack/healthcheck-common";
13
+
14
+ // ─────────────────────────────────────────────────────────────────────────────
15
+ // Mock Factories
16
+ // ─────────────────────────────────────────────────────────────────────────────
17
+
18
+ function createBaseline(overrides: Partial<FieldBaseline> = {}): FieldBaseline {
19
+ return {
20
+ mean: 100,
21
+ stdDev: 10,
22
+ trendSlope: 0,
23
+ sampleCount: 50,
24
+ computedAt: "2026-04-28T00:00:00.000Z",
25
+ ...overrides,
26
+ };
27
+ }
28
+
29
+ function createMockCache(baselineMap: Map<string, FieldBaseline> = new Map()): CacheProvider {
30
+ return {
31
+ get: mock(async (key: string) => baselineMap.get(key)) as CacheProvider["get"],
32
+ set: mock(async () => {}),
33
+ delete: mock(async () => {}),
34
+ deleteByPrefix: mock(async () => 0),
35
+ has: mock(async () => false),
36
+ };
37
+ }
38
+
39
+ function createMockCatalogClient() {
40
+ return {
41
+ getSystem: mock(async () => ({ name: "Test System" })),
42
+ notifySystemSubscribers: mock(async () => ({ notifiedCount: 0 })),
43
+ };
44
+ }
45
+
46
+ function createMockLogger() {
47
+ return {
48
+ debug: mock(() => {}),
49
+ info: mock(() => {}),
50
+ warn: mock(() => {}),
51
+ error: mock(() => {}),
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Creates a mock DB using reference equality against imported schema tables
57
+ * to correctly route queries to the right return values.
58
+ */
59
+ function createMockDb({
60
+ existingAnomaly,
61
+ baselineFromDb,
62
+ configRecord,
63
+ assignmentRecord,
64
+ }: {
65
+ existingAnomaly?: Record<string, unknown>;
66
+ baselineFromDb?: Record<string, unknown>;
67
+ configRecord?: Record<string, unknown>;
68
+ assignmentRecord?: Record<string, unknown>;
69
+ } = {}) {
70
+ const insertCalls: Array<Record<string, unknown>> = [];
71
+ const updateCalls: Array<Record<string, unknown>> = [];
72
+ const deleteCalls: unknown[] = [];
73
+
74
+ /**
75
+ * Create an object that is BOTH awaitable (thenable) and has .limit() / .orderBy().
76
+ * Drizzle queries can be consumed in two ways:
77
+ * 1. `const [result] = await db.select().from(t).where(c)` (service pattern)
78
+ * 2. `const [result] = await db.select().from(t).where(c).limit(1)` (detector pattern)
79
+ */
80
+ const makeThenableChain = (rows: unknown[]) => {
81
+ const promise = Promise.resolve(rows);
82
+ return {
83
+ then: promise.then.bind(promise),
84
+ catch: promise.catch.bind(promise),
85
+ limit: mock(() => Promise.resolve(rows)),
86
+ orderBy: mock(() => ({
87
+ then: promise.then.bind(promise),
88
+ catch: promise.catch.bind(promise),
89
+ limit: mock(() => Promise.resolve(rows)),
90
+ })),
91
+ };
92
+ };
93
+
94
+ const makeWhereChain = (rows: unknown[]) => ({
95
+ where: mock(() => makeThenableChain(rows)),
96
+ ...makeThenableChain(rows),
97
+ });
98
+
99
+ const db = {
100
+ select: mock(() => ({
101
+ from: mock((table: unknown) => {
102
+ // Use reference equality against imported schema tables
103
+ if (table === schema.anomalyBaselines) {
104
+ return makeWhereChain(baselineFromDb ? [baselineFromDb] : []);
105
+ }
106
+ if (table === schema.anomalyConfigurations) {
107
+ return makeWhereChain(configRecord ? [{ config: configRecord }] : []);
108
+ }
109
+ if (table === schema.anomalyAssignments) {
110
+ return makeWhereChain(assignmentRecord ? [{ config: assignmentRecord }] : []);
111
+ }
112
+ if (table === schema.anomalies) {
113
+ return makeWhereChain(existingAnomaly ? [existingAnomaly] : []);
114
+ }
115
+ return makeWhereChain([]);
116
+ }),
117
+ })),
118
+ insert: mock((_table: unknown) => ({
119
+ values: mock((values: Record<string, unknown>) => {
120
+ insertCalls.push(values);
121
+ return {
122
+ onConflictDoUpdate: mock(() => ({
123
+ returning: mock(() => Promise.resolve([{ config: values.config }])),
124
+ })),
125
+ returning: mock(() =>
126
+ Promise.resolve([{ id: `anomaly-${insertCalls.length}` }]),
127
+ ),
128
+ };
129
+ }),
130
+ })),
131
+ update: mock((_table: unknown) => ({
132
+ set: mock((setValues: Record<string, unknown>) => ({
133
+ where: mock(() => {
134
+ updateCalls.push(setValues);
135
+ return Promise.resolve();
136
+ }),
137
+ })),
138
+ })),
139
+ delete: mock((_table: unknown) => ({
140
+ where: mock(() => {
141
+ deleteCalls.push(true);
142
+ return Promise.resolve();
143
+ }),
144
+ })),
145
+ _insertCalls: insertCalls,
146
+ _updateCalls: updateCalls,
147
+ _deleteCalls: deleteCalls,
148
+ };
149
+
150
+ return db;
151
+ }
152
+
153
+ // Real schemas registered with healthcheck-common's healthResultRegistry, so
154
+ // detector.ts's getHealthResultMeta() lookup resolves x-anomaly-direction.
155
+ const mockResultSchema = healthResultSchema({
156
+ responseTimeMs: healthResultNumber({
157
+ "x-chart-type": "line",
158
+ "x-anomaly-enabled": true,
159
+ "x-anomaly-direction": "lower-is-better",
160
+ }),
161
+ statusCode: healthResultNumber({
162
+ "x-chart-type": "counter",
163
+ "x-anomaly-enabled": true,
164
+ "x-anomaly-direction": "deviation",
165
+ }),
166
+ availability: healthResultNumber({
167
+ "x-chart-type": "gauge",
168
+ "x-anomaly-enabled": true,
169
+ "x-anomaly-direction": "higher-is-better",
170
+ }),
171
+ bodyText: healthResultString({
172
+ "x-chart-type": "text",
173
+ "x-anomaly-enabled": false,
174
+ }),
175
+ statusText: healthResultString({
176
+ "x-chart-type": "status",
177
+ "x-anomaly-enabled": true,
178
+ "x-anomaly-direction": "dominance",
179
+ }),
180
+ isRunning: healthResultBoolean({
181
+ "x-chart-type": "boolean",
182
+ "x-anomaly-enabled": true,
183
+ "x-anomaly-direction": "dominance",
184
+ }),
185
+ });
186
+
187
+ const createMockCollectorRegistry = (): CollectorRegistry => {
188
+ const registered = {
189
+ qualifiedId: "http.request",
190
+ collector: {
191
+ result: { schema: mockResultSchema },
192
+ },
193
+ ownerPlugin: { id: "healthcheck-http", name: "HTTP" },
194
+ } as unknown as RegisteredCollector;
195
+
196
+ return {
197
+ register: mock(() => {}),
198
+ getCollector: mock(() => registered),
199
+ getCollectorsForPlugin: mock(() => [registered]),
200
+ getCollectors: mock(() => [registered]),
201
+ };
202
+ };
203
+
204
+ // ─────────────────────────────────────────────────────────────────────────────
205
+ // Constants
206
+ // ─────────────────────────────────────────────────────────────────────────────
207
+
208
+ const systemId = "sys-1";
209
+ const configurationId = "config-1";
210
+ const timestamp = new Date().toISOString();
211
+ const cacheKeyPrefix = `baseline:${configurationId}:${systemId}:collectors.http.request.responseTimeMs`;
212
+
213
+ const anomalousResult = {
214
+ "uuid-1": {
215
+ _collectorId: "http.request",
216
+ responseTimeMs: 200, // 10σ above mean=100, stdDev=10
217
+ },
218
+ };
219
+ const normalResult = {
220
+ "uuid-1": {
221
+ _collectorId: "http.request",
222
+ responseTimeMs: 105, // Within 100 ± 30
223
+ },
224
+ };
225
+
226
+ const baseProps = {
227
+ systemId,
228
+ configurationId,
229
+ status: "healthy",
230
+ timestamp,
231
+ collectorRegistry: createMockCollectorRegistry(),
232
+ };
233
+
234
+ // ─────────────────────────────────────────────────────────────────────────────
235
+ // Tests
236
+ // ─────────────────────────────────────────────────────────────────────────────
237
+
238
+ describe("Anomaly Detector — processCheckCompleted", () => {
239
+ // ─── Early exit conditions ─────────────────────────────────────────────
240
+
241
+ test("skips processing when result is undefined", async () => {
242
+ const cache = createMockCache();
243
+ await processCheckCompleted({
244
+ ...baseProps,
245
+ latencyMs: 50,
246
+ result: undefined,
247
+ db: createMockDb() as never,
248
+ cache,
249
+ logger: createMockLogger() as never,
250
+ catalogClient: createMockCatalogClient() as never,
251
+ });
252
+ expect(cache.get).not.toHaveBeenCalled();
253
+ });
254
+
255
+ test("skips processing when status is not healthy", async () => {
256
+ const cache = createMockCache();
257
+ await processCheckCompleted({
258
+ ...baseProps,
259
+ status: "unhealthy",
260
+ latencyMs: undefined,
261
+ result: anomalousResult,
262
+ db: createMockDb() as never,
263
+ cache,
264
+ logger: createMockLogger() as never,
265
+ catalogClient: createMockCatalogClient() as never,
266
+ });
267
+ expect(cache.get).not.toHaveBeenCalled();
268
+ });
269
+
270
+ test("processes categorical fields (string/boolean)", async () => {
271
+ const cache = createMockCache(new Map());
272
+ const db = createMockDb();
273
+ await processCheckCompleted({
274
+ ...baseProps,
275
+ latencyMs: 50,
276
+ result: {
277
+ "uuid-1": { _collectorId: "http.request", statusText: "OK", isRunning: true },
278
+ },
279
+ db: db as never,
280
+ cache,
281
+ logger: createMockLogger() as never,
282
+ catalogClient: createMockCatalogClient() as never,
283
+ });
284
+ // Two fields (statusText, isRunning) means cache.get should be called twice
285
+ expect(cache.get).toHaveBeenCalledTimes(2);
286
+ });
287
+
288
+ test("creates suspicious anomaly for dominance drift", async () => {
289
+ const baseline = createBaseline({ dominantValue: "OK", dominantRatio: 0.95 });
290
+ const cacheKey = `baseline:${configurationId}:${systemId}:collectors.http.request.statusText`;
291
+ const cache = createMockCache(new Map([[cacheKey, baseline]]));
292
+ const db = createMockDb();
293
+
294
+ await processCheckCompleted({
295
+ ...baseProps,
296
+ latencyMs: 50,
297
+ result: {
298
+ "uuid-1": { _collectorId: "http.request", statusText: "ERROR" },
299
+ },
300
+ db: db as never,
301
+ cache,
302
+ logger: createMockLogger() as never,
303
+ catalogClient: createMockCatalogClient() as never,
304
+ });
305
+
306
+ expect(db._insertCalls.length).toBe(1);
307
+ expect(db._insertCalls[0]).toMatchObject({
308
+ state: "suspicious",
309
+ direction: "above", // Categorical defaults to "above"
310
+ observedValue: "ERROR",
311
+ deviation: 0,
312
+ suspiciousRunCount: 1,
313
+ });
314
+ });
315
+
316
+ // ─── Learning phase (no baseline) ─────────────────────────────────────
317
+
318
+ test("skips field with no baseline (learning phase)", async () => {
319
+ const cache = createMockCache(new Map());
320
+ const db = createMockDb();
321
+ await processCheckCompleted({
322
+ ...baseProps,
323
+ latencyMs: 50,
324
+ result: anomalousResult,
325
+ db: db as never,
326
+ cache,
327
+ logger: createMockLogger() as never,
328
+ catalogClient: createMockCatalogClient() as never,
329
+ });
330
+ expect(cache.get).toHaveBeenCalledTimes(1);
331
+ expect(db._insertCalls.length).toBe(0);
332
+ });
333
+
334
+ // ─── Normal value → no anomaly ────────────────────────────────────────
335
+
336
+ test("does not create anomaly for value within normal bounds", async () => {
337
+ const baseline = createBaseline({ mean: 100, stdDev: 10 });
338
+ const cache = createMockCache(new Map([[cacheKeyPrefix, baseline]]));
339
+ const db = createMockDb();
340
+ await processCheckCompleted({
341
+ ...baseProps,
342
+ latencyMs: 50,
343
+ result: normalResult,
344
+ db: db as never,
345
+ cache,
346
+ logger: createMockLogger() as never,
347
+ catalogClient: createMockCatalogClient() as never,
348
+ });
349
+ expect(db._insertCalls.length).toBe(0);
350
+ });
351
+
352
+ // ─── Anomalous value → create suspicious ──────────────────────────────
353
+
354
+ test("creates suspicious anomaly for value outside bounds", async () => {
355
+ const baseline = createBaseline({ mean: 100, stdDev: 10 });
356
+ const cache = createMockCache(new Map([[cacheKeyPrefix, baseline]]));
357
+ const db = createMockDb();
358
+ await processCheckCompleted({
359
+ ...baseProps,
360
+ latencyMs: 50,
361
+ result: anomalousResult,
362
+ db: db as never,
363
+ cache,
364
+ logger: createMockLogger() as never,
365
+ catalogClient: createMockCatalogClient() as never,
366
+ });
367
+ expect(db._insertCalls.length).toBe(1);
368
+ expect(db._insertCalls[0]).toMatchObject({
369
+ state: "suspicious",
370
+ direction: "above",
371
+ suspiciousRunCount: 1,
372
+ systemId,
373
+ configurationId,
374
+ });
375
+ });
376
+
377
+ // ─── Suspicious → confirmed → notification dispatch ───────────────────
378
+
379
+ test("confirms anomaly and dispatches Sidecar notification", async () => {
380
+ const baseline = createBaseline({ mean: 100, stdDev: 10 });
381
+ const cache = createMockCache(new Map([[cacheKeyPrefix, baseline]]));
382
+ const catalogClient = createMockCatalogClient();
383
+ const db = createMockDb({
384
+ existingAnomaly: {
385
+ id: "anomaly-existing",
386
+ systemId,
387
+ configurationId,
388
+ fieldPath: "collectors.http.request.responseTimeMs",
389
+ state: "suspicious",
390
+ suspiciousRunCount: 2,
391
+ confirmationThreshold: 3,
392
+ },
393
+ });
394
+
395
+ await processCheckCompleted({
396
+ ...baseProps,
397
+ latencyMs: 50,
398
+ result: anomalousResult,
399
+ db: db as never,
400
+ cache,
401
+ logger: createMockLogger() as never,
402
+ catalogClient: catalogClient as never,
403
+ });
404
+
405
+ expect(db._updateCalls.length).toBe(1);
406
+ expect(db._updateCalls[0]).toMatchObject({ state: "anomaly" });
407
+
408
+ expect(catalogClient.notifySystemSubscribers).toHaveBeenCalledTimes(1);
409
+ const notifArgs = (catalogClient.notifySystemSubscribers as Mock<(...args: unknown[]) => unknown>).mock.calls[0] as unknown[];
410
+ const notifPayload = notifArgs[0] as Record<string, unknown>;
411
+ expect(notifPayload).toMatchObject({
412
+ systemId,
413
+ importance: "warning",
414
+ includeGroupSubscribers: true,
415
+ });
416
+ expect(notifPayload.title).toContain("Anomaly Detected");
417
+ });
418
+
419
+ // ─── Recovery → info notification ─────────────────────────────────────
420
+
421
+ test("recovers anomaly and dispatches info notification", async () => {
422
+ const baseline = createBaseline({ mean: 100, stdDev: 10 });
423
+ const cache = createMockCache(new Map([[cacheKeyPrefix, baseline]]));
424
+ const catalogClient = createMockCatalogClient();
425
+ const db = createMockDb({
426
+ existingAnomaly: {
427
+ id: "anomaly-confirmed",
428
+ systemId,
429
+ configurationId,
430
+ fieldPath: "collectors.http.request.responseTimeMs",
431
+ state: "anomaly",
432
+ suspiciousRunCount: 5,
433
+ confirmationThreshold: 3,
434
+ },
435
+ });
436
+
437
+ await processCheckCompleted({
438
+ ...baseProps,
439
+ latencyMs: 50,
440
+ result: normalResult,
441
+ db: db as never,
442
+ cache,
443
+ logger: createMockLogger() as never,
444
+ catalogClient: catalogClient as never,
445
+ });
446
+
447
+ expect(db._updateCalls.length).toBe(1);
448
+ expect(db._updateCalls[0]).toMatchObject({ state: "recovered" });
449
+
450
+ expect(catalogClient.notifySystemSubscribers).toHaveBeenCalledTimes(1);
451
+ const notifArgs = (catalogClient.notifySystemSubscribers as Mock<(...args: unknown[]) => unknown>).mock.calls[0] as unknown[];
452
+ const notifPayload = notifArgs[0] as Record<string, unknown>;
453
+ expect(notifPayload).toMatchObject({
454
+ systemId,
455
+ importance: "info",
456
+ });
457
+ expect(notifPayload.title).toContain("Recovered");
458
+ });
459
+
460
+ // ─── Transient spike → deleted ────────────────────────────────────────
461
+
462
+ test("deletes suspicious record when value returns to normal", async () => {
463
+ const baseline = createBaseline({ mean: 100, stdDev: 10 });
464
+ const cache = createMockCache(new Map([[cacheKeyPrefix, baseline]]));
465
+ const db = createMockDb({
466
+ existingAnomaly: {
467
+ id: "anomaly-transient",
468
+ systemId,
469
+ configurationId,
470
+ fieldPath: "collectors.http.request.responseTimeMs",
471
+ state: "suspicious",
472
+ suspiciousRunCount: 1,
473
+ confirmationThreshold: 3,
474
+ },
475
+ });
476
+
477
+ await processCheckCompleted({
478
+ ...baseProps,
479
+ latencyMs: 50,
480
+ result: normalResult,
481
+ db: db as never,
482
+ cache,
483
+ logger: createMockLogger() as never,
484
+ catalogClient: createMockCatalogClient() as never,
485
+ });
486
+
487
+ expect(db._deleteCalls.length).toBe(1);
488
+ });
489
+
490
+ // ─── Config disabled ──────────────────────────────────────────────────
491
+
492
+ test("skips processing when config is disabled", async () => {
493
+ const baseline = createBaseline();
494
+ const cache = createMockCache(new Map([[cacheKeyPrefix, baseline]]));
495
+ const db = createMockDb({
496
+ configRecord: {
497
+ version: 1,
498
+ data: { enabled: false, sensitivity: 1, confirmationWindow: 3, baselineWindow: "7d", notify: true, driftEnabled: true, driftThreshold: 2 } satisfies AnomalySettings,
499
+ },
500
+ });
501
+
502
+ await processCheckCompleted({
503
+ ...baseProps,
504
+ latencyMs: 50,
505
+ result: anomalousResult,
506
+ db: db as never,
507
+ cache,
508
+ logger: createMockLogger() as never,
509
+ catalogClient: createMockCatalogClient() as never,
510
+ });
511
+
512
+ expect(db._insertCalls.length).toBe(0);
513
+ });
514
+
515
+ // ─── Field path extraction ────────────────────────────────────────────
516
+
517
+ test("correctly builds field paths from collector results", async () => {
518
+ const baseline = createBaseline();
519
+ const path1 = `baseline:${configurationId}:${systemId}:collectors.http.request.responseTimeMs`;
520
+ const path2 = `baseline:${configurationId}:${systemId}:collectors.http.request.statusCode`;
521
+ const cache = createMockCache(new Map([[path1, baseline], [path2, baseline]]));
522
+
523
+ await processCheckCompleted({
524
+ ...baseProps,
525
+ latencyMs: 50,
526
+ result: {
527
+ "uuid-1": {
528
+ _collectorId: "http.request",
529
+ responseTimeMs: 105,
530
+ statusCode: 200,
531
+ _assertionFailed: undefined,
532
+ },
533
+ },
534
+ db: createMockDb() as never,
535
+ cache,
536
+ logger: createMockLogger() as never,
537
+ catalogClient: createMockCatalogClient() as never,
538
+ });
539
+
540
+ expect(cache.get).toHaveBeenCalledTimes(2);
541
+ expect(cache.get).toHaveBeenCalledWith(path1);
542
+ expect(cache.get).toHaveBeenCalledWith(path2);
543
+ });
544
+
545
+ // ─── Cache miss → DB fallback ─────────────────────────────────────────
546
+
547
+ test("falls back to DB when cache misses and repopulates cache", async () => {
548
+ const cache = createMockCache(new Map());
549
+ const db = createMockDb({
550
+ baselineFromDb: {
551
+ mean: 100,
552
+ stdDev: 10,
553
+ trendSlope: 0,
554
+ sampleCount: 50,
555
+ computedAt: new Date("2026-04-28T00:00:00.000Z"),
556
+ dominantValue: undefined,
557
+ dominantRatio: undefined,
558
+ },
559
+ });
560
+
561
+ await processCheckCompleted({
562
+ ...baseProps,
563
+ latencyMs: 50,
564
+ result: normalResult,
565
+ db: db as never,
566
+ cache,
567
+ logger: createMockLogger() as never,
568
+ catalogClient: createMockCatalogClient() as never,
569
+ });
570
+
571
+ expect(cache.set).toHaveBeenCalledTimes(1);
572
+ const setCalls = (cache.set as Mock<(...args: unknown[]) => unknown>).mock.calls;
573
+ const firstSetCall = setCalls[0] as unknown[];
574
+ expect(firstSetCall[0]).toContain("baseline:");
575
+ expect(firstSetCall[2]).toBe(1000 * 60 * 60); // 1 hour TTL
576
+ });
577
+
578
+ // ─── Schema-direction resolution (refactor-specific edge cases) ───────
579
+
580
+ test("resolves higher-is-better direction from schema (value below mean is anomalous)", async () => {
581
+ const baseline = createBaseline({ mean: 99, stdDev: 1 });
582
+ const cacheKey = `baseline:${configurationId}:${systemId}:collectors.http.request.availability`;
583
+ const cache = createMockCache(new Map([[cacheKey, baseline]]));
584
+ const db = createMockDb();
585
+
586
+ await processCheckCompleted({
587
+ ...baseProps,
588
+ latencyMs: 50,
589
+ result: {
590
+ "uuid-1": { _collectorId: "http.request", availability: 50 }, // far below 99
591
+ },
592
+ db: db as never,
593
+ cache,
594
+ logger: createMockLogger() as never,
595
+ catalogClient: createMockCatalogClient() as never,
596
+ });
597
+
598
+ expect(db._insertCalls.length).toBe(1);
599
+ expect(db._insertCalls[0]).toMatchObject({
600
+ state: "suspicious",
601
+ direction: "below",
602
+ });
603
+ });
604
+
605
+ test("higher-is-better ignores values above the mean", async () => {
606
+ const baseline = createBaseline({ mean: 95, stdDev: 1 });
607
+ const cacheKey = `baseline:${configurationId}:${systemId}:collectors.http.request.availability`;
608
+ const cache = createMockCache(new Map([[cacheKey, baseline]]));
609
+ const db = createMockDb();
610
+
611
+ await processCheckCompleted({
612
+ ...baseProps,
613
+ latencyMs: 50,
614
+ result: {
615
+ "uuid-1": { _collectorId: "http.request", availability: 100 }, // above mean — that's good
616
+ },
617
+ db: db as never,
618
+ cache,
619
+ logger: createMockLogger() as never,
620
+ catalogClient: createMockCatalogClient() as never,
621
+ });
622
+
623
+ expect(db._insertCalls.length).toBe(0);
624
+ });
625
+
626
+ test("resolves deviation direction (value far from mean in either direction is anomalous)", async () => {
627
+ const baseline = createBaseline({ mean: 200, stdDev: 5 });
628
+ const cacheKey = `baseline:${configurationId}:${systemId}:collectors.http.request.statusCode`;
629
+ const cache = createMockCache(new Map([[cacheKey, baseline]]));
630
+ const db = createMockDb();
631
+
632
+ await processCheckCompleted({
633
+ ...baseProps,
634
+ latencyMs: 50,
635
+ result: {
636
+ "uuid-1": { _collectorId: "http.request", statusCode: 500 }, // far from 200
637
+ },
638
+ db: db as never,
639
+ cache,
640
+ logger: createMockLogger() as never,
641
+ catalogClient: createMockCatalogClient() as never,
642
+ });
643
+
644
+ expect(db._insertCalls.length).toBe(1);
645
+ expect(db._insertCalls[0]).toMatchObject({ state: "suspicious" });
646
+ });
647
+
648
+ test("skips fields where schema has anomaly-enabled: false and config has no direction", async () => {
649
+ const baseline = createBaseline({ mean: 100, stdDev: 10 });
650
+ const cacheKey = `baseline:${configurationId}:${systemId}:collectors.http.request.bodyText`;
651
+ const cache = createMockCache(new Map([[cacheKey, baseline]]));
652
+ const db = createMockDb();
653
+
654
+ await processCheckCompleted({
655
+ ...baseProps,
656
+ latencyMs: 50,
657
+ result: {
658
+ "uuid-1": { _collectorId: "http.request", bodyText: "anything" },
659
+ },
660
+ db: db as never,
661
+ cache,
662
+ logger: createMockLogger() as never,
663
+ catalogClient: createMockCatalogClient() as never,
664
+ });
665
+
666
+ // Baseline lookup happens, but direction resolution short-circuits before insert.
667
+ expect(db._insertCalls.length).toBe(0);
668
+ });
669
+
670
+ test("skips when collector is not registered in collectorRegistry", async () => {
671
+ const baseline = createBaseline({ mean: 100, stdDev: 10 });
672
+ const cache = createMockCache(new Map([[cacheKeyPrefix, baseline]]));
673
+ const db = createMockDb();
674
+ const emptyRegistry = {
675
+ register: mock(() => {}),
676
+ getCollector: mock(() => undefined),
677
+ getCollectorsForPlugin: mock(() => []),
678
+ getCollectors: mock(() => []),
679
+ } as unknown as import("@checkstack/backend-api").CollectorRegistry;
680
+
681
+ await processCheckCompleted({
682
+ ...baseProps,
683
+ collectorRegistry: emptyRegistry,
684
+ latencyMs: 50,
685
+ result: anomalousResult,
686
+ db: db as never,
687
+ cache,
688
+ logger: createMockLogger() as never,
689
+ catalogClient: createMockCatalogClient() as never,
690
+ });
691
+
692
+ // No schema direction available, no config direction → silently skipped
693
+ expect(db._insertCalls.length).toBe(0);
694
+ });
695
+
696
+ // ─── State machine edge cases ─────────────────────────────────────────
697
+
698
+ test("increments suspiciousRunCount without crossing confirmation threshold", async () => {
699
+ const baseline = createBaseline({ mean: 100, stdDev: 10 });
700
+ const cache = createMockCache(new Map([[cacheKeyPrefix, baseline]]));
701
+ const db = createMockDb({
702
+ existingAnomaly: {
703
+ id: "anomaly-counting",
704
+ systemId,
705
+ configurationId,
706
+ fieldPath: "collectors.http.request.responseTimeMs",
707
+ state: "suspicious",
708
+ suspiciousRunCount: 1,
709
+ confirmationThreshold: 5, // Not yet reached
710
+ },
711
+ });
712
+
713
+ await processCheckCompleted({
714
+ ...baseProps,
715
+ latencyMs: 50,
716
+ result: anomalousResult,
717
+ db: db as never,
718
+ cache,
719
+ logger: createMockLogger() as never,
720
+ catalogClient: createMockCatalogClient() as never,
721
+ });
722
+
723
+ expect(db._updateCalls.length).toBe(1);
724
+ expect(db._updateCalls[0]).toMatchObject({ suspiciousRunCount: 2 });
725
+ // Critically, must NOT transition to "anomaly"
726
+ expect(db._updateCalls[0]).not.toHaveProperty("state");
727
+ });
728
+
729
+ test("updates observed value on already-confirmed anomaly without re-emitting", async () => {
730
+ const baseline = createBaseline({ mean: 100, stdDev: 10 });
731
+ const cache = createMockCache(new Map([[cacheKeyPrefix, baseline]]));
732
+ const catalogClient = createMockCatalogClient();
733
+ const db = createMockDb({
734
+ existingAnomaly: {
735
+ id: "anomaly-confirmed",
736
+ systemId,
737
+ configurationId,
738
+ fieldPath: "collectors.http.request.responseTimeMs",
739
+ state: "anomaly",
740
+ suspiciousRunCount: 5,
741
+ confirmationThreshold: 3,
742
+ },
743
+ });
744
+
745
+ await processCheckCompleted({
746
+ ...baseProps,
747
+ latencyMs: 50,
748
+ result: anomalousResult,
749
+ db: db as never,
750
+ cache,
751
+ logger: createMockLogger() as never,
752
+ catalogClient: catalogClient as never,
753
+ });
754
+
755
+ // Should update observed value but not send another notification
756
+ expect(db._updateCalls.length).toBe(1);
757
+ expect(catalogClient.notifySystemSubscribers).not.toHaveBeenCalled();
758
+ });
759
+
760
+ // ─── Signal emission (F8) ─────────────────────────────────────────────
761
+
762
+ test("broadcasts signal when suspicious anomaly is created", async () => {
763
+ const baseline = createBaseline({ mean: 100, stdDev: 10 });
764
+ const cache = createMockCache(new Map([[cacheKeyPrefix, baseline]]));
765
+ const broadcast = mock(async () => {});
766
+ const signalService = { broadcast } as never;
767
+ const db = createMockDb();
768
+
769
+ await processCheckCompleted({
770
+ ...baseProps,
771
+ latencyMs: 50,
772
+ result: anomalousResult,
773
+ db: db as never,
774
+ cache,
775
+ logger: createMockLogger() as never,
776
+ catalogClient: createMockCatalogClient() as never,
777
+ signalService,
778
+ });
779
+
780
+ expect(broadcast).toHaveBeenCalledTimes(1);
781
+ const broadcastArgs = (broadcast as Mock<(...args: unknown[]) => unknown>).mock.calls[0] as unknown[];
782
+ const broadcastPayload = broadcastArgs[1] as Record<string, unknown>;
783
+ expect(broadcastPayload).toMatchObject({ systemId, newState: "suspicious" });
784
+ });
785
+
786
+ test("broadcasts signal on confirmation transition", async () => {
787
+ const baseline = createBaseline({ mean: 100, stdDev: 10 });
788
+ const cache = createMockCache(new Map([[cacheKeyPrefix, baseline]]));
789
+ const broadcast = mock(async () => {});
790
+ const signalService = { broadcast } as never;
791
+ const db = createMockDb({
792
+ existingAnomaly: {
793
+ id: "anomaly-existing",
794
+ systemId,
795
+ configurationId,
796
+ fieldPath: "collectors.http.request.responseTimeMs",
797
+ state: "suspicious",
798
+ suspiciousRunCount: 2,
799
+ confirmationThreshold: 3,
800
+ },
801
+ });
802
+
803
+ await processCheckCompleted({
804
+ ...baseProps,
805
+ latencyMs: 50,
806
+ result: anomalousResult,
807
+ db: db as never,
808
+ cache,
809
+ logger: createMockLogger() as never,
810
+ catalogClient: createMockCatalogClient() as never,
811
+ signalService,
812
+ });
813
+
814
+ expect(broadcast).toHaveBeenCalledTimes(1);
815
+ const broadcastArgs = (broadcast as Mock<(...args: unknown[]) => unknown>).mock.calls[0] as unknown[];
816
+ const broadcastPayload = broadcastArgs[1] as Record<string, unknown>;
817
+ expect(broadcastPayload).toMatchObject({ newState: "anomaly" });
818
+ });
819
+
820
+ test("broadcasts signal on recovery", async () => {
821
+ const baseline = createBaseline({ mean: 100, stdDev: 10 });
822
+ const cache = createMockCache(new Map([[cacheKeyPrefix, baseline]]));
823
+ const broadcast = mock(async () => {});
824
+ const signalService = { broadcast } as never;
825
+ const db = createMockDb({
826
+ existingAnomaly: {
827
+ id: "anomaly-confirmed",
828
+ systemId,
829
+ configurationId,
830
+ fieldPath: "collectors.http.request.responseTimeMs",
831
+ state: "anomaly",
832
+ suspiciousRunCount: 5,
833
+ confirmationThreshold: 3,
834
+ },
835
+ });
836
+
837
+ await processCheckCompleted({
838
+ ...baseProps,
839
+ latencyMs: 50,
840
+ result: normalResult,
841
+ db: db as never,
842
+ cache,
843
+ logger: createMockLogger() as never,
844
+ catalogClient: createMockCatalogClient() as never,
845
+ signalService,
846
+ });
847
+
848
+ expect(broadcast).toHaveBeenCalledTimes(1);
849
+ const broadcastArgs = (broadcast as Mock<(...args: unknown[]) => unknown>).mock.calls[0] as unknown[];
850
+ const broadcastPayload = broadcastArgs[1] as Record<string, unknown>;
851
+ expect(broadcastPayload).toMatchObject({ newState: "recovered" });
852
+ });
853
+
854
+ // ─── Notification resilience ──────────────────────────────────────────
855
+
856
+ test("does not crash when notification dispatch fails", async () => {
857
+ const baseline = createBaseline({ mean: 100, stdDev: 10 });
858
+ const cache = createMockCache(new Map([[cacheKeyPrefix, baseline]]));
859
+ const failingClient = {
860
+ getSystem: mock(async () => {
861
+ throw new Error("catalog unreachable");
862
+ }),
863
+ notifySystemSubscribers: mock(async () => ({ notifiedCount: 0 })),
864
+ };
865
+ const logger = createMockLogger();
866
+ const db = createMockDb({
867
+ existingAnomaly: {
868
+ id: "anomaly-existing",
869
+ systemId,
870
+ configurationId,
871
+ fieldPath: "collectors.http.request.responseTimeMs",
872
+ state: "suspicious",
873
+ suspiciousRunCount: 2,
874
+ confirmationThreshold: 3,
875
+ },
876
+ });
877
+
878
+ await processCheckCompleted({
879
+ ...baseProps,
880
+ latencyMs: 50,
881
+ result: anomalousResult,
882
+ db: db as never,
883
+ cache,
884
+ logger: logger as never,
885
+ catalogClient: failingClient as never,
886
+ });
887
+
888
+ // State transition still happened despite notification failure
889
+ expect(db._updateCalls.length).toBe(1);
890
+ expect(db._updateCalls[0]).toMatchObject({ state: "anomaly" });
891
+ // The failure was logged at warn level
892
+ expect(logger.warn).toHaveBeenCalled();
893
+ });
894
+ });