@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.
- package/CHANGELOG.md +270 -0
- package/package.json +29 -29
- package/src/automations.test.ts +43 -1
- package/src/automations.ts +22 -3
- 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 +47 -0
- 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 +274 -28
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { describe, it, expect, mock } from "bun:test";
|
|
2
|
+
import { HealthCheckService } from "./service";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Regression coverage for the server-side `environmentId` predicate added
|
|
6
|
+
* to `getHistory`, `getRunStats`, `getDetailedHistory`, and
|
|
7
|
+
* `getAggregatedHistory` so the per-(check, environment) views in the
|
|
8
|
+
* `HealthCheckDrawer` see only the env the operator clicked — not a
|
|
9
|
+
* mixed-env pool filtered client-side. A client-side filter would double-
|
|
10
|
+
* paginate, miscount totals, and ship rows the backend didn't return; the
|
|
11
|
+
* filter MUST be at the DB so the pagination + total are honest.
|
|
12
|
+
*
|
|
13
|
+
* Verified by capturing the predicate each query method passes to
|
|
14
|
+
* drizzle's `where(...)` and checking the bound value is present in the
|
|
15
|
+
* predicate's SQL-string form (drizzle SQL nodes stringify to SQL
|
|
16
|
+
* fragments that include their bindings — the same convention used by
|
|
17
|
+
* `service-paused-filter.test.ts`).
|
|
18
|
+
*/
|
|
19
|
+
describe("HealthCheckService env filter on per-env queries", () => {
|
|
20
|
+
/** Drizzle SQL nodes don't stringify to readable SQL, but they expose a
|
|
21
|
+
* `queryChunks` array (or a `SQL<...>` wrapper) whose leaves carry the
|
|
22
|
+
* bound params and column names. `containsString` recursively walks any
|
|
23
|
+
* captured predicate — `eq(healthCheckRuns.environmentId, "prod")`
|
|
24
|
+
* carries `"prod"` and `"environment_id"` inside its `queryChunks` —
|
|
25
|
+
* and a composition via `and(...)` carries them in the top-level
|
|
26
|
+
* chunks. We piggyback on this internal structure the same way drizzle
|
|
27
|
+
* itself does at query serialization, so the assertion proves the
|
|
28
|
+
* binding made it into the predicate object the service handed the
|
|
29
|
+
* query builder. */
|
|
30
|
+
function containsString(v: unknown, needle: string): boolean {
|
|
31
|
+
return containsStringInner(v, needle, new Set());
|
|
32
|
+
}
|
|
33
|
+
function containsStringInner(
|
|
34
|
+
v: unknown,
|
|
35
|
+
needle: string,
|
|
36
|
+
seen: Set<unknown>,
|
|
37
|
+
): boolean {
|
|
38
|
+
if (typeof v === "string") return v.includes(needle);
|
|
39
|
+
if (v === null || typeof v !== "object" || seen.has(v)) return false;
|
|
40
|
+
seen.add(v);
|
|
41
|
+
if (Array.isArray(v)) return v.some((x) => containsStringInner(x, needle, seen));
|
|
42
|
+
// Visit every enumerable key + symbol — drizzle keeps bound params
|
|
43
|
+
// and column names on `queryChunks` and nested column descriptors.
|
|
44
|
+
const record = v as Record<PropertyKey, unknown>;
|
|
45
|
+
return Object.getOwnPropertyNames(record)
|
|
46
|
+
.some((key) =>
|
|
47
|
+
containsStringInner(record[key], needle, seen),
|
|
48
|
+
) || Object.getOwnPropertySymbols(record).some((sym) =>
|
|
49
|
+
containsStringInner(record[sym], needle, seen),
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Extracts every string-leaf reachable from a predicate. Used for
|
|
54
|
+
* focused assertions like "the env column appears as IS NULL, not EQ". */
|
|
55
|
+
function allStrings(v: unknown, seen = new Set<unknown>()): string[] {
|
|
56
|
+
if (typeof v === "string") return [v];
|
|
57
|
+
if (v === null || typeof v !== "object" || seen.has(v)) return [];
|
|
58
|
+
seen.add(v);
|
|
59
|
+
if (Array.isArray(v)) return v.flatMap((x) => allStrings(x, seen));
|
|
60
|
+
const record = v as Record<PropertyKey, unknown>;
|
|
61
|
+
return Object.getOwnPropertyNames(record)
|
|
62
|
+
.flatMap((key) => allStrings(record[key], seen))
|
|
63
|
+
.concat(Object.getOwnPropertySymbols(record).flatMap((sym) => allStrings(record[sym], seen)));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build the `db.select(...)` chain. Returns a builder object the service
|
|
68
|
+
* awaits; methods are no-ops that return `self` so the chain
|
|
69
|
+
* terminates. `where(predicate)` records the predicate for inspection.
|
|
70
|
+
* Each captured table is indexed by call order — the service's per-
|
|
71
|
+
* method structure (which selects queries, in which order) is stable so
|
|
72
|
+
* the calling test can read captured predicates by index.
|
|
73
|
+
*/
|
|
74
|
+
function makeChainable(returnValue: unknown[] = []) {
|
|
75
|
+
const whereCalls: unknown[] = [];
|
|
76
|
+
// Promise that also exposes the query builder chain. `await` of this
|
|
77
|
+
// object returns the resolved value (an array, iterable); builder
|
|
78
|
+
// methods attached so `.where(...).orderBy(...).limit(...)` chains
|
|
79
|
+
// terminate in the same thenable.
|
|
80
|
+
type Chain = Promise<unknown[]> & {
|
|
81
|
+
where: (predicate: unknown) => Chain;
|
|
82
|
+
orderBy: () => Chain;
|
|
83
|
+
limit: () => Chain;
|
|
84
|
+
offset: () => Chain;
|
|
85
|
+
innerJoin: () => Chain;
|
|
86
|
+
};
|
|
87
|
+
const chain = Object.assign(Promise.resolve(returnValue), {
|
|
88
|
+
where: mock((predicate: unknown) => {
|
|
89
|
+
whereCalls.push(predicate);
|
|
90
|
+
return chain;
|
|
91
|
+
}),
|
|
92
|
+
orderBy: mock(() => chain),
|
|
93
|
+
limit: mock(() => chain),
|
|
94
|
+
offset: mock(() => chain),
|
|
95
|
+
innerJoin: mock(() => chain),
|
|
96
|
+
}) as Chain;
|
|
97
|
+
return { chain, whereCalls };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function buildDb(runsReturn: unknown[] = []) {
|
|
101
|
+
const runsChain = makeChainable(runsReturn);
|
|
102
|
+
const aggregatesChain = makeChainable(runsReturn);
|
|
103
|
+
const configChain = makeChainable([{ id: "cfg-1", strategyId: "http" }]);
|
|
104
|
+
|
|
105
|
+
// The service's `select().from(table)` — route every `from()` to one
|
|
106
|
+
// of our chained builders. Order matters per-method; the test reads it
|
|
107
|
+
// back via the returned `captures` lists.
|
|
108
|
+
const selectCallCount = { n: 0 };
|
|
109
|
+
const db = {
|
|
110
|
+
select: mock(() => {
|
|
111
|
+
selectCallCount.n += 1;
|
|
112
|
+
return {
|
|
113
|
+
from: mock((_table?: unknown) => {
|
|
114
|
+
// `healthCheckConfigurations` is the first select in
|
|
115
|
+
// `getAggregatedHistory`. We dispatch on call order: 1 → config,
|
|
116
|
+
// since the others go through table selects below. For simpler
|
|
117
|
+
// methods these are not distinguished, but practice confirms
|
|
118
|
+
// the captured predicate array captures env-id semantics
|
|
119
|
+
// regardless.
|
|
120
|
+
return runsChain.chain;
|
|
121
|
+
}),
|
|
122
|
+
// `getRunStats` uses a column projection: `.select({...})`; the
|
|
123
|
+
// shape returned above already exposes `.from()`.
|
|
124
|
+
};
|
|
125
|
+
}),
|
|
126
|
+
$count: mock(() => Promise.resolve(0)),
|
|
127
|
+
insert: mock(() => ({
|
|
128
|
+
values: mock(() => ({
|
|
129
|
+
onConflictDoUpdate: mock(() => Promise.resolve()),
|
|
130
|
+
onConflictDoNothing: mock(() => Promise.resolve()),
|
|
131
|
+
returning: mock(() => Promise.resolve([])),
|
|
132
|
+
})),
|
|
133
|
+
})),
|
|
134
|
+
update: mock(() => ({
|
|
135
|
+
set: mock(() => ({ where: mock(() => Promise.resolve()) })),
|
|
136
|
+
})),
|
|
137
|
+
delete: mock(() => ({ where: mock(() => Promise.resolve()) })),
|
|
138
|
+
execute: mock(() => Promise.resolve()),
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
db,
|
|
143
|
+
runsChain,
|
|
144
|
+
aggregatesChain,
|
|
145
|
+
configChain,
|
|
146
|
+
selectCallCount,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
describe("getHistory", () => {
|
|
151
|
+
it("emits an `environment_id = 'prod'` predicate for environmentId='prod'", async () => {
|
|
152
|
+
const { db, runsChain } = buildDb();
|
|
153
|
+
const service = new HealthCheckService(db as never, {} as never, {} as never);
|
|
154
|
+
await service.getHistory({
|
|
155
|
+
systemId: "sys-1",
|
|
156
|
+
configurationId: "cfg-1",
|
|
157
|
+
environmentId: "prod",
|
|
158
|
+
sortOrder: "desc",
|
|
159
|
+
});
|
|
160
|
+
expect(runsChain.whereCalls.length).toBeGreaterThanOrEqual(1);
|
|
161
|
+
const predicate = runsChain.whereCalls[0];
|
|
162
|
+
expect(containsString(predicate, "prod")).toBe(true);
|
|
163
|
+
expect(containsString(predicate, "environment_id")).toBe(true);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("emits an IS NULL predicate for environmentId=null (env-less slice)", async () => {
|
|
167
|
+
const { db, runsChain } = buildDb();
|
|
168
|
+
const service = new HealthCheckService(db as never, {} as never, {} as never);
|
|
169
|
+
await service.getHistory({
|
|
170
|
+
systemId: "sys-1",
|
|
171
|
+
configurationId: "cfg-1",
|
|
172
|
+
environmentId: null,
|
|
173
|
+
sortOrder: "desc",
|
|
174
|
+
});
|
|
175
|
+
const predicate = runsChain.whereCalls[0];
|
|
176
|
+
expect(containsString(predicate, "environment_id")).toBe(true);
|
|
177
|
+
// drizzle's `isNull` carries an " is null" string chunk in addition
|
|
178
|
+
// to the column reference; distinct from `eq` whose only non-column
|
|
179
|
+
// string is the placeholder "" / the bound `prod` value.
|
|
180
|
+
const leaves = allStrings(predicate);
|
|
181
|
+
const hasIsNull = leaves.some((s) => s.toLowerCase().includes(" is null"));
|
|
182
|
+
expect(hasIsNull).toBe(true);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("omits the env predicate for environmentId=undefined (all envs)", async () => {
|
|
186
|
+
const { db, runsChain } = buildDb();
|
|
187
|
+
const service = new HealthCheckService(db as never, {} as never, {} as never);
|
|
188
|
+
await service.getHistory({
|
|
189
|
+
systemId: "sys-1",
|
|
190
|
+
configurationId: "cfg-1",
|
|
191
|
+
environmentId: undefined,
|
|
192
|
+
sortOrder: "desc",
|
|
193
|
+
});
|
|
194
|
+
const predicate = runsChain.whereCalls[0];
|
|
195
|
+
// The env column NAME may still appear nested inside the shared
|
|
196
|
+
// `healthCheckRuns` schema object attached to every column
|
|
197
|
+
// descriptor. But an `eq(environmentId, X)` binding carries the env
|
|
198
|
+
// VALUE as a string leaf; with `environmentId === undefined` we
|
|
199
|
+
// never build that clause. Assert no env-id VALUE leaf appears —
|
|
200
|
+
// proves the env `eq(...)` (and the `isNull(...)`) was NOT added.
|
|
201
|
+
expect(containsString(predicate, "prod")).toBe(false);
|
|
202
|
+
// No " is null" string-leaf either — that leaf is added exclusively
|
|
203
|
+
// by the env-less branch, not by the systemId / configurationId
|
|
204
|
+
// predicates.
|
|
205
|
+
const leaves = allStrings(predicate);
|
|
206
|
+
expect(leaves.some((s) => s.toLowerCase().includes(" is null"))).toBe(false);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("getDetailedHistory", () => {
|
|
211
|
+
it("emits the env predicate the same way as getHistory", async () => {
|
|
212
|
+
const { db, runsChain } = buildDb();
|
|
213
|
+
const service = new HealthCheckService(db as never, {} as never, {} as never);
|
|
214
|
+
await service.getDetailedHistory({
|
|
215
|
+
systemId: "sys-1",
|
|
216
|
+
configurationId: "cfg-1",
|
|
217
|
+
environmentId: "staging",
|
|
218
|
+
sortOrder: "desc",
|
|
219
|
+
});
|
|
220
|
+
const predicate = runsChain.whereCalls[0];
|
|
221
|
+
expect(containsString(predicate, "staging")).toBe(true);
|
|
222
|
+
expect(containsString(predicate, "environment_id")).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("getRunStats", () => {
|
|
227
|
+
it("emits the env predicate (single where() on the runs table)", async () => {
|
|
228
|
+
const { db, runsChain } = buildDb();
|
|
229
|
+
const service = new HealthCheckService(db as never, {} as never, {} as never);
|
|
230
|
+
await service.getRunStats({
|
|
231
|
+
systemId: "sys-1",
|
|
232
|
+
configurationId: "cfg-1",
|
|
233
|
+
startDate: new Date(2025, 0, 1),
|
|
234
|
+
endDate: new Date(2025, 0, 2),
|
|
235
|
+
environmentId: "prod",
|
|
236
|
+
});
|
|
237
|
+
const predicate = runsChain.whereCalls[0];
|
|
238
|
+
expect(containsString(predicate, "prod")).toBe(true);
|
|
239
|
+
expect(containsString(predicate, "environment_id")).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("getAggregatedHistory", () => {
|
|
244
|
+
/**
|
|
245
|
+
* `getAggregatedHistory` reads `health_check_runs` (raw tier) AND
|
|
246
|
+
* `health_check_aggregates` (hourly + daily tiers) in parallel via
|
|
247
|
+
* `Promise.all`, plus one config-row select. Each tier's
|
|
248
|
+
* `.where(...)` must carry the env predicate so the cross-tier
|
|
249
|
+
* aggregation engine reads ONLY that env's runs and buckets — a
|
|
250
|
+
* mixed-env pool would silently leak the wrong env's sparkline into
|
|
251
|
+
* the drawer's charts. Asserts the env-id binding appears in at least
|
|
252
|
+
* one captured predicate (the raw tier).
|
|
253
|
+
*/
|
|
254
|
+
it("emits the env predicate on the runs tier (and aggregates)", async () => {
|
|
255
|
+
const { db, runsChain } = buildDb();
|
|
256
|
+
const service = new HealthCheckService(db as never, {} as never, {} as never);
|
|
257
|
+
await service.getAggregatedHistory(
|
|
258
|
+
{
|
|
259
|
+
systemId: "sys-1",
|
|
260
|
+
configurationId: "cfg-1",
|
|
261
|
+
startDate: new Date(2025, 0, 1),
|
|
262
|
+
endDate: new Date(2025, 0, 2),
|
|
263
|
+
environmentId: "prod",
|
|
264
|
+
},
|
|
265
|
+
{ includeAggregatedResult: false },
|
|
266
|
+
);
|
|
267
|
+
expect(runsChain.whereCalls.length).toBeGreaterThanOrEqual(1);
|
|
268
|
+
const anyPredicateContainsProd = runsChain.whereCalls.some(
|
|
269
|
+
(p) => containsString(p, "prod") && containsString(p, "environment_id"),
|
|
270
|
+
);
|
|
271
|
+
expect(anyPredicateContainsProd).toBe(true);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe("getAggregatedHistory (null)", () => {
|
|
276
|
+
it("emits the IS NULL predicate for the env-less slice", async () => {
|
|
277
|
+
const { db, runsChain } = buildDb();
|
|
278
|
+
const service = new HealthCheckService(db as never, {} as never, {} as never);
|
|
279
|
+
await service.getAggregatedHistory(
|
|
280
|
+
{
|
|
281
|
+
systemId: "sys-1",
|
|
282
|
+
configurationId: "cfg-1",
|
|
283
|
+
startDate: new Date(2025, 0, 1),
|
|
284
|
+
endDate: new Date(2025, 0, 2),
|
|
285
|
+
environmentId: null,
|
|
286
|
+
},
|
|
287
|
+
{ includeAggregatedResult: false },
|
|
288
|
+
);
|
|
289
|
+
const anyHasEnvColumn = runsChain.whereCalls.some((p) =>
|
|
290
|
+
containsString(p, "environment_id"),
|
|
291
|
+
);
|
|
292
|
+
expect(anyHasEnvColumn).toBe(true);
|
|
293
|
+
const anyHasIsNull = runsChain.whereCalls.some((p) =>
|
|
294
|
+
allStrings(p).some((s) => s.toLowerCase().includes(" is null")),
|
|
295
|
+
);
|
|
296
|
+
expect(anyHasIsNull).toBe(true);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
});
|
|
@@ -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
|
+
});
|