@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,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
+ });