@checkstack/healthcheck-backend 1.18.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.
@@ -35,20 +35,17 @@ import {
35
35
  } from "@checkstack/healthcheck-common";
36
36
  import {
37
37
  CatalogApi,
38
- catalogRoutes,
39
- createSystemSubject,
40
38
  type Environment,
41
39
  } from "@checkstack/catalog-common";
42
40
  import {
43
41
  resolveEffectiveEnvironments,
44
42
  type EffectiveEnvironment,
45
43
  } from "./effective-environments";
46
- import { systemHealthCollapseKey } from "@checkstack/healthcheck-common";
44
+ import { buildHealthTransitionNotification } from "./health-notification-content";
47
45
  import { MaintenanceApi } from "@checkstack/maintenance-common";
48
46
  import { IncidentApi } from "@checkstack/incident-common";
49
47
  import { NotificationApi } from "@checkstack/notification-common";
50
- import { healthcheckSystemSubscription } from "@checkstack/healthcheck-common";
51
- import { resolveRoute, type InferClient, extractErrorMessage} from "@checkstack/common";
48
+ import { type InferClient, extractErrorMessage} from "@checkstack/common";
52
49
  import { secretEnvMappingSchema } from "@checkstack/secrets-common";
53
50
  import type {
54
51
  SecretResolverService,
@@ -465,6 +462,13 @@ async function notifyStateChange(props: {
465
462
  systemId: string;
466
463
  systemName: string;
467
464
  configurationId: string;
465
+ /**
466
+ * Human-readable name of the health check whose run drove this transition.
467
+ * Named in the body and surfaced as a `healthcheck.healthcheck` subject so
468
+ * subscribers see WHICH check failed, not just which system. Best-effort:
469
+ * falls back to the `configurationId` when the name could not be resolved.
470
+ */
471
+ configurationName?: string;
468
472
  previousStatus: HealthCheckStatus;
469
473
  newStatus: HealthCheckStatus;
470
474
  /**
@@ -493,6 +497,7 @@ async function notifyStateChange(props: {
493
497
  systemId,
494
498
  systemName,
495
499
  configurationId,
500
+ configurationName,
496
501
  previousStatus,
497
502
  newStatus,
498
503
  environmentId,
@@ -505,8 +510,9 @@ async function notifyStateChange(props: {
505
510
  logger,
506
511
  } = props;
507
512
 
508
- const envScoped = typeof environmentId === "string";
509
- const envSuffix = envScoped && environmentName ? ` (${environmentName})` : "";
513
+ // The check that just ran is the one driving this aggregate transition, so
514
+ // its name is the authoritative check to blame. Fall back to the id.
515
+ const checkName = configurationName ?? configurationId;
510
516
 
511
517
  const transition = classifyTransition(previousStatus, newStatus);
512
518
  if (transition === "none") {
@@ -572,70 +578,24 @@ async function notifyStateChange(props: {
572
578
  );
573
579
  }
574
580
 
575
- let title: string;
576
- let body: string;
577
- let importance: "info" | "warning" | "critical";
578
-
579
- if (transition === "recovery") {
580
- title = `System health restored${envSuffix}: ${systemName}`;
581
- body = envScoped
582
- ? `Health checks for **${systemName}** in environment **${environmentName ?? environmentId}** are now passing. The system has returned to normal operation in that environment.`
583
- : `All health checks for **${systemName}** are now passing. The system has returned to normal operation.`;
584
- importance = "info";
585
- } else if (newStatus === "unhealthy") {
586
- title = `System health critical${envSuffix}: ${systemName}`;
587
- body = envScoped
588
- ? `Health checks indicate **${systemName}** is unhealthy in environment **${environmentName ?? environmentId}** and may be down in that environment.`
589
- : `Health checks indicate **${systemName}** is unhealthy and may be down.`;
590
- importance = "critical";
591
- } else {
592
- // degraded — either an escalation from healthy or a partial recovery
593
- title = `System health degraded${envSuffix}: ${systemName}`;
594
- body = envScoped
595
- ? `Some health checks for **${systemName}** in environment **${environmentName ?? environmentId}** are failing. That environment may be experiencing issues.`
596
- : `Some health checks for **${systemName}** are failing. The system may be experiencing issues.`;
597
- importance = "warning";
598
- }
599
-
600
- const systemDetailPath = resolveRoute(catalogRoutes.routes.systemDetail, {
601
- systemId,
602
- });
603
- // Recovery lands on the default (all) view; failing transitions deep-link
604
- // operators into the failing-checks filter so they can debug immediately.
605
- const actionUrl =
606
- transition === "recovery"
607
- ? systemDetailPath
608
- : `${systemDetailPath}?filter=failing`;
609
- const actionLabel =
610
- transition === "recovery" ? "View System" : "View failing checks";
611
-
612
581
  void catalogClient; // parents are resolved server-side via stored target edges
613
582
 
614
583
  try {
615
- await notificationClient.notifyForSubscription({
616
- specId: healthcheckSystemSubscription.specId,
617
- resourceKeys: [systemId],
618
- title,
619
- body,
620
- importance,
621
- action: { label: actionLabel, url: actionUrl },
622
- // Env-qualified collapse key so two failing envs of one system generate
623
- // two independent notification cards (one per env) instead of merging
624
- // -> operators see all env outages. The system-rollup transition
625
- // (`environmentId === null`/undefined) keys on the bare systemId and
626
- // therefore reuses the pre-existing single-card identity.
627
- collapseKey: envScoped
628
- ? systemHealthCollapseKey(systemId, environmentId)
629
- : systemHealthCollapseKey(systemId),
630
- subjects: [
631
- createSystemSubject({
632
- id: systemId,
633
- name: systemName,
634
- url: systemDetailPath,
635
- status: newStatus,
636
- }),
637
- ],
638
- });
584
+ // Content (title/body/subjects/collapseKey) is built by a pure, unit-tested
585
+ // helper so the wording - which now NAMES the failing check and pushes a
586
+ // `healthcheck.healthcheck` subject - can be verified without the executor.
587
+ await notificationClient.notifyForSubscription(
588
+ buildHealthTransitionNotification({
589
+ transition,
590
+ systemId,
591
+ systemName,
592
+ configurationId,
593
+ checkName,
594
+ newStatus,
595
+ environmentId,
596
+ environmentName,
597
+ }),
598
+ );
639
599
  logger.debug(
640
600
  `Notified subscribers: ${previousStatus} → ${newStatus} for system ${systemId}`,
641
601
  );
@@ -1390,6 +1350,7 @@ async function executeHealthCheckJob(props: {
1390
1350
  systemId,
1391
1351
  systemName,
1392
1352
  configurationId: configId,
1353
+ configurationName: configRow.configName,
1393
1354
  previousStatus,
1394
1355
  newStatus: newState.status,
1395
1356
  environmentId,
@@ -1557,6 +1518,7 @@ async function executeHealthCheckJob(props: {
1557
1518
  systemId,
1558
1519
  systemName,
1559
1520
  configurationId: configId,
1521
+ configurationName: configRow.configName,
1560
1522
  previousStatus,
1561
1523
  newStatus: newState.status,
1562
1524
  environmentId,
@@ -1723,6 +1685,7 @@ async function executeHealthCheckJob(props: {
1723
1685
  systemId,
1724
1686
  systemName,
1725
1687
  configurationId: configId,
1688
+ configurationName: configName,
1726
1689
  previousStatus,
1727
1690
  newStatus: newState.status,
1728
1691
  service,
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
@@ -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
+ );
@@ -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,
@@ -78,10 +79,13 @@ describe("HealthCheckService data ordering", () => {
78
79
  orderBy: orderByMock,
79
80
  }));
80
81
 
81
- return {
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({
82
86
  select: mock(() => ({ from: fromMock })),
83
87
  $count: mock(() => Promise.resolve(mockRuns.length)),
84
- };
88
+ });
85
89
  }
86
90
 
87
91
  beforeEach(() => {
@@ -93,6 +93,12 @@ describe("HealthCheckService - paused configuration filtering", () => {
93
93
  })),
94
94
  });
95
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
+
96
102
  let selectCallCount = 0;
97
103
  return {
98
104
  select: mock(() => {
@@ -104,6 +110,7 @@ describe("HealthCheckService - paused configuration filtering", () => {
104
110
  }
105
111
  return { from: mock(() => runsFrom) };
106
112
  }),
113
+ selectDistinct: mock(() => ({ from: mock(() => distinctFrom) })),
107
114
  insert: mock(() => ({
108
115
  values: mock(() => ({
109
116
  onConflictDoUpdate: mock(() => Promise.resolve()),
@@ -199,6 +206,11 @@ describe("HealthCheckService - paused configuration filtering", () => {
199
206
  orderBy: runsOrderBy,
200
207
  });
201
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
+
202
214
  let selectCallCount = 0;
203
215
  const mockDb = {
204
216
  select: mock(() => {
@@ -208,6 +220,7 @@ describe("HealthCheckService - paused configuration filtering", () => {
208
220
  }
209
221
  return { from: mock(() => runsFrom) };
210
222
  }),
223
+ selectDistinct: mock(() => ({ from: mock(() => distinctFrom) })),
211
224
  insert: mock(() => ({
212
225
  values: mock(() => ({
213
226
  onConflictDoUpdate: mock(() => Promise.resolve()),