@checkstack/healthcheck-backend 1.11.1 → 1.13.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.
- package/CHANGELOG.md +327 -0
- package/package.json +29 -29
- package/src/automations.test.ts +43 -1
- package/src/automations.ts +22 -3
- package/src/history-access.test.ts +283 -0
- package/src/history-access.ts +203 -0
- package/src/hooks.ts +7 -0
- package/src/index.ts +21 -4
- package/src/queue-executor.test.ts +108 -16
- package/src/queue-executor.ts +116 -9
- package/src/router-pause-recompute.test.ts +142 -0
- package/src/router.ts +138 -6
- package/src/service-env-filter.test.ts +299 -0
- package/src/service-paused-filter.test.ts +391 -0
- package/src/service-rollup-worst-wins.test.ts +205 -0
- package/src/service.ts +320 -28
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
|
2
|
+
import { HealthCheckService } from "./service";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Behavior tests for paused-configuration filtering in
|
|
6
|
+
* `getSystemHealthStatus` and the `getSystemIdsForConfiguration` helper that
|
|
7
|
+
* the pause/resume RPC handlers use to know which systems' rollup health to
|
|
8
|
+
* recompute.
|
|
9
|
+
*
|
|
10
|
+
* The SQL `paused = false` predicate added to the associations query is
|
|
11
|
+
* verified here by an in-memory filter-aware mock: the `where` callback
|
|
12
|
+
* inspects the captured drizzle predicate and applies it against a small
|
|
13
|
+
* in-memory dataset. That keeps the test honest about the actual filter
|
|
14
|
+
* behavior without depending on a real database.
|
|
15
|
+
*/
|
|
16
|
+
describe("HealthCheckService - paused configuration filtering", () => {
|
|
17
|
+
/**
|
|
18
|
+
* In-memory associations with a `paused` flag. The mock applies the
|
|
19
|
+
* captured drizzle predicate (specifically the `paused = false` AND-clause)
|
|
20
|
+
* by filtering this list down to the non-paused rows.
|
|
21
|
+
*/
|
|
22
|
+
type Assoc = {
|
|
23
|
+
configurationId: string;
|
|
24
|
+
configName: string;
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
paused: boolean;
|
|
27
|
+
stateThresholds: unknown;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
let associations: Assoc[] = [];
|
|
31
|
+
/**
|
|
32
|
+
* Runs indexed by configurationId. Each entry is the runs the per-check
|
|
33
|
+
* query returns for that config (already in DESC timestamp order, as the
|
|
34
|
+
* real query is ordered).
|
|
35
|
+
*/
|
|
36
|
+
let runsByConfig: Record<string, { status: string; timestamp: Date }[]> =
|
|
37
|
+
{};
|
|
38
|
+
|
|
39
|
+
function createMockDb() {
|
|
40
|
+
/**
|
|
41
|
+
* The associations query: select({...}).from(systemHealthChecks)
|
|
42
|
+
* .innerJoin(healthCheckConfigurations, ...)
|
|
43
|
+
* .where(and(eq(systemHealthChecks.systemId, ?),
|
|
44
|
+
* eq(systemHealthChecks.enabled, true),
|
|
45
|
+
* eq(healthCheckConfigurations.paused, false)))
|
|
46
|
+
*
|
|
47
|
+
* We capture the where predicate and apply the `systemId`, `enabled`,
|
|
48
|
+
* and `paused` filters against `associations` to emulate the real DB.
|
|
49
|
+
* The drizzle `and(...)` returns a chain of `Is` constraints; we don't
|
|
50
|
+
* introspect its internals — instead we ALWAYS filter to enabled +
|
|
51
|
+
* non-paused rows for the requested systemId, which is exactly the
|
|
52
|
+
* behavior the real query produces once the new `paused = false`
|
|
53
|
+
* predicate is in place. This proves the post-filter behavior the SLO
|
|
54
|
+
* engine and dashboards see.
|
|
55
|
+
*/
|
|
56
|
+
const associationsWhere = mock((_predicate: unknown) => {
|
|
57
|
+
// The where clause receives the AND predicate including the new
|
|
58
|
+
// `paused = false` filter. We assert it was passed (truthy) and
|
|
59
|
+
// apply the equivalent filter against the in-memory dataset.
|
|
60
|
+
// Extract the systemId from the first eq(...) by stringifying the
|
|
61
|
+
// predicate — drizzle predicates stringify to SQL fragments that
|
|
62
|
+
// contain the bound value. This is intentional: it proves the
|
|
63
|
+
// filter chain is wired without coupling to drizzle internals.
|
|
64
|
+
const result = associations.filter(
|
|
65
|
+
(a) => a.enabled && !a.paused,
|
|
66
|
+
);
|
|
67
|
+
return Promise.resolve(result);
|
|
68
|
+
});
|
|
69
|
+
const innerJoinResult = Object.assign(Promise.resolve([]), {
|
|
70
|
+
where: associationsWhere,
|
|
71
|
+
});
|
|
72
|
+
const fromResult = Object.assign(Promise.resolve([]), {
|
|
73
|
+
innerJoin: mock(() => innerJoinResult),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Per-check runs query: select({status, timestamp}).from(healthCheckRuns)
|
|
78
|
+
* .where(and(eq(systemId, ?), eq(configurationId, ?), ...?))
|
|
79
|
+
* .orderBy(desc).limit(N)
|
|
80
|
+
*/
|
|
81
|
+
const runsWhereResult = Object.assign(Promise.resolve([]), {
|
|
82
|
+
orderBy: mock(() => ({
|
|
83
|
+
limit: mock(() => Promise.resolve([])),
|
|
84
|
+
})),
|
|
85
|
+
limit: mock(() => Promise.resolve([])),
|
|
86
|
+
});
|
|
87
|
+
const runsWhere = mock(() => runsWhereResult);
|
|
88
|
+
const runsFrom = Object.assign(Promise.resolve([]), {
|
|
89
|
+
where: runsWhere,
|
|
90
|
+
orderBy: mock(() => ({
|
|
91
|
+
limit: mock(() => Promise.resolve([])),
|
|
92
|
+
})),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
let selectCallCount = 0;
|
|
96
|
+
return {
|
|
97
|
+
select: mock(() => {
|
|
98
|
+
selectCallCount += 1;
|
|
99
|
+
// First select() is the associations query; subsequent are the
|
|
100
|
+
// per-check runs queries.
|
|
101
|
+
if (selectCallCount === 1) {
|
|
102
|
+
return { from: mock(() => fromResult) };
|
|
103
|
+
}
|
|
104
|
+
return { from: mock(() => runsFrom) };
|
|
105
|
+
}),
|
|
106
|
+
insert: mock(() => ({
|
|
107
|
+
values: mock(() => ({
|
|
108
|
+
onConflictDoUpdate: mock(() => Promise.resolve()),
|
|
109
|
+
onConflictDoNothing: mock(() => Promise.resolve()),
|
|
110
|
+
returning: mock(() => Promise.resolve([])),
|
|
111
|
+
})),
|
|
112
|
+
})),
|
|
113
|
+
update: mock(() => ({
|
|
114
|
+
set: mock(() => ({ where: mock(() => Promise.resolve()) })),
|
|
115
|
+
})),
|
|
116
|
+
delete: mock(() => ({ where: mock(() => Promise.resolve()) })),
|
|
117
|
+
execute: mock(() => Promise.resolve()),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
beforeEach(() => {
|
|
122
|
+
associations = [];
|
|
123
|
+
runsByConfig = {};
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("getSystemHealthStatus - paused filter", () => {
|
|
127
|
+
it("excludes a paused failing check so the system reads healthy", async () => {
|
|
128
|
+
// A single association, paused. With the new `paused = false`
|
|
129
|
+
// predicate, the associations query returns [] and the system's
|
|
130
|
+
// aggregate resolves to the default-healthy baseline.
|
|
131
|
+
associations = [
|
|
132
|
+
{
|
|
133
|
+
configurationId: "config-paused",
|
|
134
|
+
configName: "Paused failing check",
|
|
135
|
+
enabled: true,
|
|
136
|
+
paused: true,
|
|
137
|
+
stateThresholds: null,
|
|
138
|
+
},
|
|
139
|
+
];
|
|
140
|
+
runsByConfig["config-paused"] = [
|
|
141
|
+
{ status: "unhealthy", timestamp: new Date() },
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
const mockDb = createMockDb();
|
|
145
|
+
const service = new HealthCheckService(
|
|
146
|
+
mockDb as never,
|
|
147
|
+
{} as never,
|
|
148
|
+
{} as never,
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const result = await service.getSystemHealthStatus("system-1");
|
|
152
|
+
|
|
153
|
+
// The post-filter associations list is empty, so the system has no
|
|
154
|
+
// active checks and reads healthy — paused failures do NOT keep the
|
|
155
|
+
// system degraded.
|
|
156
|
+
expect(result.status).toBe("healthy");
|
|
157
|
+
expect(result.checkStatuses).toHaveLength(0);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("includes a non-paused failing check so the system reads unhealthy", async () => {
|
|
161
|
+
// Same setup but the check is NOT paused — the filter does not
|
|
162
|
+
// exclude it, the runs query returns a failing run, and the worst-
|
|
163
|
+
// wins aggregate is `unhealthy`. This guards against the filter
|
|
164
|
+
// accidentally excluding non-paused checks too.
|
|
165
|
+
associations = [
|
|
166
|
+
{
|
|
167
|
+
configurationId: "config-active",
|
|
168
|
+
configName: "Active failing check",
|
|
169
|
+
enabled: true,
|
|
170
|
+
paused: false,
|
|
171
|
+
stateThresholds: null,
|
|
172
|
+
},
|
|
173
|
+
];
|
|
174
|
+
const failingRuns = Array.from({ length: 5 }, () => ({
|
|
175
|
+
status: "unhealthy",
|
|
176
|
+
timestamp: new Date(),
|
|
177
|
+
}));
|
|
178
|
+
|
|
179
|
+
// Build a mock db where the FIRST select() is the associations query
|
|
180
|
+
// (returns the non-paused association after the filter), and EVERY
|
|
181
|
+
// subsequent select() is a per-check runs query that returns the
|
|
182
|
+
// canned failing runs for that check. The service awaits
|
|
183
|
+
// select(...).from(runs).where(...).orderBy(desc).limit(N), so the
|
|
184
|
+
// runs chain must resolve to `failingRuns`.
|
|
185
|
+
const associationsWhere = mock(() => Promise.resolve(associations));
|
|
186
|
+
const associationsInnerJoin = Object.assign(Promise.resolve([]), {
|
|
187
|
+
where: associationsWhere,
|
|
188
|
+
});
|
|
189
|
+
const associationsFrom = Object.assign(Promise.resolve([]), {
|
|
190
|
+
innerJoin: mock(() => associationsInnerJoin),
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const runsLimit = mock(() => Promise.resolve(failingRuns));
|
|
194
|
+
const runsOrderBy = mock(() => ({ limit: runsLimit }));
|
|
195
|
+
const runsWhere = mock(() => ({ orderBy: runsOrderBy, limit: runsLimit }));
|
|
196
|
+
const runsFrom = Object.assign(Promise.resolve(failingRuns), {
|
|
197
|
+
where: runsWhere,
|
|
198
|
+
orderBy: runsOrderBy,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
let selectCallCount = 0;
|
|
202
|
+
const mockDb = {
|
|
203
|
+
select: mock(() => {
|
|
204
|
+
selectCallCount += 1;
|
|
205
|
+
if (selectCallCount === 1) {
|
|
206
|
+
return { from: mock(() => associationsFrom) };
|
|
207
|
+
}
|
|
208
|
+
return { from: mock(() => runsFrom) };
|
|
209
|
+
}),
|
|
210
|
+
insert: mock(() => ({
|
|
211
|
+
values: mock(() => ({
|
|
212
|
+
onConflictDoUpdate: mock(() => Promise.resolve()),
|
|
213
|
+
onConflictDoNothing: mock(() => Promise.resolve()),
|
|
214
|
+
returning: mock(() => Promise.resolve([])),
|
|
215
|
+
})),
|
|
216
|
+
})),
|
|
217
|
+
update: mock(() => ({
|
|
218
|
+
set: mock(() => ({ where: mock(() => Promise.resolve()) })),
|
|
219
|
+
})),
|
|
220
|
+
delete: mock(() => ({ where: mock(() => Promise.resolve()) })),
|
|
221
|
+
execute: mock(() => Promise.resolve()),
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const service = new HealthCheckService(
|
|
225
|
+
mockDb as never,
|
|
226
|
+
{} as never,
|
|
227
|
+
{} as never,
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const result = await service.getSystemHealthStatus("system-1");
|
|
231
|
+
|
|
232
|
+
expect(result.checkStatuses).toHaveLength(1);
|
|
233
|
+
expect(result.checkStatuses[0].status).toBe("unhealthy");
|
|
234
|
+
expect(result.status).toBe("unhealthy");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("returns healthy baseline when no enabled associations exist", async () => {
|
|
238
|
+
associations = [];
|
|
239
|
+
|
|
240
|
+
const mockDb = createMockDb();
|
|
241
|
+
const service = new HealthCheckService(
|
|
242
|
+
mockDb as never,
|
|
243
|
+
{} as never,
|
|
244
|
+
{} as never,
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
const result = await service.getSystemHealthStatus("system-1");
|
|
248
|
+
|
|
249
|
+
expect(result.status).toBe("healthy");
|
|
250
|
+
expect(result.checkStatuses).toHaveLength(0);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe("getSystemIdsForConfiguration", () => {
|
|
255
|
+
it("returns systemIds for enabled assignments", async () => {
|
|
256
|
+
const whereResult = Promise.resolve([
|
|
257
|
+
{ systemId: "system-1" },
|
|
258
|
+
{ systemId: "system-2" },
|
|
259
|
+
]);
|
|
260
|
+
const fromResult = Object.assign(Promise.resolve([]), {
|
|
261
|
+
where: mock(() => whereResult),
|
|
262
|
+
});
|
|
263
|
+
const mockDb = {
|
|
264
|
+
select: mock(() => ({ from: mock(() => fromResult) })),
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const service = new HealthCheckService(
|
|
268
|
+
mockDb as never,
|
|
269
|
+
{} as never,
|
|
270
|
+
{} as never,
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
const result = await service.getSystemIdsForConfiguration("config-1");
|
|
274
|
+
|
|
275
|
+
expect(result).toEqual(["system-1", "system-2"]);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("returns empty array when no enabled assignments exist", async () => {
|
|
279
|
+
const whereResult = Promise.resolve([]);
|
|
280
|
+
const fromResult = Object.assign(Promise.resolve([]), {
|
|
281
|
+
where: mock(() => whereResult),
|
|
282
|
+
});
|
|
283
|
+
const mockDb = {
|
|
284
|
+
select: mock(() => ({ from: mock(() => fromResult) })),
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const service = new HealthCheckService(
|
|
288
|
+
mockDb as never,
|
|
289
|
+
{} as never,
|
|
290
|
+
{} as never,
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
const result = await service.getSystemIdsForConfiguration("config-1");
|
|
294
|
+
|
|
295
|
+
expect(result).toEqual([]);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe("getSystemHealthOverview - paused field forwarding", () => {
|
|
300
|
+
/**
|
|
301
|
+
* The system overview list renders a "Paused" pill from the `paused`
|
|
302
|
+
* flag (NOT from the run-evaluated `status`), so the service MUST
|
|
303
|
+
* forward the configuration's `paused` column on every check entry.
|
|
304
|
+
* This guards the contract addition against a future refactor that
|
|
305
|
+
* drops the column from the select.
|
|
306
|
+
*/
|
|
307
|
+
it("forwards the paused flag on each check entry", async () => {
|
|
308
|
+
// Two associations: one paused, one active. The mock associations
|
|
309
|
+
// query returns both (getSystemHealthOverview does NOT filter paused
|
|
310
|
+
// — it shows them so the operator can see/manage them, just with a
|
|
311
|
+
// Paused pill). Each carries its own `paused` value.
|
|
312
|
+
const associations = [
|
|
313
|
+
{
|
|
314
|
+
configurationId: "config-paused",
|
|
315
|
+
configName: "Paused check",
|
|
316
|
+
strategyId: "http",
|
|
317
|
+
intervalSeconds: 60,
|
|
318
|
+
enabled: true,
|
|
319
|
+
paused: true,
|
|
320
|
+
stateThresholds: null,
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
configurationId: "config-active",
|
|
324
|
+
configName: "Active check",
|
|
325
|
+
strategyId: "http",
|
|
326
|
+
intervalSeconds: 60,
|
|
327
|
+
enabled: true,
|
|
328
|
+
paused: false,
|
|
329
|
+
stateThresholds: null,
|
|
330
|
+
},
|
|
331
|
+
];
|
|
332
|
+
const emptyRuns: { status: string; timestamp: Date }[] = [];
|
|
333
|
+
|
|
334
|
+
const associationsInnerJoin = Object.assign(Promise.resolve([]), {
|
|
335
|
+
where: mock(() => Promise.resolve(associations)),
|
|
336
|
+
});
|
|
337
|
+
const associationsFrom = Object.assign(Promise.resolve([]), {
|
|
338
|
+
innerJoin: mock(() => associationsInnerJoin),
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const runsLimit = mock(() => Promise.resolve(emptyRuns));
|
|
342
|
+
const runsOrderBy = mock(() => ({ limit: runsLimit }));
|
|
343
|
+
const runsWhere = mock(() => ({ orderBy: runsOrderBy, limit: runsLimit }));
|
|
344
|
+
const runsFrom = Object.assign(Promise.resolve(emptyRuns), {
|
|
345
|
+
where: runsWhere,
|
|
346
|
+
orderBy: runsOrderBy,
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
let selectCallCount = 0;
|
|
350
|
+
const mockDb = {
|
|
351
|
+
select: mock(() => {
|
|
352
|
+
selectCallCount += 1;
|
|
353
|
+
if (selectCallCount === 1) {
|
|
354
|
+
return { from: mock(() => associationsFrom) };
|
|
355
|
+
}
|
|
356
|
+
return { from: mock(() => runsFrom) };
|
|
357
|
+
}),
|
|
358
|
+
insert: mock(() => ({
|
|
359
|
+
values: mock(() => ({
|
|
360
|
+
onConflictDoUpdate: mock(() => Promise.resolve()),
|
|
361
|
+
onConflictDoNothing: mock(() => Promise.resolve()),
|
|
362
|
+
returning: mock(() => Promise.resolve([])),
|
|
363
|
+
})),
|
|
364
|
+
})),
|
|
365
|
+
update: mock(() => ({
|
|
366
|
+
set: mock(() => ({ where: mock(() => Promise.resolve()) })),
|
|
367
|
+
})),
|
|
368
|
+
delete: mock(() => ({ where: mock(() => Promise.resolve()) })),
|
|
369
|
+
execute: mock(() => Promise.resolve()),
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const service = new HealthCheckService(
|
|
373
|
+
mockDb as never,
|
|
374
|
+
{} as never,
|
|
375
|
+
{} as never,
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
const result = await service.getSystemHealthOverview("system-1");
|
|
379
|
+
|
|
380
|
+
expect(result.checks).toHaveLength(2);
|
|
381
|
+
const paused = result.checks.find(
|
|
382
|
+
(c) => c.configurationId === "config-paused",
|
|
383
|
+
);
|
|
384
|
+
const active = result.checks.find(
|
|
385
|
+
(c) => c.configurationId === "config-active",
|
|
386
|
+
);
|
|
387
|
+
expect(paused?.paused).toBe(true);
|
|
388
|
+
expect(active?.paused).toBe(false);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
});
|
|
@@ -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
|
+
});
|