@checkstack/slo-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,662 @@
1
+ import { describe, it, expect, beforeEach, mock } from "bun:test";
2
+ import { SloEngine } from "./slo-engine";
3
+ import type { SloService } from "./service";
4
+ import type { SloObjective, SloDowntimeEvent } from "@checkstack/slo-common";
5
+
6
+ // =============================================================================
7
+ // MOCK FACTORIES
8
+ // =============================================================================
9
+
10
+ function createMockSignalService() {
11
+ return {
12
+ broadcast: mock(() => Promise.resolve()),
13
+ subscribe: mock(() => () => {}),
14
+ };
15
+ }
16
+
17
+ function createMockLogger() {
18
+ return {
19
+ debug: mock(() => {}),
20
+ info: mock(() => {}),
21
+ warn: mock(() => {}),
22
+ error: mock(() => {}),
23
+ };
24
+ }
25
+
26
+ function createObjective(
27
+ overrides: Partial<SloObjective> = {},
28
+ ): SloObjective {
29
+ return {
30
+ id: "obj-1",
31
+ systemId: "sys-1",
32
+ // eslint-disable-next-line unicorn/no-null -- Zod schema uses .nullable()
33
+ healthCheckConfigurationId: null,
34
+ target: 99.9,
35
+ windowDays: 30,
36
+ dependencyExclusion: "self-only",
37
+ excludedDependencyIds: undefined,
38
+ burnRateThresholds: {
39
+ warningPercent: 50,
40
+ criticalPercent: 80,
41
+ fastBurnMultiplier: 5,
42
+ },
43
+ createdAt: new Date("2026-01-01"),
44
+ updatedAt: new Date("2026-01-01"),
45
+ ...overrides,
46
+ };
47
+ }
48
+
49
+ function createDowntimeEvent(
50
+ overrides: Partial<SloDowntimeEvent> = {},
51
+ ): SloDowntimeEvent {
52
+ return {
53
+ id: "evt-1",
54
+ objectiveId: "obj-1",
55
+ systemId: "sys-1",
56
+ startTime: new Date(),
57
+ // eslint-disable-next-line unicorn/no-null -- Zod schema uses .nullable()
58
+ endTime: null,
59
+ // eslint-disable-next-line unicorn/no-null -- Zod schema uses .nullable()
60
+ durationSeconds: null,
61
+ attributionType: "self",
62
+ // eslint-disable-next-line unicorn/no-null -- Zod schema uses .nullable()
63
+ upstreamSystemId: null,
64
+ // eslint-disable-next-line unicorn/no-null -- Zod schema uses .nullable()
65
+ upstreamSystemName: null,
66
+ ...overrides,
67
+ };
68
+ }
69
+
70
+ function createMockService(
71
+ options: {
72
+ objectives?: SloObjective[];
73
+ openEvents?: SloDowntimeEvent[];
74
+ openSelfEvents?: SloDowntimeEvent[];
75
+ openUpstreamEvents?: SloDowntimeEvent[];
76
+ } = {},
77
+ ) {
78
+ const {
79
+ objectives = [],
80
+ openEvents = [],
81
+ openSelfEvents = [],
82
+ openUpstreamEvents = [],
83
+ } = options;
84
+
85
+ return {
86
+ getObjectivesForSystem: mock(() => Promise.resolve(objectives)),
87
+ getObjective: mock(({ id }: { id: string }) =>
88
+ Promise.resolve(objectives.find((o) => o.id === id)),
89
+ ),
90
+ getOpenDowntimeEventsForObjective: mock(() =>
91
+ Promise.resolve(openEvents),
92
+ ),
93
+ getOpenDowntimeEvents: mock(() => Promise.resolve(openEvents)),
94
+ getOpenSelfEvents: mock(() => Promise.resolve(openSelfEvents)),
95
+ getOpenUpstreamEvents: mock(() => Promise.resolve(openUpstreamEvents)),
96
+ openDowntimeEvent: mock(() =>
97
+ Promise.resolve(createDowntimeEvent()),
98
+ ),
99
+ closeDowntimeEvent: mock(() =>
100
+ Promise.resolve(createDowntimeEvent({ endTime: new Date(), durationSeconds: 60 })),
101
+ ),
102
+ getDowntimeForWindow: mock(() =>
103
+ Promise.resolve({
104
+ totalMinutes: 0,
105
+ selfMinutes: 0,
106
+ upstreamMinutes: 0,
107
+ entries: [],
108
+ }),
109
+ ),
110
+ listObjectives: mock(() => Promise.resolve(objectives)),
111
+ getStreak: mock(() => Promise.resolve(undefined)),
112
+ insertDailySnapshot: mock(() => Promise.resolve()),
113
+ incrementStreak: mock(() => Promise.resolve()),
114
+ resetStreak: mock(() => Promise.resolve()),
115
+ getRecentDowntimeEvents: mock(() => Promise.resolve([])),
116
+ unlockAchievement: mock(() => Promise.resolve(undefined)),
117
+ hasAchievement: mock(() => Promise.resolve(false)),
118
+ getAchievements: mock(() => Promise.resolve([])),
119
+ } as unknown as SloService;
120
+ }
121
+
122
+ // =============================================================================
123
+ // TESTS
124
+ // =============================================================================
125
+
126
+ describe("SloEngine", () => {
127
+ let engine: SloEngine;
128
+ let mockService: SloService;
129
+ let mockSignalService: ReturnType<typeof createMockSignalService>;
130
+ let mockLogger: ReturnType<typeof createMockLogger>;
131
+
132
+ const alwaysHealthy = async () => ({
133
+ isHealthy: true,
134
+ systemName: "upstream",
135
+ });
136
+ const alwaysUnhealthy = async () => ({
137
+ isHealthy: false,
138
+ systemName: "upstream",
139
+ });
140
+ // Suppress unused variable lint — kept for future tests
141
+ void alwaysUnhealthy;
142
+
143
+ describe("handleSystemDown", () => {
144
+ it("should open a downtime event for each objective on the system", async () => {
145
+ const objective = createObjective();
146
+ mockService = createMockService({ objectives: [objective] });
147
+ mockSignalService = createMockSignalService();
148
+ mockLogger = createMockLogger();
149
+
150
+ engine = new SloEngine({
151
+ service: mockService,
152
+ signalService: mockSignalService as never,
153
+ logger: mockLogger as never,
154
+ });
155
+
156
+ await engine.handleSystemDown({
157
+ systemId: "sys-1",
158
+ getUpstreamHealthStatus: alwaysHealthy,
159
+ });
160
+
161
+ expect(mockService.openDowntimeEvent).toHaveBeenCalledTimes(1);
162
+ expect(mockService.openDowntimeEvent).toHaveBeenCalledWith(
163
+ expect.objectContaining({
164
+ objectiveId: "obj-1",
165
+ systemId: "sys-1",
166
+ attributionType: "self",
167
+ }),
168
+ );
169
+ });
170
+
171
+ it("should skip opening if there is already an open event (idempotent)", async () => {
172
+ const objective = createObjective();
173
+ const existingEvent = createDowntimeEvent();
174
+ mockService = createMockService({
175
+ objectives: [objective],
176
+ openEvents: [existingEvent],
177
+ });
178
+ mockSignalService = createMockSignalService();
179
+ mockLogger = createMockLogger();
180
+
181
+ engine = new SloEngine({
182
+ service: mockService,
183
+ signalService: mockSignalService as never,
184
+ logger: mockLogger as never,
185
+ });
186
+
187
+ await engine.handleSystemDown({
188
+ systemId: "sys-1",
189
+ getUpstreamHealthStatus: alwaysHealthy,
190
+ });
191
+
192
+ expect(mockService.openDowntimeEvent).not.toHaveBeenCalled();
193
+ });
194
+
195
+ it("should open multiple events for multiple objectives", async () => {
196
+ const obj1 = createObjective({ id: "obj-1" });
197
+ const obj2 = createObjective({ id: "obj-2" });
198
+ mockService = createMockService({ objectives: [obj1, obj2] });
199
+ mockSignalService = createMockSignalService();
200
+ mockLogger = createMockLogger();
201
+
202
+ engine = new SloEngine({
203
+ service: mockService,
204
+ signalService: mockSignalService as never,
205
+ logger: mockLogger as never,
206
+ });
207
+
208
+ await engine.handleSystemDown({
209
+ systemId: "sys-1",
210
+ getUpstreamHealthStatus: alwaysHealthy,
211
+ });
212
+
213
+ expect(mockService.openDowntimeEvent).toHaveBeenCalledTimes(2);
214
+ });
215
+
216
+ it("should always attribute as 'self' in strict mode", async () => {
217
+ const objective = createObjective({ dependencyExclusion: "strict" });
218
+ mockService = createMockService({ objectives: [objective] });
219
+ mockSignalService = createMockSignalService();
220
+ mockLogger = createMockLogger();
221
+
222
+ engine = new SloEngine({
223
+ service: mockService,
224
+ signalService: mockSignalService as never,
225
+ logger: mockLogger as never,
226
+ });
227
+
228
+ await engine.handleSystemDown({
229
+ systemId: "sys-1",
230
+ getUpstreamHealthStatus: alwaysUnhealthy,
231
+ });
232
+
233
+ expect(mockService.openDowntimeEvent).toHaveBeenCalledWith(
234
+ expect.objectContaining({
235
+ attributionType: "self",
236
+ }),
237
+ );
238
+ });
239
+ });
240
+
241
+ describe("handleSystemUp", () => {
242
+ it("should close all open downtime events for the system", async () => {
243
+ const evt1 = createDowntimeEvent({ id: "evt-1", objectiveId: "obj-1" });
244
+ const evt2 = createDowntimeEvent({ id: "evt-2", objectiveId: "obj-1" });
245
+ const objective = createObjective();
246
+ mockService = createMockService({
247
+ objectives: [objective],
248
+ openEvents: [evt1, evt2],
249
+ });
250
+ mockSignalService = createMockSignalService();
251
+ mockLogger = createMockLogger();
252
+
253
+ engine = new SloEngine({
254
+ service: mockService,
255
+ signalService: mockSignalService as never,
256
+ logger: mockLogger as never,
257
+ });
258
+
259
+ await engine.handleSystemUp({ systemId: "sys-1" });
260
+
261
+ expect(mockService.closeDowntimeEvent).toHaveBeenCalledTimes(2);
262
+ });
263
+
264
+ it("should broadcast SLO_STATUS_CHANGED for affected objectives", async () => {
265
+ const evt = createDowntimeEvent({ objectiveId: "obj-1" });
266
+ const objective = createObjective();
267
+ mockService = createMockService({
268
+ objectives: [objective],
269
+ openEvents: [evt],
270
+ });
271
+ mockSignalService = createMockSignalService();
272
+ mockLogger = createMockLogger();
273
+
274
+ engine = new SloEngine({
275
+ service: mockService,
276
+ signalService: mockSignalService as never,
277
+ logger: mockLogger as never,
278
+ });
279
+
280
+ await engine.handleSystemUp({ systemId: "sys-1" });
281
+
282
+ expect(mockSignalService.broadcast).toHaveBeenCalledWith(
283
+ expect.objectContaining({ id: "slo.status.changed" }),
284
+ expect.objectContaining({
285
+ systemId: "sys-1",
286
+ objectiveId: "obj-1",
287
+ }),
288
+ );
289
+ });
290
+
291
+ it("should do nothing if no open events exist", async () => {
292
+ mockService = createMockService();
293
+ mockSignalService = createMockSignalService();
294
+ mockLogger = createMockLogger();
295
+
296
+ engine = new SloEngine({
297
+ service: mockService,
298
+ signalService: mockSignalService as never,
299
+ logger: mockLogger as never,
300
+ });
301
+
302
+ await engine.handleSystemUp({ systemId: "sys-1" });
303
+
304
+ expect(mockService.closeDowntimeEvent).not.toHaveBeenCalled();
305
+ expect(mockSignalService.broadcast).not.toHaveBeenCalled();
306
+ });
307
+ });
308
+
309
+ describe("handleUpstreamDown", () => {
310
+ it("should split open self events into upstream events", async () => {
311
+ const selfEvent = createDowntimeEvent({
312
+ id: "evt-self",
313
+ objectiveId: "obj-1",
314
+ attributionType: "self",
315
+ });
316
+ const objective = createObjective({ dependencyExclusion: "self-only" });
317
+ mockService = createMockService({
318
+ objectives: [objective],
319
+ openSelfEvents: [selfEvent],
320
+ });
321
+ mockSignalService = createMockSignalService();
322
+ mockLogger = createMockLogger();
323
+
324
+ engine = new SloEngine({
325
+ service: mockService,
326
+ signalService: mockSignalService as never,
327
+ logger: mockLogger as never,
328
+ });
329
+
330
+ await engine.handleUpstreamDown({
331
+ upstreamSystemId: "upstream-1",
332
+ upstreamSystemName: "Upstream Service",
333
+ downstreamSystemIds: ["sys-1"],
334
+ });
335
+
336
+ // Should close the self event
337
+ expect(mockService.closeDowntimeEvent).toHaveBeenCalledWith({
338
+ id: "evt-self",
339
+ });
340
+
341
+ // Should open a new upstream event
342
+ expect(mockService.openDowntimeEvent).toHaveBeenCalledWith(
343
+ expect.objectContaining({
344
+ attributionType: "upstream",
345
+ upstreamSystemId: "upstream-1",
346
+ upstreamSystemName: "Upstream Service",
347
+ }),
348
+ );
349
+ });
350
+
351
+ it("should skip splitting for strict mode objectives", async () => {
352
+ const selfEvent = createDowntimeEvent({
353
+ id: "evt-self",
354
+ attributionType: "self",
355
+ });
356
+ const objective = createObjective({ dependencyExclusion: "strict" });
357
+ mockService = createMockService({
358
+ objectives: [objective],
359
+ openSelfEvents: [selfEvent],
360
+ });
361
+ mockSignalService = createMockSignalService();
362
+ mockLogger = createMockLogger();
363
+
364
+ engine = new SloEngine({
365
+ service: mockService,
366
+ signalService: mockSignalService as never,
367
+ logger: mockLogger as never,
368
+ });
369
+
370
+ await engine.handleUpstreamDown({
371
+ upstreamSystemId: "upstream-1",
372
+ upstreamSystemName: "Upstream",
373
+ downstreamSystemIds: ["sys-1"],
374
+ });
375
+
376
+ expect(mockService.closeDowntimeEvent).not.toHaveBeenCalled();
377
+ expect(mockService.openDowntimeEvent).not.toHaveBeenCalled();
378
+ });
379
+
380
+ it("should skip splitting if upstream is in excluded dependencies", async () => {
381
+ const selfEvent = createDowntimeEvent({
382
+ id: "evt-self",
383
+ attributionType: "self",
384
+ });
385
+ const objective = createObjective({
386
+ dependencyExclusion: "self-only",
387
+ excludedDependencyIds: ["upstream-excluded"],
388
+ });
389
+ mockService = createMockService({
390
+ objectives: [objective],
391
+ openSelfEvents: [selfEvent],
392
+ });
393
+ mockSignalService = createMockSignalService();
394
+ mockLogger = createMockLogger();
395
+
396
+ engine = new SloEngine({
397
+ service: mockService,
398
+ signalService: mockSignalService as never,
399
+ logger: mockLogger as never,
400
+ });
401
+
402
+ await engine.handleUpstreamDown({
403
+ upstreamSystemId: "upstream-excluded",
404
+ upstreamSystemName: "Excluded Upstream",
405
+ downstreamSystemIds: ["sys-1"],
406
+ });
407
+
408
+ expect(mockService.closeDowntimeEvent).not.toHaveBeenCalled();
409
+ });
410
+ });
411
+
412
+ describe("handleUpstreamUp", () => {
413
+ it("should close upstream events and open new self events when downstream still down", async () => {
414
+ const upstreamEvent = createDowntimeEvent({
415
+ id: "evt-upstream",
416
+ objectiveId: "obj-1",
417
+ attributionType: "upstream",
418
+ upstreamSystemId: "upstream-1",
419
+ });
420
+ const objective = createObjective();
421
+ mockService = createMockService({
422
+ objectives: [objective],
423
+ openUpstreamEvents: [upstreamEvent],
424
+ });
425
+ mockSignalService = createMockSignalService();
426
+ mockLogger = createMockLogger();
427
+
428
+ // After closing the upstream event, getOpenDowntimeEventsForObjective
429
+ // should return [] — meaning no other open events remain, so the
430
+ // downstream must still be down and needs re-attribution
431
+ (mockService.getOpenDowntimeEventsForObjective as ReturnType<typeof mock>)
432
+ .mockResolvedValue([]);
433
+
434
+ engine = new SloEngine({
435
+ service: mockService,
436
+ signalService: mockSignalService as never,
437
+ logger: mockLogger as never,
438
+ });
439
+
440
+ await engine.handleUpstreamUp({
441
+ upstreamSystemId: "upstream-1",
442
+ downstreamSystemIds: ["sys-1"],
443
+ getUpstreamHealthStatus: alwaysHealthy,
444
+ });
445
+
446
+ // Should close the upstream event
447
+ expect(mockService.closeDowntimeEvent).toHaveBeenCalledWith({
448
+ id: "evt-upstream",
449
+ });
450
+
451
+ // Should open a new event (re-attributed)
452
+ expect(mockService.openDowntimeEvent).toHaveBeenCalled();
453
+ });
454
+ });
455
+
456
+ describe("computeStatus", () => {
457
+ it("should calculate correct availability for zero downtime", async () => {
458
+ const objective = createObjective({ target: 99.9, windowDays: 30 });
459
+ mockService = createMockService({ objectives: [objective] });
460
+ mockSignalService = createMockSignalService();
461
+ mockLogger = createMockLogger();
462
+
463
+ engine = new SloEngine({
464
+ service: mockService,
465
+ signalService: mockSignalService as never,
466
+ logger: mockLogger as never,
467
+ });
468
+
469
+ const status = await engine.computeStatus({ objective });
470
+
471
+ expect(status.errorBudgetConsumedMinutes).toBe(0);
472
+ expect(status.errorBudgetRemainingPercent).toBe(100);
473
+ expect(status.isBreaching).toBe(false);
474
+ });
475
+
476
+ it("should count only selfMinutes for self-only mode", async () => {
477
+ const objective = createObjective({
478
+ target: 99.9,
479
+ windowDays: 30,
480
+ dependencyExclusion: "self-only",
481
+ });
482
+ mockService = createMockService({ objectives: [objective] });
483
+
484
+ // Mock downtime: 10 min self + 20 min upstream
485
+ (mockService.getDowntimeForWindow as ReturnType<typeof mock>).mockResolvedValue({
486
+ totalMinutes: 30,
487
+ selfMinutes: 10,
488
+ upstreamMinutes: 20,
489
+ entries: [],
490
+ });
491
+
492
+ mockSignalService = createMockSignalService();
493
+ mockLogger = createMockLogger();
494
+
495
+ engine = new SloEngine({
496
+ service: mockService,
497
+ signalService: mockSignalService as never,
498
+ logger: mockLogger as never,
499
+ });
500
+
501
+ const status = await engine.computeStatus({ objective });
502
+
503
+ // Should only count 10 self minutes
504
+ expect(status.errorBudgetConsumedMinutes).toBe(10);
505
+ // Strict should count all 30
506
+ expect(status.errorBudgetConsumedStrictMinutes).toBe(30);
507
+ });
508
+
509
+ it("should count totalMinutes for strict mode", async () => {
510
+ const objective = createObjective({
511
+ target: 99.9,
512
+ windowDays: 30,
513
+ dependencyExclusion: "strict",
514
+ });
515
+ mockService = createMockService({ objectives: [objective] });
516
+
517
+ (mockService.getDowntimeForWindow as ReturnType<typeof mock>).mockResolvedValue({
518
+ totalMinutes: 30,
519
+ selfMinutes: 10,
520
+ upstreamMinutes: 20,
521
+ entries: [],
522
+ });
523
+
524
+ mockSignalService = createMockSignalService();
525
+ mockLogger = createMockLogger();
526
+
527
+ engine = new SloEngine({
528
+ service: mockService,
529
+ signalService: mockSignalService as never,
530
+ logger: mockLogger as never,
531
+ });
532
+
533
+ const status = await engine.computeStatus({ objective });
534
+
535
+ // Strict counts all downtime
536
+ expect(status.errorBudgetConsumedMinutes).toBe(30);
537
+ });
538
+
539
+ it("should flag as breaching when availability is below target", async () => {
540
+ const objective = createObjective({
541
+ target: 99.9,
542
+ windowDays: 30,
543
+ });
544
+ mockService = createMockService({ objectives: [objective] });
545
+
546
+ // 99.9% of 30 days = 43,200 minutes → allowed downtime = 43.2 min
547
+ // Set consumed = 50 min → breaching
548
+ (mockService.getDowntimeForWindow as ReturnType<typeof mock>).mockResolvedValue({
549
+ totalMinutes: 50,
550
+ selfMinutes: 50,
551
+ upstreamMinutes: 0,
552
+ entries: [],
553
+ });
554
+
555
+ mockSignalService = createMockSignalService();
556
+ mockLogger = createMockLogger();
557
+
558
+ engine = new SloEngine({
559
+ service: mockService,
560
+ signalService: mockSignalService as never,
561
+ logger: mockLogger as never,
562
+ });
563
+
564
+ const status = await engine.computeStatus({ objective });
565
+
566
+ expect(status.isBreaching).toBe(true);
567
+ });
568
+ });
569
+
570
+ describe("reconcileObjective", () => {
571
+ it("should open a downtime event when system is already unhealthy", async () => {
572
+ const objective = createObjective();
573
+ mockService = createMockService({ objectives: [objective] });
574
+ mockSignalService = createMockSignalService();
575
+ mockLogger = createMockLogger();
576
+
577
+ engine = new SloEngine({
578
+ service: mockService,
579
+ signalService: mockSignalService as never,
580
+ logger: mockLogger as never,
581
+ });
582
+
583
+ engine.setHealthStatusCallback(async () => ({
584
+ isHealthy: false,
585
+ }));
586
+
587
+ await engine.reconcileObjective({ objective });
588
+
589
+ expect(mockService.openDowntimeEvent).toHaveBeenCalledWith(
590
+ expect.objectContaining({
591
+ objectiveId: "obj-1",
592
+ systemId: "sys-1",
593
+ attributionType: "self",
594
+ }),
595
+ );
596
+ });
597
+
598
+ it("should skip when system is healthy", async () => {
599
+ const objective = createObjective();
600
+ mockService = createMockService({ objectives: [objective] });
601
+ mockSignalService = createMockSignalService();
602
+ mockLogger = createMockLogger();
603
+
604
+ engine = new SloEngine({
605
+ service: mockService,
606
+ signalService: mockSignalService as never,
607
+ logger: mockLogger as never,
608
+ });
609
+
610
+ engine.setHealthStatusCallback(async () => ({
611
+ isHealthy: true,
612
+ }));
613
+
614
+ await engine.reconcileObjective({ objective });
615
+
616
+ expect(mockService.openDowntimeEvent).not.toHaveBeenCalled();
617
+ });
618
+
619
+ it("should skip gracefully when no callback is set", async () => {
620
+ const objective = createObjective();
621
+ mockService = createMockService({ objectives: [objective] });
622
+ mockSignalService = createMockSignalService();
623
+ mockLogger = createMockLogger();
624
+
625
+ engine = new SloEngine({
626
+ service: mockService,
627
+ signalService: mockSignalService as never,
628
+ logger: mockLogger as never,
629
+ });
630
+
631
+ // No setHealthStatusCallback call — should not throw
632
+ await engine.reconcileObjective({ objective });
633
+
634
+ expect(mockService.openDowntimeEvent).not.toHaveBeenCalled();
635
+ });
636
+
637
+ it("should skip when open events already exist (idempotent)", async () => {
638
+ const objective = createObjective();
639
+ const existingEvent = createDowntimeEvent();
640
+ mockService = createMockService({
641
+ objectives: [objective],
642
+ openEvents: [existingEvent],
643
+ });
644
+ mockSignalService = createMockSignalService();
645
+ mockLogger = createMockLogger();
646
+
647
+ engine = new SloEngine({
648
+ service: mockService,
649
+ signalService: mockSignalService as never,
650
+ logger: mockLogger as never,
651
+ });
652
+
653
+ engine.setHealthStatusCallback(async () => ({
654
+ isHealthy: false,
655
+ }));
656
+
657
+ await engine.reconcileObjective({ objective });
658
+
659
+ expect(mockService.openDowntimeEvent).not.toHaveBeenCalled();
660
+ });
661
+ });
662
+ });