@checkstack/healthcheck-backend 1.11.1 → 1.12.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,205 @@
1
+ import { describe, it, expect, mock } from "bun:test";
2
+ import { HealthCheckService } from "./service";
3
+ import { evaluateHealthStatus } from "./state-evaluator";
4
+
5
+ /**
6
+ * Regression coverage for the system-rollup worst-wins-across-environments
7
+ * fix in `getSystemHealthStatus(systemId)` (the `environmentId === undefined`
8
+ * branch).
9
+ *
10
+ * The original branch flattened every environment's runs into one
11
+ * `timestamp DESC` list and handed the interleaved list to the threshold
12
+ * evaluator (the default `consecutive` mode). Consecutive mode walks
13
+ * newest-first and breaks the streak on the first interleaving env, so the
14
+ * rollup collapsed to whichever env ran last in the batch — masking any
15
+ * permanently-failing sibling env ("the healthy env wins" / latest-wins)
16
+ * and flapping whenever env insertion order drifted across ticks.
17
+ *
18
+ * The fix evaluates the threshold window PER ENVIRONMENT within the
19
+ * association and takes worst-wins across envs (unhealthy > degraded >
20
+ * healthy), making the rollup stable regardless of insertion order or
21
+ * multi-pod racing. These tests pin that behavior with a mocked DB that
22
+ * returns the interleaved mixed-pool the real query would surface.
23
+ */
24
+ describe("HealthCheckService - system rollup worst-wins across environments", () => {
25
+ /**
26
+ * The mixed-pool query captured by the mock. Ordered DESC (newest first),
27
+ * exactly the shape the real `health_check_runs` query returns. Two envs
28
+ * (`prod`, `staging`) of one assignment, both fanning out every tick, prod
29
+ * permanently unhealthy and staging permanently healthy. The env insertion
30
+ * order in the executor is sequential membership order, so prod lands before
31
+ * staging, making the latest run in the pool a staging-healthy run — the
32
+ * exact scenario that masked prod's outage under flattening.
33
+ */
34
+ const PROD_RUN = { status: "unhealthy" as const, environmentId: "prod" };
35
+ const STAGE_RUN = { status: "healthy" as const, environmentId: "staging" };
36
+
37
+ function buildMixedPool(ticksPerEnv = 5): { status: "unhealthy" | "healthy"; timestamp: Date; environmentId: string }[] {
38
+ const pool: { status: "unhealthy" | "healthy"; timestamp: Date; environmentId: string }[] = [];
39
+ for (let i = 0; i < ticksPerEnv; i++) {
40
+ pool.push({ ...PROD_RUN, timestamp: new Date(2025, 0, 1, 0, 0, i) });
41
+ pool.push({ ...STAGE_RUN, timestamp: new Date(2025, 0, 1, 0, 0, i + 0.5) });
42
+ }
43
+ return pool; // DESC at the DB layer; we return newest-first below.
44
+ }
45
+
46
+ function createMockDb(runsMixedDesc: { status: string; timestamp: Date; environmentId: string }[]) {
47
+ const assocWhere = mock(() => Promise.resolve([
48
+ {
49
+ configurationId: "config-1",
50
+ configName: "HTTP probe",
51
+ enabled: true,
52
+ paused: false,
53
+ stateThresholds: null,
54
+ },
55
+ ]));
56
+ const assocInnerJoin = Object.assign(Promise.resolve([]), { where: assocWhere });
57
+ const assocFrom = Object.assign(Promise.resolve([]), { innerJoin: mock(() => assocInnerJoin) });
58
+
59
+ const runsLimit = mock(() => Promise.resolve(runsMixedDesc));
60
+ const runsOrderBy = mock(() => ({ limit: runsLimit }));
61
+ const runsWhere = mock(() => ({ orderBy: runsOrderBy, limit: runsLimit }));
62
+ const runsFrom = Object.assign(Promise.resolve(runsMixedDesc), {
63
+ where: runsWhere,
64
+ orderBy: runsOrderBy,
65
+ });
66
+
67
+ let selectCallCount = 0;
68
+ return {
69
+ select: mock(() => {
70
+ selectCallCount += 1;
71
+ if (selectCallCount === 1) return { from: mock(() => assocFrom) };
72
+ return { from: mock(() => runsFrom) };
73
+ }),
74
+ insert: mock(() => ({
75
+ values: mock(() => ({
76
+ onConflictDoUpdate: mock(() => Promise.resolve()),
77
+ onConflictDoNothing: mock(() => Promise.resolve()),
78
+ returning: mock(() => Promise.resolve([])),
79
+ })),
80
+ })),
81
+ update: mock(() => ({ set: mock(() => ({ where: mock(() => Promise.resolve()) })) })),
82
+ delete: mock(() => ({ where: mock(() => Promise.resolve()) })),
83
+ execute: mock(() => Promise.resolve()),
84
+ };
85
+ }
86
+
87
+ it("the rollup reports unhealthy when ONE env is permanently unhealthy, the other healthy", async () => {
88
+ // DB returns newest-first interleaved runs. The pre-fix behavior would
89
+ // mask prod's outage because the latest run is staging-healthy; the
90
+ // threshold evaluator (default consecutive mode) walks newest-first from
91
+ // staging-healthy, breaks the streak on the very next prod-unhealthy run,
92
+ // and falls back to `"healthy"`.
93
+ const pool = buildMixedPool(5);
94
+ const runsDesc = pool.toReversed(); // oldest produced first above; reverse to DESC
95
+ const mockDb = createMockDb(runsDesc as never);
96
+ const service = new HealthCheckService(mockDb as never, {} as never, {} as never);
97
+
98
+ const result = await service.getSystemHealthStatus("system-1");
99
+
100
+ expect(result.status).toBe("unhealthy");
101
+ expect(result.checkStatuses).toHaveLength(1);
102
+ expect(result.checkStatuses[0].status).toBe("unhealthy");
103
+ expect(result.checkStatuses[0].runsConsidered).toBe(pool.length);
104
+ });
105
+
106
+ it("flattening the same mixed pool through the evaluator (the pre-fix derivation) would have returned `healthy`", async () => {
107
+ // Sanity check: the very data the rollup branch reads, fed directly to
108
+ // `evaluateHealthStatus` as one flat interleaved list, collapses to
109
+ // "healthy" — the precise regression this fix replaces with per-env
110
+ // evaluation. Pinning it here guards against a relax of the test above.
111
+ const pool = buildMixedPool(5);
112
+ const runsDesc = pool.toReversed();
113
+ const flatStatus = evaluateHealthStatus({
114
+ runs: runsDesc as never,
115
+ });
116
+ expect(flatStatus).toBe("healthy");
117
+ });
118
+
119
+ it("reports healthy only when EVERY env is healthy", async () => {
120
+ const allHealthy = Array.from({ length: 10 }, (_, i) => ({
121
+ status: "healthy",
122
+ timestamp: new Date(2025, 0, 1, 0, 0, i),
123
+ environmentId: i % 2 === 0 ? "prod" : "staging",
124
+ })).toReversed();
125
+ const mockDb = createMockDb(allHealthy as never);
126
+ const service = new HealthCheckService(mockDb as never, {} as never, {} as never);
127
+
128
+ const result = await service.getSystemHealthStatus("system-1");
129
+ expect(result.status).toBe("healthy");
130
+ });
131
+
132
+ it("degrades (not flaps) when one env is degraded and the other healthy", async () => {
133
+ // Default consecutive thresholds need 2 consecutive failures to escalate
134
+ // to `degraded` (and 5 to escalate to `unhealthy` — so keep prod's streak
135
+ // at exactly 2 degraded runs). Per-env: prod's env-sorted slice =
136
+ // [degraded, degraded] (newest first) → degraded; staging → healthy.
137
+ // Rollup worst-wins = degraded. Flattening would break on the staging
138
+ // interleave and return `healthy` (the masked bug); per-env gives a
139
+ // stable `degraded`.
140
+ const pool: { status: "healthy" | "degraded"; timestamp: Date; environmentId: string }[] = [];
141
+ for (let i = 0; i < 2; i++) {
142
+ pool.push({ status: "degraded", timestamp: new Date(2025, 0, 1, 0, 0, i), environmentId: "prod" });
143
+ pool.push({ status: "healthy", timestamp: new Date(2025, 0, 1, 0, 0, i + 0.5), environmentId: "staging" });
144
+ }
145
+ const runsDesc = pool.toReversed();
146
+ const mockDb = createMockDb(runsDesc as never);
147
+ const service = new HealthCheckService(mockDb as never, {} as never, {} as never);
148
+
149
+ const result = await service.getSystemHealthStatus("system-1");
150
+ expect(result.status).toBe("degraded");
151
+ });
152
+
153
+ it("the per-env slice (concrete environmentId) is unaffected — only the rollup branch changed", async () => {
154
+ // Pass an explicit environmentId; the old per-env branch (string envId)
155
+ // must continue to filter to that env's slice. Here we ask for `prod`
156
+ // and expect unhealthy.
157
+ const prodOnly = Array.from({ length: 5 }, (_, i) => ({
158
+ status: "unhealthy",
159
+ timestamp: new Date(2025, 0, 1, 0, 0, i),
160
+ }));
161
+ // Mock: the runs query mirrors the predicate back to prodOnly.
162
+ const assocWhere = mock(() => Promise.resolve([
163
+ {
164
+ configurationId: "config-1",
165
+ configName: "HTTP probe",
166
+ enabled: true,
167
+ paused: false,
168
+ stateThresholds: null,
169
+ },
170
+ ]));
171
+ const assocInnerJoin = Object.assign(Promise.resolve([]), { where: assocWhere });
172
+ const assocFrom = Object.assign(Promise.resolve([]), { innerJoin: mock(() => assocInnerJoin) });
173
+
174
+ const runsLimit = mock(() => Promise.resolve(prodOnly));
175
+ const runsOrderBy = mock(() => ({ limit: runsLimit }));
176
+ const runsWhere = mock(() => ({ orderBy: runsOrderBy, limit: runsLimit }));
177
+ const runsFrom = Object.assign(Promise.resolve(prodOnly), {
178
+ where: runsWhere,
179
+ orderBy: runsOrderBy,
180
+ });
181
+
182
+ let selectCallCount = 0;
183
+ const mockDb = {
184
+ select: mock(() => {
185
+ selectCallCount += 1;
186
+ if (selectCallCount === 1) return { from: mock(() => assocFrom) };
187
+ return { from: mock(() => runsFrom) };
188
+ }),
189
+ insert: mock(() => ({
190
+ values: mock(() => ({
191
+ onConflictDoUpdate: mock(() => Promise.resolve()),
192
+ onConflictDoNothing: mock(() => Promise.resolve()),
193
+ returning: mock(() => Promise.resolve([])),
194
+ })),
195
+ })),
196
+ update: mock(() => ({ set: mock(() => ({ where: mock(() => Promise.resolve()) })) })),
197
+ delete: mock(() => ({ where: mock(() => Promise.resolve()) })),
198
+ execute: mock(() => Promise.resolve()),
199
+ };
200
+
201
+ const service = new HealthCheckService(mockDb as never, {} as never, {} as never);
202
+ const result = await service.getSystemHealthStatus("system-1", "prod");
203
+ expect(result.status).toBe("unhealthy");
204
+ });
205
+ });