@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +484 -0
  2. package/drizzle/0019_chemical_frightful_four.sql +8 -0
  3. package/drizzle/0020_certain_mordo.sql +2 -0
  4. package/drizzle/meta/0019_snapshot.json +661 -0
  5. package/drizzle/meta/0020_snapshot.json +711 -0
  6. package/drizzle/meta/_journal.json +14 -0
  7. package/package.json +23 -21
  8. package/src/ai/system-signals-contributor.test.ts +33 -9
  9. package/src/ai/system-signals-contributor.ts +38 -16
  10. package/src/cache-test-stub.ts +26 -0
  11. package/src/cache.test.ts +291 -0
  12. package/src/cache.ts +204 -34
  13. package/src/health-notification-content.test.ts +111 -0
  14. package/src/health-notification-content.ts +145 -0
  15. package/src/healthcheck-gitops-kinds.test.ts +14 -0
  16. package/src/healthcheck-gitops-kinds.ts +27 -0
  17. package/src/index.ts +31 -12
  18. package/src/queue-executor.test.ts +13 -26
  19. package/src/queue-executor.ts +125 -112
  20. package/src/retention-job.ts +8 -0
  21. package/src/rollup-consumer.test.ts +19 -8
  22. package/src/router-config-secrets.test.ts +2 -7
  23. package/src/router-create-and-assign.test.ts +2 -7
  24. package/src/router-pause-recompute.test.ts +2 -7
  25. package/src/router.test.ts +3 -8
  26. package/src/router.ts +43 -15
  27. package/src/schema.ts +74 -31
  28. package/src/service-batching.test.ts +8 -0
  29. package/src/service-bulk-counts.it.test.ts +144 -0
  30. package/src/service-bulk-run-stats.it.test.ts +197 -0
  31. package/src/service-ordering.test.ts +6 -2
  32. package/src/service-paused-filter.test.ts +13 -0
  33. package/src/service-rollup-worst-wins.test.ts +209 -145
  34. package/src/service.ts +408 -284
  35. package/src/status-fingerprint.test.ts +92 -0
  36. package/src/status-fingerprint.ts +66 -0
  37. package/src/status-page/rollup.test.ts +40 -0
  38. package/src/status-page/rollup.ts +27 -0
  39. package/src/status-page/widgets.test.ts +387 -0
  40. 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 type { HealthCheckCache } from "./cache";
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: HealthCheckCache = {
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 type { HealthCheckCache } from "./cache";
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: HealthCheckCache = {
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 type { HealthCheckCache } from "./cache";
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: HealthCheckCache = {
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,
@@ -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 type { HealthCheckCache } from "./cache";
7
-
8
- const passthroughCache: HealthCheckCache = {
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
- const base = await cache.wrapSystemHealthStatus(input.systemId, () =>
678
- service.getSystemHealthStatus(input.systemId),
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. See
692
- // ./cache.ts for the key/TTL/invalidation contract.
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 service.getBulkSystemHealthMatrix(input.systemIds);
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("health_check_runs", {
190
- id: uuid("id").primaryKey().defaultRandom(),
191
- configurationId: uuid("configuration_id")
192
- .notNull()
193
- .references(() => healthCheckConfigurations.id, { onDelete: "cascade" }),
194
- systemId: text("system_id").notNull(),
195
- /**
196
- * Environment this run was executed for (per-environment fan-out).
197
- * null = ran with no environment (the opt-out / no-membership case,
198
- * which is exactly the pre-feature behavior). Nullable text, NOT a FK
199
- * to the catalog `environments` table (healthcheck and catalog are
200
- * separate plugins with separate Postgres schemas, mirroring how
201
- * `systemId` is a bare text with no FK to `systems`).
202
- */
203
- environmentId: text("environment_id"),
204
- status: healthCheckStatusEnum("status").notNull(),
205
- /** Execution duration in milliseconds */
206
- latencyMs: integer("latency_ms"),
207
- result: jsonb("result").$type<Record<string, unknown>>(),
208
- /**
209
- * Source identifier for result attribution.
210
- * null = local core execution, UUID = satellite ID.
211
- */
212
- sourceId: text("source_id"),
213
- /**
214
- * Human-readable source label for UI display.
215
- * e.g. "Local" or "EU West (eu-west-1)".
216
- */
217
- sourceLabel: text("source_label"),
218
- timestamp: timestamp("timestamp").defaultNow().notNull(),
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
+ );