@checkstack/healthcheck-backend 1.18.0 → 1.20.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 +484 -0
- package/drizzle/0019_chemical_frightful_four.sql +8 -0
- package/drizzle/0020_certain_mordo.sql +2 -0
- package/drizzle/meta/0019_snapshot.json +661 -0
- package/drizzle/meta/0020_snapshot.json +711 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +23 -21
- package/src/ai/system-signals-contributor.test.ts +33 -9
- package/src/ai/system-signals-contributor.ts +38 -16
- package/src/cache-test-stub.ts +26 -0
- package/src/cache.test.ts +291 -0
- package/src/cache.ts +204 -34
- package/src/health-notification-content.test.ts +111 -0
- package/src/health-notification-content.ts +145 -0
- package/src/healthcheck-gitops-kinds.test.ts +14 -0
- package/src/healthcheck-gitops-kinds.ts +27 -0
- package/src/index.ts +31 -12
- package/src/queue-executor.test.ts +13 -26
- package/src/queue-executor.ts +125 -112
- package/src/retention-job.ts +8 -0
- package/src/rollup-consumer.test.ts +19 -8
- package/src/router-config-secrets.test.ts +2 -7
- package/src/router-create-and-assign.test.ts +2 -7
- package/src/router-pause-recompute.test.ts +2 -7
- package/src/router.test.ts +3 -8
- package/src/router.ts +43 -15
- package/src/schema.ts +74 -31
- package/src/service-batching.test.ts +8 -0
- package/src/service-bulk-counts.it.test.ts +144 -0
- package/src/service-bulk-run-stats.it.test.ts +197 -0
- package/src/service-ordering.test.ts +6 -2
- package/src/service-paused-filter.test.ts +13 -0
- package/src/service-rollup-worst-wins.test.ts +209 -145
- package/src/service.ts +408 -284
- package/src/status-fingerprint.test.ts +92 -0
- package/src/status-fingerprint.ts +66 -0
- package/src/status-page/rollup.test.ts +40 -0
- package/src/status-page/rollup.ts +27 -0
- package/src/status-page/widgets.test.ts +387 -0
- package/src/status-page/widgets.ts +236 -39
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
healthcheckSecretMarker,
|
|
17
17
|
isHealthcheckSecretMarker,
|
|
18
18
|
} from "./config-secrets";
|
|
19
|
-
import
|
|
19
|
+
import { createStubHealthCheckCache } from "./cache-test-stub";
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* Guards the SEC-1 fix: `createAndAssign` (the first-check wizard / AI propose
|
|
@@ -24,12 +24,7 @@ import type { HealthCheckCache } from "./cache";
|
|
|
24
24
|
* and return a REDACTED config - never persist or echo a plaintext credential.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
const passthroughCache
|
|
28
|
-
wrapSystemHealthStatus: (_systemId, loader) => loader(),
|
|
29
|
-
invalidateSystem: async () => {},
|
|
30
|
-
invalidateAllSystems: async () => 0,
|
|
31
|
-
scope: {} as HealthCheckCache["scope"],
|
|
32
|
-
};
|
|
27
|
+
const passthroughCache = createStubHealthCheckCache();
|
|
33
28
|
|
|
34
29
|
const mockUser = {
|
|
35
30
|
type: "user" as const,
|
|
@@ -2,7 +2,7 @@ import { describe, it, expect, mock } from "bun:test";
|
|
|
2
2
|
import { createHealthCheckRouter } from "./router";
|
|
3
3
|
import { createMockRpcContext } from "@checkstack/backend-api";
|
|
4
4
|
import { call } from "@orpc/server";
|
|
5
|
-
import
|
|
5
|
+
import { createStubHealthCheckCache } from "./cache-test-stub";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Router-level tests for the atomic create+assign path and the
|
|
@@ -12,12 +12,7 @@ import type { HealthCheckCache } from "./cache";
|
|
|
12
12
|
* `input.body.notificationPolicy`).
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
const passthroughCache
|
|
16
|
-
wrapSystemHealthStatus: (_systemId, loader) => loader(),
|
|
17
|
-
invalidateSystem: async () => {},
|
|
18
|
-
invalidateAllSystems: async () => 0,
|
|
19
|
-
scope: {} as HealthCheckCache["scope"],
|
|
20
|
-
};
|
|
15
|
+
const passthroughCache = createStubHealthCheckCache();
|
|
21
16
|
|
|
22
17
|
const mockUser = {
|
|
23
18
|
type: "user" as const,
|
|
@@ -2,7 +2,7 @@ import { describe, it, expect, mock, beforeEach } from "bun:test";
|
|
|
2
2
|
import { createHealthCheckRouter } from "./router";
|
|
3
3
|
import { createMockRpcContext } from "@checkstack/backend-api";
|
|
4
4
|
import { call } from "@orpc/server";
|
|
5
|
-
import
|
|
5
|
+
import { createStubHealthCheckCache } from "./cache-test-stub";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Router-level tests for the pause/resume handlers' rollup-health recompute
|
|
@@ -14,12 +14,7 @@ import type { HealthCheckCache } from "./cache";
|
|
|
14
14
|
* any degraded transition (see the resume handler comment for rationale).
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
const passthroughCache
|
|
18
|
-
wrapSystemHealthStatus: (_systemId, loader) => loader(),
|
|
19
|
-
invalidateSystem: async () => {},
|
|
20
|
-
invalidateAllSystems: async () => 0,
|
|
21
|
-
scope: {} as HealthCheckCache["scope"],
|
|
22
|
-
};
|
|
17
|
+
const passthroughCache = createStubHealthCheckCache();
|
|
23
18
|
|
|
24
19
|
const mockUser = {
|
|
25
20
|
type: "user" as const,
|
package/src/router.test.ts
CHANGED
|
@@ -3,14 +3,9 @@ import { createHealthCheckRouter } from "./router";
|
|
|
3
3
|
import { createMockRpcContext, Versioned } from "@checkstack/backend-api";
|
|
4
4
|
import { call } from "@orpc/server";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
const passthroughCache
|
|
9
|
-
wrapSystemHealthStatus: (_systemId, loader) => loader(),
|
|
10
|
-
invalidateSystem: async () => {},
|
|
11
|
-
invalidateAllSystems: async () => 0,
|
|
12
|
-
scope: {} as HealthCheckCache["scope"],
|
|
13
|
-
};
|
|
6
|
+
import { createStubHealthCheckCache } from "./cache-test-stub";
|
|
7
|
+
|
|
8
|
+
const passthroughCache = createStubHealthCheckCache();
|
|
14
9
|
|
|
15
10
|
describe("HealthCheck Router", () => {
|
|
16
11
|
const mockUser = {
|
package/src/router.ts
CHANGED
|
@@ -227,6 +227,27 @@ export const createHealthCheckRouter = (opts: {
|
|
|
227
227
|
});
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
// Recompute the rollup `health` entity for this system NOW. Changing an
|
|
231
|
+
// assignment's environment set can make a previously-effective env slice
|
|
232
|
+
// orphaned (env disabled/removed from `environmentIds`): that slice stops
|
|
233
|
+
// producing runs, so NO per-env health-change event will ever fire for it,
|
|
234
|
+
// and the event-driven rollup consumer would never recompute the disabled
|
|
235
|
+
// env's last (unhealthy) status away. `getSystemHealthStatus` now excludes
|
|
236
|
+
// non-effective slices, so this recompute promptly drops the stale slice
|
|
237
|
+
// from the persisted entity - closing any SLO downtime it was holding open
|
|
238
|
+
// and clearing the badge - instead of waiting for its runs to age out of
|
|
239
|
+
// the window. Best-effort: a recompute failure is logged inside the helper
|
|
240
|
+
// and never breaks the mutation (the live RPC reads are already correct).
|
|
241
|
+
if (recomputeSystemRollupHealth) {
|
|
242
|
+
try {
|
|
243
|
+
await recomputeSystemRollupHealth(args.systemId);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
logger.warn(
|
|
246
|
+
`Failed to recompute rollup health after assignment change for system ${args.systemId}: ${extractErrorMessage(error, "unknown")}`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
230
251
|
// Notify subscribers (e.g., satellite-backend) that assignments changed.
|
|
231
252
|
const emitHook = getEmitHook();
|
|
232
253
|
if (emitHook) {
|
|
@@ -473,6 +494,17 @@ export const createHealthCheckRouter = (opts: {
|
|
|
473
494
|
},
|
|
474
495
|
),
|
|
475
496
|
|
|
497
|
+
getBulkAssignedHealthCheckCounts:
|
|
498
|
+
os.getBulkAssignedHealthCheckCounts.handler(async ({ input }) => {
|
|
499
|
+
// ONE grouped query for the whole visible system list (replaces the
|
|
500
|
+
// per-row getSystemAssociations N+1). recordKey gating on the contract
|
|
501
|
+
// drops counts for systems the caller may not read.
|
|
502
|
+
const counts = await service.getBulkAssignedHealthCheckCounts(
|
|
503
|
+
input.systemIds,
|
|
504
|
+
);
|
|
505
|
+
return { counts };
|
|
506
|
+
}),
|
|
507
|
+
|
|
476
508
|
associateSystem: os.associateSystem.handler(async ({ input, context }) => {
|
|
477
509
|
await enforceNotGitOpsLocked("System", input.systemId);
|
|
478
510
|
await service.associateSystem({
|
|
@@ -573,6 +605,10 @@ export const createHealthCheckRouter = (opts: {
|
|
|
573
605
|
return service.getRunStats(input);
|
|
574
606
|
}),
|
|
575
607
|
|
|
608
|
+
getBulkRunStats: os.getBulkRunStats.handler(async ({ input }) => {
|
|
609
|
+
return { stats: await service.getBulkRunStats(input) };
|
|
610
|
+
}),
|
|
611
|
+
|
|
576
612
|
getDetailedHistory: os.getDetailedHistory.handler(
|
|
577
613
|
async ({ input, context }) => {
|
|
578
614
|
// Handler-side authorization (the contract's `access` is deliberately
|
|
@@ -674,9 +710,10 @@ export const createHealthCheckRouter = (opts: {
|
|
|
674
710
|
),
|
|
675
711
|
getSystemHealthStatus: os.getSystemHealthStatus.handler(
|
|
676
712
|
async ({ input }) => {
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
713
|
+
// The cache holds the RAW (pre-override) status; incident overrides are
|
|
714
|
+
// folded downstream (always live) so an override lifts the instant its
|
|
715
|
+
// incident resolves. See ./cache.ts for the key/TTL/invalidation contract.
|
|
716
|
+
const base = await cache.read(input.systemId);
|
|
680
717
|
const folded = await foldIncidentOverrides({
|
|
681
718
|
[input.systemId]: base,
|
|
682
719
|
});
|
|
@@ -688,24 +725,15 @@ export const createHealthCheckRouter = (opts: {
|
|
|
688
725
|
async ({ input }) => {
|
|
689
726
|
// Per-entity caching: each system's status is cached individually
|
|
690
727
|
// and invalidated by id on mutations, so dashboards with overlapping
|
|
691
|
-
// (but non-identical) system sets share cache entries.
|
|
692
|
-
|
|
693
|
-
const statuses: Record<string, SystemHealthStatusResponse> = {};
|
|
694
|
-
await Promise.all(
|
|
695
|
-
input.systemIds.map(async (systemId) => {
|
|
696
|
-
statuses[systemId] = await cache.wrapSystemHealthStatus(
|
|
697
|
-
systemId,
|
|
698
|
-
() => service.getSystemHealthStatus(systemId),
|
|
699
|
-
);
|
|
700
|
-
}),
|
|
701
|
-
);
|
|
728
|
+
// (but non-identical) system sets share cache entries.
|
|
729
|
+
const statuses = await cache.readBulk(input.systemIds);
|
|
702
730
|
return { statuses: await foldIncidentOverrides(statuses) };
|
|
703
731
|
},
|
|
704
732
|
),
|
|
705
733
|
|
|
706
734
|
getBulkSystemHealthMatrix: os.getBulkSystemHealthMatrix.handler(
|
|
707
735
|
async ({ input }) => {
|
|
708
|
-
const matrix = await
|
|
736
|
+
const matrix = await cache.readMatrix(input.systemIds);
|
|
709
737
|
|
|
710
738
|
// Fold active incident overrides into each system's OVERALL rollup, so
|
|
711
739
|
// an incident-forced status still propagates through dependencies (as
|
package/src/schema.ts
CHANGED
|
@@ -127,6 +127,13 @@ export const systemHealthChecks = pgTable(
|
|
|
127
127
|
},
|
|
128
128
|
(t) => ({
|
|
129
129
|
pk: primaryKey({ columns: [t.systemId, t.configurationId] }),
|
|
130
|
+
// Reverse lookup for getSystemIdsForConfiguration on config-change
|
|
131
|
+
// recompute (WHERE configuration_id [AND enabled]). The PK leads with
|
|
132
|
+
// system_id, so a config-scoped scan cannot use it.
|
|
133
|
+
configEnabledIdx: index("system_health_checks_config_enabled_idx").on(
|
|
134
|
+
t.configurationId,
|
|
135
|
+
t.enabled,
|
|
136
|
+
),
|
|
130
137
|
}),
|
|
131
138
|
);
|
|
132
139
|
|
|
@@ -186,37 +193,65 @@ export const healthCheckStateTransitions = pgTable(
|
|
|
186
193
|
}),
|
|
187
194
|
);
|
|
188
195
|
|
|
189
|
-
export const healthCheckRuns = pgTable(
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
196
|
+
export const healthCheckRuns = pgTable(
|
|
197
|
+
"health_check_runs",
|
|
198
|
+
{
|
|
199
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
200
|
+
configurationId: uuid("configuration_id")
|
|
201
|
+
.notNull()
|
|
202
|
+
.references(() => healthCheckConfigurations.id, { onDelete: "cascade" }),
|
|
203
|
+
systemId: text("system_id").notNull(),
|
|
204
|
+
/**
|
|
205
|
+
* Environment this run was executed for (per-environment fan-out).
|
|
206
|
+
* null = ran with no environment (the opt-out / no-membership case,
|
|
207
|
+
* which is exactly the pre-feature behavior). Nullable text, NOT a FK
|
|
208
|
+
* to the catalog `environments` table (healthcheck and catalog are
|
|
209
|
+
* separate plugins with separate Postgres schemas, mirroring how
|
|
210
|
+
* `systemId` is a bare text with no FK to `systems`).
|
|
211
|
+
*/
|
|
212
|
+
environmentId: text("environment_id"),
|
|
213
|
+
status: healthCheckStatusEnum("status").notNull(),
|
|
214
|
+
/** Execution duration in milliseconds */
|
|
215
|
+
latencyMs: integer("latency_ms"),
|
|
216
|
+
result: jsonb("result").$type<Record<string, unknown>>(),
|
|
217
|
+
/**
|
|
218
|
+
* Source identifier for result attribution.
|
|
219
|
+
* null = local core execution, UUID = satellite ID.
|
|
220
|
+
*/
|
|
221
|
+
sourceId: text("source_id"),
|
|
222
|
+
/**
|
|
223
|
+
* Human-readable source label for UI display.
|
|
224
|
+
* e.g. "Local" or "EU West (eu-west-1)".
|
|
225
|
+
*/
|
|
226
|
+
sourceLabel: text("source_label"),
|
|
227
|
+
timestamp: timestamp("timestamp").defaultNow().notNull(),
|
|
228
|
+
},
|
|
229
|
+
(t) => ({
|
|
230
|
+
// The status read path reads the last N runs for a (system, check) slice
|
|
231
|
+
// ordered by timestamp DESC. Without a supporting index every such read
|
|
232
|
+
// sequential-scans the whole (multi-million-row) table and sorts. Postgres
|
|
233
|
+
// scans a plain btree backward, so an ASC index serves ORDER BY ... DESC.
|
|
234
|
+
//
|
|
235
|
+
// Cross-environment newest-run read (WHERE system_id, configuration_id
|
|
236
|
+
// ORDER BY timestamp DESC LIMIT n - the single hottest query) and the
|
|
237
|
+
// retention DELETE (WHERE system_id, configuration_id AND timestamp < cutoff).
|
|
238
|
+
checkRecentIdx: index("health_check_runs_check_recent_idx").on(
|
|
239
|
+
t.systemId,
|
|
240
|
+
t.configurationId,
|
|
241
|
+
t.timestamp,
|
|
242
|
+
),
|
|
243
|
+
// Env-scoped slice read (WHERE system_id, configuration_id,
|
|
244
|
+
// environment_id [= ? | IS NULL] ORDER BY timestamp DESC LIMIT n), the
|
|
245
|
+
// per-check DISTINCT-environment discovery, and the per-env "last healthy"
|
|
246
|
+
// max(timestamp) GROUP BY environment_id.
|
|
247
|
+
sliceRecentIdx: index("health_check_runs_slice_recent_idx").on(
|
|
248
|
+
t.systemId,
|
|
249
|
+
t.configurationId,
|
|
250
|
+
t.environmentId,
|
|
251
|
+
t.timestamp,
|
|
252
|
+
),
|
|
253
|
+
}),
|
|
254
|
+
);
|
|
220
255
|
|
|
221
256
|
/**
|
|
222
257
|
* Bucket size enum for aggregated data.
|
|
@@ -286,5 +321,13 @@ export const healthCheckAggregates = pgTable(
|
|
|
286
321
|
t.bucketSize,
|
|
287
322
|
t.sourceId,
|
|
288
323
|
).nullsNotDistinct(),
|
|
324
|
+
// Health-state read with configuration_id absent (WHERE system_id,
|
|
325
|
+
// bucket_size, bucket_start ...). The unique index leads with
|
|
326
|
+
// configuration_id, so this query scans without a system-leading index.
|
|
327
|
+
systemBucketIdx: index("health_check_aggregates_system_bucket_idx").on(
|
|
328
|
+
t.systemId,
|
|
329
|
+
t.bucketSize,
|
|
330
|
+
t.bucketStart,
|
|
331
|
+
),
|
|
289
332
|
}),
|
|
290
333
|
);
|
|
@@ -18,6 +18,8 @@ describe("HealthCheckService.getSystemHealthStatus - read batching", () => {
|
|
|
18
18
|
enabled: true,
|
|
19
19
|
paused: false,
|
|
20
20
|
stateThresholds: null,
|
|
21
|
+
// All-environments selector; each check has only the env-less slice below.
|
|
22
|
+
environmentIds: null,
|
|
21
23
|
}));
|
|
22
24
|
const assocWhere = mock(() => Promise.resolve(associations));
|
|
23
25
|
const assocInnerJoin = Object.assign(Promise.resolve([]), {
|
|
@@ -39,6 +41,11 @@ describe("HealthCheckService.getSystemHealthStatus - read batching", () => {
|
|
|
39
41
|
orderBy: runsOrderBy,
|
|
40
42
|
});
|
|
41
43
|
|
|
44
|
+
// Distinct env keys query per check: a single env-less (null) slice.
|
|
45
|
+
const distinctFrom = Object.assign(Promise.resolve([]), {
|
|
46
|
+
where: mock(() => Promise.resolve([{ environmentId: null }])),
|
|
47
|
+
});
|
|
48
|
+
|
|
42
49
|
let selectCallCount = 0;
|
|
43
50
|
const db = withTransactionMock({
|
|
44
51
|
select: mock(() => {
|
|
@@ -46,6 +53,7 @@ describe("HealthCheckService.getSystemHealthStatus - read batching", () => {
|
|
|
46
53
|
if (selectCallCount === 1) return { from: mock(() => assocFrom) };
|
|
47
54
|
return { from: mock(() => runsFrom) };
|
|
48
55
|
}),
|
|
56
|
+
selectDistinct: mock(() => ({ from: mock(() => distinctFrom) })),
|
|
49
57
|
insert: mock(() => ({ values: mock(() => Promise.resolve()) })),
|
|
50
58
|
update: mock(() => ({
|
|
51
59
|
set: mock(() => ({ where: mock(() => Promise.resolve()) })),
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test for `HealthCheckService.getBulkAssignedHealthCheckCounts`
|
|
3
|
+
* against a REAL Postgres. The method's whole point is the GROUP BY / COUNT and
|
|
4
|
+
* the zero-fill for systems with no rows - behaviour a mocked db cannot prove
|
|
5
|
+
* (the database does the grouping). These tests pin: counts are grouped per
|
|
6
|
+
* system, systems with no assignments report 0, requested-but-absent systems
|
|
7
|
+
* report 0, and non-requested systems never leak in.
|
|
8
|
+
*
|
|
9
|
+
* Gated on CHECKSTACK_IT so it runs in CI (shared compose Postgres) and is
|
|
10
|
+
* skipped in the default `bun test` run, matching the other *.it.test.ts here.
|
|
11
|
+
*/
|
|
12
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test";
|
|
13
|
+
import { drizzle } from "drizzle-orm/node-postgres";
|
|
14
|
+
import { Pool } from "pg";
|
|
15
|
+
import type {
|
|
16
|
+
SafeDatabase,
|
|
17
|
+
HealthCheckRegistry,
|
|
18
|
+
CollectorRegistry,
|
|
19
|
+
} from "@checkstack/backend-api";
|
|
20
|
+
import * as schema from "./schema";
|
|
21
|
+
import { HealthCheckService } from "./service";
|
|
22
|
+
|
|
23
|
+
const PG_URL =
|
|
24
|
+
process.env.CHECKSTACK_IT_PG_URL ??
|
|
25
|
+
"postgres://postgres:postgres@localhost:5432/postgres";
|
|
26
|
+
const SCHEMA = "healthcheck_it_bulk_counts";
|
|
27
|
+
|
|
28
|
+
let admin: Pool;
|
|
29
|
+
let pool: Pool;
|
|
30
|
+
let service: HealthCheckService;
|
|
31
|
+
|
|
32
|
+
async function insertAssignment(row: {
|
|
33
|
+
systemId: string;
|
|
34
|
+
configurationId: string;
|
|
35
|
+
}): Promise<void> {
|
|
36
|
+
await pool.query(
|
|
37
|
+
`INSERT INTO "${SCHEMA}".system_health_checks (system_id, configuration_id)
|
|
38
|
+
VALUES ($1, $2)`,
|
|
39
|
+
[row.systemId, row.configurationId],
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe.skipIf(!process.env.CHECKSTACK_IT)(
|
|
44
|
+
"HealthCheckService.getBulkAssignedHealthCheckCounts (shared Postgres)",
|
|
45
|
+
() => {
|
|
46
|
+
beforeAll(async () => {
|
|
47
|
+
admin = new Pool({ connectionString: PG_URL });
|
|
48
|
+
await admin.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`);
|
|
49
|
+
await admin.query(`CREATE SCHEMA "${SCHEMA}"`);
|
|
50
|
+
// Only the columns the grouped COUNT touches; no FK to configurations so
|
|
51
|
+
// the DDL stays minimal and focused on the aggregation behaviour.
|
|
52
|
+
await admin.query(
|
|
53
|
+
`CREATE TABLE "${SCHEMA}".system_health_checks (
|
|
54
|
+
system_id text NOT NULL,
|
|
55
|
+
configuration_id uuid NOT NULL,
|
|
56
|
+
PRIMARY KEY (system_id, configuration_id)
|
|
57
|
+
)`,
|
|
58
|
+
);
|
|
59
|
+
pool = new Pool({
|
|
60
|
+
connectionString: PG_URL,
|
|
61
|
+
options: `-c search_path=${SCHEMA}`,
|
|
62
|
+
});
|
|
63
|
+
const db = drizzle(pool, {
|
|
64
|
+
schema,
|
|
65
|
+
}) as unknown as SafeDatabase<typeof schema>;
|
|
66
|
+
// registry / collectorRegistry are unused by the count method under test;
|
|
67
|
+
// stub them so the constructor is satisfied without wiring real registries.
|
|
68
|
+
const service_ = new HealthCheckService(
|
|
69
|
+
db,
|
|
70
|
+
{} as unknown as HealthCheckRegistry,
|
|
71
|
+
{} as unknown as CollectorRegistry,
|
|
72
|
+
);
|
|
73
|
+
service = service_;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
afterAll(async () => {
|
|
77
|
+
await pool?.end();
|
|
78
|
+
await admin.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`);
|
|
79
|
+
await admin.end();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
beforeEach(async () => {
|
|
83
|
+
await pool.query(`TRUNCATE "${SCHEMA}".system_health_checks`);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("groups counts per system and zero-fills systems with no assignments", async () => {
|
|
87
|
+
// sys-a: 2 assignments, sys-b: 1, sys-c: none.
|
|
88
|
+
await insertAssignment({
|
|
89
|
+
systemId: "sys-a",
|
|
90
|
+
configurationId: crypto.randomUUID(),
|
|
91
|
+
});
|
|
92
|
+
await insertAssignment({
|
|
93
|
+
systemId: "sys-a",
|
|
94
|
+
configurationId: crypto.randomUUID(),
|
|
95
|
+
});
|
|
96
|
+
await insertAssignment({
|
|
97
|
+
systemId: "sys-b",
|
|
98
|
+
configurationId: crypto.randomUUID(),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const counts = await service.getBulkAssignedHealthCheckCounts([
|
|
102
|
+
"sys-a",
|
|
103
|
+
"sys-b",
|
|
104
|
+
"sys-c",
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
expect(counts).toEqual({ "sys-a": 2, "sys-b": 1, "sys-c": 0 });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("returns 0 for a requested system that has no rows at all", async () => {
|
|
111
|
+
await insertAssignment({
|
|
112
|
+
systemId: "sys-a",
|
|
113
|
+
configurationId: crypto.randomUUID(),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const counts = await service.getBulkAssignedHealthCheckCounts([
|
|
117
|
+
"sys-a",
|
|
118
|
+
"missing",
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
expect(counts).toEqual({ "sys-a": 1, missing: 0 });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("never leaks counts for systems not in the requested set", async () => {
|
|
125
|
+
await insertAssignment({
|
|
126
|
+
systemId: "sys-a",
|
|
127
|
+
configurationId: crypto.randomUUID(),
|
|
128
|
+
});
|
|
129
|
+
await insertAssignment({
|
|
130
|
+
systemId: "sys-other",
|
|
131
|
+
configurationId: crypto.randomUUID(),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const counts = await service.getBulkAssignedHealthCheckCounts(["sys-a"]);
|
|
135
|
+
|
|
136
|
+
expect(counts).toEqual({ "sys-a": 1 });
|
|
137
|
+
expect(Object.keys(counts)).not.toContain("sys-other");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("returns an empty map for an empty request without querying", async () => {
|
|
141
|
+
expect(await service.getBulkAssignedHealthCheckCounts([])).toEqual({});
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
);
|