@checkstack/healthcheck-backend 1.17.0 → 1.19.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 +559 -0
- package/package.json +32 -29
- package/src/adaptive-timeout.test.ts +91 -0
- package/src/adaptive-timeout.ts +75 -0
- package/src/ai/system-signals-contributor.test.ts +2 -0
- package/src/automations.test.ts +47 -0
- package/src/automations.ts +19 -3
- package/src/health-notification-content.test.ts +89 -0
- package/src/health-notification-content.ts +138 -0
- package/src/healthcheck-gitops-kinds.test.ts +34 -2
- package/src/healthcheck-gitops-kinds.ts +17 -13
- package/src/index.ts +58 -6
- package/src/migration-chain-contract.test.ts +7 -1
- package/src/notification-policy.test.ts +19 -0
- package/src/notification-policy.ts +26 -0
- package/src/queue-executor.test.ts +391 -338
- package/src/queue-executor.ts +426 -362
- package/src/realtime-aggregation.ts +9 -2
- package/src/rollup-consumer.test.ts +191 -0
- package/src/rollup-consumer.ts +160 -0
- package/src/router.ts +46 -13
- package/src/schedule-jitter.test.ts +69 -0
- package/src/schedule-jitter.ts +50 -0
- package/src/schedule-reconciler.it.test.ts +453 -0
- package/src/schedule-reconciler.test.ts +418 -0
- package/src/schedule-reconciler.ts +304 -0
- package/src/service-batching.test.ts +106 -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 +10 -2
- package/src/service-paused-filter.test.ts +27 -7
- package/src/service-rollup-worst-wins.test.ts +221 -124
- package/src/service.ts +557 -266
- package/src/slow-check-admission.test.ts +184 -0
- package/src/slow-check-admission.ts +101 -0
- package/src/slow-check-classifier.test.ts +155 -0
- package/src/slow-check-classifier.ts +137 -0
- package/src/slow-check-config.ts +102 -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 +303 -0
- package/src/status-page/widgets.ts +155 -39
- package/src/suspect-lane.test.ts +50 -0
- package/src/suspect-lane.ts +61 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, it, expect, mock } from "bun:test";
|
|
2
|
+
import { withTransactionMock } from "@checkstack/test-utils-backend";
|
|
3
|
+
import { HealthCheckService } from "./service";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Regression: `getSystemHealthStatus` batches its 1 (associations) + N
|
|
7
|
+
* (per-check run window) reads into ONE scoped transaction (see
|
|
8
|
+
* `withScopedTransaction`), so the whole read fan-out pays a single
|
|
9
|
+
* BEGIN/SET LOCAL/COMMIT and holds one connection instead of 1+N standalone
|
|
10
|
+
* scoped queries. This pins that the reads run inside `db.transaction(...)`
|
|
11
|
+
* (exactly once per call), independent of how many checks a system has.
|
|
12
|
+
*/
|
|
13
|
+
describe("HealthCheckService.getSystemHealthStatus - read batching", () => {
|
|
14
|
+
function createMockDb(configCount: number) {
|
|
15
|
+
const associations = Array.from({ length: configCount }, (_, i) => ({
|
|
16
|
+
configurationId: `config-${i}`,
|
|
17
|
+
configName: `Check ${i}`,
|
|
18
|
+
enabled: true,
|
|
19
|
+
paused: false,
|
|
20
|
+
stateThresholds: null,
|
|
21
|
+
// All-environments selector; each check has only the env-less slice below.
|
|
22
|
+
environmentIds: null,
|
|
23
|
+
}));
|
|
24
|
+
const assocWhere = mock(() => Promise.resolve(associations));
|
|
25
|
+
const assocInnerJoin = Object.assign(Promise.resolve([]), {
|
|
26
|
+
where: assocWhere,
|
|
27
|
+
});
|
|
28
|
+
const assocFrom = Object.assign(Promise.resolve([]), {
|
|
29
|
+
innerJoin: mock(() => assocInnerJoin),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Each per-check run window returns one healthy run.
|
|
33
|
+
const healthyRun = [
|
|
34
|
+
{ status: "healthy" as const, timestamp: new Date(), environmentId: null },
|
|
35
|
+
];
|
|
36
|
+
const runsLimit = mock(() => Promise.resolve(healthyRun));
|
|
37
|
+
const runsOrderBy = mock(() => ({ limit: runsLimit }));
|
|
38
|
+
const runsWhere = mock(() => ({ orderBy: runsOrderBy, limit: runsLimit }));
|
|
39
|
+
const runsFrom = Object.assign(Promise.resolve(healthyRun), {
|
|
40
|
+
where: runsWhere,
|
|
41
|
+
orderBy: runsOrderBy,
|
|
42
|
+
});
|
|
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
|
+
|
|
49
|
+
let selectCallCount = 0;
|
|
50
|
+
const db = withTransactionMock({
|
|
51
|
+
select: mock(() => {
|
|
52
|
+
selectCallCount += 1;
|
|
53
|
+
if (selectCallCount === 1) return { from: mock(() => assocFrom) };
|
|
54
|
+
return { from: mock(() => runsFrom) };
|
|
55
|
+
}),
|
|
56
|
+
selectDistinct: mock(() => ({ from: mock(() => distinctFrom) })),
|
|
57
|
+
insert: mock(() => ({ values: mock(() => Promise.resolve()) })),
|
|
58
|
+
update: mock(() => ({
|
|
59
|
+
set: mock(() => ({ where: mock(() => Promise.resolve()) })),
|
|
60
|
+
})),
|
|
61
|
+
delete: mock(() => ({ where: mock(() => Promise.resolve()) })),
|
|
62
|
+
execute: mock(() => Promise.resolve()),
|
|
63
|
+
});
|
|
64
|
+
return db;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
it("wraps the associations + per-check reads in exactly ONE transaction", async () => {
|
|
68
|
+
const mockDb = createMockDb(3);
|
|
69
|
+
const service = new HealthCheckService(
|
|
70
|
+
mockDb as never,
|
|
71
|
+
{} as never,
|
|
72
|
+
{} as never,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const result = await service.getSystemHealthStatus("system-1");
|
|
76
|
+
|
|
77
|
+
expect(result.status).toBe("healthy");
|
|
78
|
+
expect(result.checkStatuses).toHaveLength(3);
|
|
79
|
+
// One transaction covers all 1 + N reads (not one per query).
|
|
80
|
+
const transaction = (mockDb as unknown as { transaction: ReturnType<typeof mock> })
|
|
81
|
+
.transaction;
|
|
82
|
+
expect(transaction).toHaveBeenCalledTimes(1);
|
|
83
|
+
// 1 associations select + 3 per-check run selects = 4 selects, all inside
|
|
84
|
+
// the single transaction.
|
|
85
|
+
const select = (mockDb as unknown as { select: ReturnType<typeof mock> })
|
|
86
|
+
.select;
|
|
87
|
+
expect(select).toHaveBeenCalledTimes(4);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("still opens exactly one transaction for a system with no checks", async () => {
|
|
91
|
+
const mockDb = createMockDb(0);
|
|
92
|
+
const service = new HealthCheckService(
|
|
93
|
+
mockDb as never,
|
|
94
|
+
{} as never,
|
|
95
|
+
{} as never,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const result = await service.getSystemHealthStatus("system-1");
|
|
99
|
+
|
|
100
|
+
expect(result.status).toBe("healthy");
|
|
101
|
+
expect(result.checkStatuses).toHaveLength(0);
|
|
102
|
+
const transaction = (mockDb as unknown as { transaction: ReturnType<typeof mock> })
|
|
103
|
+
.transaction;
|
|
104
|
+
expect(transaction).toHaveBeenCalledTimes(1);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -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
|
+
);
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test for `HealthCheckService.getBulkRunStats` against a REAL
|
|
3
|
+
* Postgres. The method's whole point is the single grouped read over
|
|
4
|
+
* `health_check_runs` for MANY systems, then per-system summarization - so each
|
|
5
|
+
* entry MUST be byte-identical to what the single `getRunStats({ systemId })`
|
|
6
|
+
* would return for the same window. A mocked db cannot prove the `inArray`
|
|
7
|
+
* grouping / windowing, so this real-DB guard pins the equivalence, the
|
|
8
|
+
* per-system isolation, and the "systems with no runs are omitted" behaviour
|
|
9
|
+
* the status-page uptime column relies on.
|
|
10
|
+
*
|
|
11
|
+
* Gated on CHECKSTACK_IT so it runs in CI (shared compose Postgres) and is
|
|
12
|
+
* skipped in the default `bun test` run, matching the other *.it.test.ts here.
|
|
13
|
+
*/
|
|
14
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test";
|
|
15
|
+
import { drizzle } from "drizzle-orm/node-postgres";
|
|
16
|
+
import { Pool } from "pg";
|
|
17
|
+
import type {
|
|
18
|
+
SafeDatabase,
|
|
19
|
+
HealthCheckRegistry,
|
|
20
|
+
CollectorRegistry,
|
|
21
|
+
} from "@checkstack/backend-api";
|
|
22
|
+
import * as schema from "./schema";
|
|
23
|
+
import { HealthCheckService } from "./service";
|
|
24
|
+
|
|
25
|
+
const PG_URL =
|
|
26
|
+
process.env.CHECKSTACK_IT_PG_URL ??
|
|
27
|
+
"postgres://postgres:postgres@localhost:5432/postgres";
|
|
28
|
+
const SCHEMA = "healthcheck_it_bulk_run_stats";
|
|
29
|
+
|
|
30
|
+
const START = new Date("2026-06-01T00:00:00.000Z");
|
|
31
|
+
const END = new Date("2026-06-01T23:59:59.000Z");
|
|
32
|
+
|
|
33
|
+
let admin: Pool;
|
|
34
|
+
let pool: Pool;
|
|
35
|
+
let service: HealthCheckService;
|
|
36
|
+
|
|
37
|
+
async function insertRun(row: {
|
|
38
|
+
systemId: string;
|
|
39
|
+
status: string;
|
|
40
|
+
latencyMs?: number | null;
|
|
41
|
+
at: string;
|
|
42
|
+
environmentId?: string | null;
|
|
43
|
+
}): Promise<void> {
|
|
44
|
+
await pool.query(
|
|
45
|
+
`INSERT INTO "${SCHEMA}".health_check_runs
|
|
46
|
+
(id, configuration_id, system_id, environment_id, status, latency_ms, timestamp)
|
|
47
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
|
48
|
+
[
|
|
49
|
+
crypto.randomUUID(),
|
|
50
|
+
crypto.randomUUID(),
|
|
51
|
+
row.systemId,
|
|
52
|
+
row.environmentId ?? null,
|
|
53
|
+
row.status,
|
|
54
|
+
row.latencyMs ?? null,
|
|
55
|
+
row.at,
|
|
56
|
+
],
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe.skipIf(!process.env.CHECKSTACK_IT)(
|
|
61
|
+
"HealthCheckService.getBulkRunStats (shared Postgres)",
|
|
62
|
+
() => {
|
|
63
|
+
beforeAll(async () => {
|
|
64
|
+
admin = new Pool({ connectionString: PG_URL });
|
|
65
|
+
await admin.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`);
|
|
66
|
+
await admin.query(`CREATE SCHEMA "${SCHEMA}"`);
|
|
67
|
+
// Minimal DDL: only the columns the grouped read touches; status is plain
|
|
68
|
+
// text and configuration_id carries no FK, keeping the schema
|
|
69
|
+
// self-contained like the sibling *.it.test.ts files.
|
|
70
|
+
await admin.query(
|
|
71
|
+
`CREATE TABLE "${SCHEMA}".health_check_runs (
|
|
72
|
+
id uuid PRIMARY KEY,
|
|
73
|
+
configuration_id uuid NOT NULL,
|
|
74
|
+
system_id text NOT NULL,
|
|
75
|
+
environment_id text,
|
|
76
|
+
status text NOT NULL,
|
|
77
|
+
latency_ms integer,
|
|
78
|
+
result jsonb,
|
|
79
|
+
source_id text,
|
|
80
|
+
source_label text,
|
|
81
|
+
timestamp timestamp NOT NULL DEFAULT now()
|
|
82
|
+
)`,
|
|
83
|
+
);
|
|
84
|
+
pool = new Pool({
|
|
85
|
+
connectionString: PG_URL,
|
|
86
|
+
options: `-c search_path=${SCHEMA}`,
|
|
87
|
+
});
|
|
88
|
+
const db = drizzle(pool, {
|
|
89
|
+
schema,
|
|
90
|
+
}) as unknown as SafeDatabase<typeof schema>;
|
|
91
|
+
// registry / collectorRegistry are unused by the stats read under test;
|
|
92
|
+
// stub them so the constructor is satisfied.
|
|
93
|
+
service = new HealthCheckService(
|
|
94
|
+
db,
|
|
95
|
+
{} as unknown as HealthCheckRegistry,
|
|
96
|
+
{} as unknown as CollectorRegistry,
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
afterAll(async () => {
|
|
101
|
+
await pool?.end();
|
|
102
|
+
await admin.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`);
|
|
103
|
+
await admin.end();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
beforeEach(async () => {
|
|
107
|
+
await pool.query(`TRUNCATE "${SCHEMA}".health_check_runs`);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("returns per-system stats identical to getRunStats for the same window", async () => {
|
|
111
|
+
// sys-a: 3 healthy + 1 unhealthy; sys-b: 2 healthy; sys-c: no runs.
|
|
112
|
+
await insertRun({ systemId: "sys-a", status: "healthy", latencyMs: 10, at: "2026-06-01T01:00:00Z" });
|
|
113
|
+
await insertRun({ systemId: "sys-a", status: "healthy", latencyMs: 20, at: "2026-06-01T02:00:00Z" });
|
|
114
|
+
await insertRun({ systemId: "sys-a", status: "healthy", latencyMs: 30, at: "2026-06-01T03:00:00Z" });
|
|
115
|
+
await insertRun({ systemId: "sys-a", status: "unhealthy", latencyMs: 40, at: "2026-06-01T04:00:00Z" });
|
|
116
|
+
await insertRun({ systemId: "sys-b", status: "healthy", latencyMs: 5, at: "2026-06-01T05:00:00Z" });
|
|
117
|
+
await insertRun({ systemId: "sys-b", status: "healthy", latencyMs: 7, at: "2026-06-01T06:00:00Z" });
|
|
118
|
+
// A run OUTSIDE the window must be ignored by both endpoints.
|
|
119
|
+
await insertRun({ systemId: "sys-a", status: "unhealthy", at: "2026-05-01T00:00:00Z" });
|
|
120
|
+
|
|
121
|
+
const ids = ["sys-a", "sys-b", "sys-c"];
|
|
122
|
+
const stats = await service.getBulkRunStats({
|
|
123
|
+
systemIds: ids,
|
|
124
|
+
startDate: START,
|
|
125
|
+
endDate: END,
|
|
126
|
+
maxBuckets: 24,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
for (const systemId of ids) {
|
|
130
|
+
const single = await service.getRunStats({ systemId, startDate: START, endDate: END, maxBuckets: 24 });
|
|
131
|
+
if (single.total.runCount === 0) {
|
|
132
|
+
// Zero-run systems are omitted from the bulk record.
|
|
133
|
+
expect(stats[systemId]).toBeUndefined();
|
|
134
|
+
} else {
|
|
135
|
+
expect(stats[systemId]).toEqual(single);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// sys-c had no runs => omitted; only populated systems are keys.
|
|
140
|
+
expect(Object.keys(stats).toSorted()).toEqual(["sys-a", "sys-b"]);
|
|
141
|
+
// Sanity: sys-a uptime = 3/4 healthy = 75%.
|
|
142
|
+
expect(stats["sys-a"]?.total.uptimePct).toBe(75);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("scopes uptime to the selected environments (status-page env filter)", async () => {
|
|
146
|
+
// sys-a: prod 2 healthy; staging 1 healthy + 1 unhealthy; env-less 1
|
|
147
|
+
// unhealthy. A prod-only page must count ONLY the two prod runs (100%),
|
|
148
|
+
// never the staging or env-less runs.
|
|
149
|
+
await insertRun({ systemId: "sys-a", status: "healthy", environmentId: "env-prod", at: "2026-06-01T01:00:00Z" });
|
|
150
|
+
await insertRun({ systemId: "sys-a", status: "healthy", environmentId: "env-prod", at: "2026-06-01T02:00:00Z" });
|
|
151
|
+
await insertRun({ systemId: "sys-a", status: "healthy", environmentId: "env-stage", at: "2026-06-01T03:00:00Z" });
|
|
152
|
+
await insertRun({ systemId: "sys-a", status: "unhealthy", environmentId: "env-stage", at: "2026-06-01T04:00:00Z" });
|
|
153
|
+
await insertRun({ systemId: "sys-a", status: "unhealthy", environmentId: null, at: "2026-06-01T05:00:00Z" });
|
|
154
|
+
|
|
155
|
+
const prodOnly = await service.getBulkRunStats({
|
|
156
|
+
systemIds: ["sys-a"],
|
|
157
|
+
startDate: START,
|
|
158
|
+
endDate: END,
|
|
159
|
+
environmentIds: ["env-prod"],
|
|
160
|
+
maxBuckets: 24,
|
|
161
|
+
});
|
|
162
|
+
expect(prodOnly["sys-a"]?.total.runCount).toBe(2);
|
|
163
|
+
expect(prodOnly["sys-a"]?.total.uptimePct).toBe(100);
|
|
164
|
+
|
|
165
|
+
// No env filter counts every run (5 total, 3 healthy = 60%).
|
|
166
|
+
const all = await service.getBulkRunStats({
|
|
167
|
+
systemIds: ["sys-a"],
|
|
168
|
+
startDate: START,
|
|
169
|
+
endDate: END,
|
|
170
|
+
maxBuckets: 24,
|
|
171
|
+
});
|
|
172
|
+
expect(all["sys-a"]?.total.runCount).toBe(5);
|
|
173
|
+
expect(all["sys-a"]?.total.uptimePct).toBe(60);
|
|
174
|
+
|
|
175
|
+
// getRunStats honors the same set filter for the single-system uptime widget.
|
|
176
|
+
const single = await service.getRunStats({
|
|
177
|
+
systemId: "sys-a",
|
|
178
|
+
startDate: START,
|
|
179
|
+
endDate: END,
|
|
180
|
+
environmentIds: ["env-prod", "env-stage"],
|
|
181
|
+
maxBuckets: 24,
|
|
182
|
+
});
|
|
183
|
+
expect(single.total.runCount).toBe(4);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("returns an empty record for an empty request without querying", async () => {
|
|
187
|
+
expect(
|
|
188
|
+
await service.getBulkRunStats({
|
|
189
|
+
systemIds: [],
|
|
190
|
+
startDate: START,
|
|
191
|
+
endDate: END,
|
|
192
|
+
maxBuckets: 1,
|
|
193
|
+
}),
|
|
194
|
+
).toEqual({});
|
|
195
|
+
});
|
|
196
|
+
},
|
|
197
|
+
);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
|
2
2
|
import type { InferSelectModel } from "drizzle-orm";
|
|
3
|
+
import { withTransactionMock } from "@checkstack/test-utils-backend";
|
|
3
4
|
import { HealthCheckService } from "./service";
|
|
4
5
|
import {
|
|
5
6
|
healthCheckRuns,
|
|
@@ -64,6 +65,10 @@ describe("HealthCheckService data ordering", () => {
|
|
|
64
65
|
const whereMock = mock(() => ({
|
|
65
66
|
orderBy: orderByMock,
|
|
66
67
|
limit: mock(createLimitResult),
|
|
68
|
+
// getSystemHealthOverview's "last successful run" query terminates in
|
|
69
|
+
// `.where(...).groupBy(environmentId)` (a max-per-env aggregate). These
|
|
70
|
+
// ordering tests don't assert last-success, so return no groups.
|
|
71
|
+
groupBy: mock(() => Promise.resolve([])),
|
|
67
72
|
}));
|
|
68
73
|
const innerJoinMock = mock(() => ({
|
|
69
74
|
where: mock(() => Promise.resolve([...mockAssociations])),
|
|
@@ -74,10 +79,13 @@ describe("HealthCheckService data ordering", () => {
|
|
|
74
79
|
orderBy: orderByMock,
|
|
75
80
|
}));
|
|
76
81
|
|
|
77
|
-
|
|
82
|
+
// getSystemHealthOverview batches its reads in ONE scoped transaction, so
|
|
83
|
+
// the mock must expose `.transaction(cb)` (runs `cb` against the same mock
|
|
84
|
+
// db). getHistory/getDetailedHistory run standalone and ignore it.
|
|
85
|
+
return withTransactionMock({
|
|
78
86
|
select: mock(() => ({ from: fromMock })),
|
|
79
87
|
$count: mock(() => Promise.resolve(mockRuns.length)),
|
|
80
|
-
};
|
|
88
|
+
});
|
|
81
89
|
}
|
|
82
90
|
|
|
83
91
|
beforeEach(() => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
|
2
|
+
import { withTransactionMock } from "@checkstack/test-utils-backend";
|
|
2
3
|
import { HealthCheckService } from "./service";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -92,6 +93,12 @@ describe("HealthCheckService - paused configuration filtering", () => {
|
|
|
92
93
|
})),
|
|
93
94
|
});
|
|
94
95
|
|
|
96
|
+
// Distinct env keys query (rollup): resolves to no env slices for these
|
|
97
|
+
// fixtures (runs are empty), so the rollup has nothing to evaluate.
|
|
98
|
+
const distinctFrom = Object.assign(Promise.resolve([]), {
|
|
99
|
+
where: mock(() => Promise.resolve([])),
|
|
100
|
+
});
|
|
101
|
+
|
|
95
102
|
let selectCallCount = 0;
|
|
96
103
|
return {
|
|
97
104
|
select: mock(() => {
|
|
@@ -103,6 +110,7 @@ describe("HealthCheckService - paused configuration filtering", () => {
|
|
|
103
110
|
}
|
|
104
111
|
return { from: mock(() => runsFrom) };
|
|
105
112
|
}),
|
|
113
|
+
selectDistinct: mock(() => ({ from: mock(() => distinctFrom) })),
|
|
106
114
|
insert: mock(() => ({
|
|
107
115
|
values: mock(() => ({
|
|
108
116
|
onConflictDoUpdate: mock(() => Promise.resolve()),
|
|
@@ -143,7 +151,7 @@ describe("HealthCheckService - paused configuration filtering", () => {
|
|
|
143
151
|
|
|
144
152
|
const mockDb = createMockDb();
|
|
145
153
|
const service = new HealthCheckService(
|
|
146
|
-
mockDb as never,
|
|
154
|
+
withTransactionMock(mockDb) as never,
|
|
147
155
|
{} as never,
|
|
148
156
|
{} as never,
|
|
149
157
|
);
|
|
@@ -198,6 +206,11 @@ describe("HealthCheckService - paused configuration filtering", () => {
|
|
|
198
206
|
orderBy: runsOrderBy,
|
|
199
207
|
});
|
|
200
208
|
|
|
209
|
+
// Distinct env keys: a single env-less (null) slice for this env-less check.
|
|
210
|
+
const distinctFrom = Object.assign(Promise.resolve([]), {
|
|
211
|
+
where: mock(() => Promise.resolve([{ environmentId: null }])),
|
|
212
|
+
});
|
|
213
|
+
|
|
201
214
|
let selectCallCount = 0;
|
|
202
215
|
const mockDb = {
|
|
203
216
|
select: mock(() => {
|
|
@@ -207,6 +220,7 @@ describe("HealthCheckService - paused configuration filtering", () => {
|
|
|
207
220
|
}
|
|
208
221
|
return { from: mock(() => runsFrom) };
|
|
209
222
|
}),
|
|
223
|
+
selectDistinct: mock(() => ({ from: mock(() => distinctFrom) })),
|
|
210
224
|
insert: mock(() => ({
|
|
211
225
|
values: mock(() => ({
|
|
212
226
|
onConflictDoUpdate: mock(() => Promise.resolve()),
|
|
@@ -222,7 +236,7 @@ describe("HealthCheckService - paused configuration filtering", () => {
|
|
|
222
236
|
};
|
|
223
237
|
|
|
224
238
|
const service = new HealthCheckService(
|
|
225
|
-
mockDb as never,
|
|
239
|
+
withTransactionMock(mockDb) as never,
|
|
226
240
|
{} as never,
|
|
227
241
|
{} as never,
|
|
228
242
|
);
|
|
@@ -239,7 +253,7 @@ describe("HealthCheckService - paused configuration filtering", () => {
|
|
|
239
253
|
|
|
240
254
|
const mockDb = createMockDb();
|
|
241
255
|
const service = new HealthCheckService(
|
|
242
|
-
mockDb as never,
|
|
256
|
+
withTransactionMock(mockDb) as never,
|
|
243
257
|
{} as never,
|
|
244
258
|
{} as never,
|
|
245
259
|
);
|
|
@@ -265,7 +279,7 @@ describe("HealthCheckService - paused configuration filtering", () => {
|
|
|
265
279
|
};
|
|
266
280
|
|
|
267
281
|
const service = new HealthCheckService(
|
|
268
|
-
mockDb as never,
|
|
282
|
+
withTransactionMock(mockDb) as never,
|
|
269
283
|
{} as never,
|
|
270
284
|
{} as never,
|
|
271
285
|
);
|
|
@@ -285,7 +299,7 @@ describe("HealthCheckService - paused configuration filtering", () => {
|
|
|
285
299
|
};
|
|
286
300
|
|
|
287
301
|
const service = new HealthCheckService(
|
|
288
|
-
mockDb as never,
|
|
302
|
+
withTransactionMock(mockDb) as never,
|
|
289
303
|
{} as never,
|
|
290
304
|
{} as never,
|
|
291
305
|
);
|
|
@@ -340,7 +354,13 @@ describe("HealthCheckService - paused configuration filtering", () => {
|
|
|
340
354
|
|
|
341
355
|
const runsLimit = mock(() => Promise.resolve(emptyRuns));
|
|
342
356
|
const runsOrderBy = mock(() => ({ limit: runsLimit }));
|
|
343
|
-
|
|
357
|
+
// getSystemHealthOverview also runs a `.where(...).groupBy(env)` aggregate
|
|
358
|
+
// for the last-successful-run stamp; return no groups here.
|
|
359
|
+
const runsWhere = mock(() => ({
|
|
360
|
+
orderBy: runsOrderBy,
|
|
361
|
+
limit: runsLimit,
|
|
362
|
+
groupBy: mock(() => Promise.resolve([])),
|
|
363
|
+
}));
|
|
344
364
|
const runsFrom = Object.assign(Promise.resolve(emptyRuns), {
|
|
345
365
|
where: runsWhere,
|
|
346
366
|
orderBy: runsOrderBy,
|
|
@@ -370,7 +390,7 @@ describe("HealthCheckService - paused configuration filtering", () => {
|
|
|
370
390
|
};
|
|
371
391
|
|
|
372
392
|
const service = new HealthCheckService(
|
|
373
|
-
mockDb as never,
|
|
393
|
+
withTransactionMock(mockDb) as never,
|
|
374
394
|
{} as never,
|
|
375
395
|
{} as never,
|
|
376
396
|
);
|