@checkstack/healthcheck-backend 1.2.0 → 1.3.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.
@@ -72,6 +72,7 @@ const createMockCatalogClient = () => ({
72
72
  // Other methods not used in queue-executor
73
73
  getEntities: mock(async () => ({ systems: [], groups: [] })),
74
74
  getSystems: mock(async () => ({ systems: [] })),
75
+ getSystem: mock(async () => null),
75
76
  getGroups: mock(async () => []),
76
77
  createSystem: mock(async () => ({})),
77
78
  updateSystem: mock(async () => ({})),
@@ -415,4 +416,140 @@ describe("Queue-Based Health Check Executor", () => {
415
416
  expect(mockSignalService.getRecordedSignals()).toHaveLength(0);
416
417
  });
417
418
  });
419
+
420
+ describe("executeHealthCheckJob - collector run-context", () => {
421
+ it("passes curated run-context to the collector (name falls back to id when configName is null)", async () => {
422
+ const mockDb = createMockDb();
423
+ const mockRegistry = createMockRegistry();
424
+ const mockLogger = createMockLogger();
425
+ const mockQueueManager = createMockQueueManager();
426
+ const mockCatalogClient = createMockCatalogClient();
427
+ const mockMaintenanceClient = createMockMaintenanceClient();
428
+ const mockIncidentClient = createMockIncidentClient();
429
+ const mockSignalService = createMockSignalService();
430
+
431
+ // Catalog resolves the system name.
432
+ (mockCatalogClient.getSystem as any) = mock(async () => ({
433
+ id: "system-1",
434
+ name: "web-01",
435
+ }));
436
+
437
+ // configName is null -> run-context check.name must fall back to id.
438
+ let selectCallCount = 0;
439
+ (mockDb.select as any) = mock(() => {
440
+ selectCallCount++;
441
+ if (selectCallCount === 2) {
442
+ return {
443
+ from: mock(() => ({
444
+ innerJoin: mock(() => ({
445
+ where: mock(() =>
446
+ Promise.resolve([
447
+ {
448
+ configId: "config-1",
449
+ configName: null,
450
+ strategyId: "test-strategy",
451
+ config: { timeout: 5000 },
452
+ collectors: [
453
+ { id: "col-1", collectorId: "test-collector", config: {} },
454
+ ],
455
+ interval: 45,
456
+ enabled: true,
457
+ paused: false,
458
+ includeLocal: true,
459
+ satelliteIds: [],
460
+ },
461
+ ]),
462
+ ),
463
+ })),
464
+ })),
465
+ };
466
+ }
467
+ return {
468
+ from: mock(() => ({
469
+ innerJoin: mock(() => ({
470
+ where: mock(() => Promise.resolve([])),
471
+ })),
472
+ })),
473
+ };
474
+ });
475
+
476
+ // Capture the run-context the collector receives.
477
+ let capturedRunContext: unknown;
478
+ const collectorExecute = mock(
479
+ async (params: { runContext?: unknown }) => {
480
+ capturedRunContext = params.runContext;
481
+ return { result: {} };
482
+ },
483
+ );
484
+ const mockCollectorRegistry = {
485
+ register: mock(() => {}),
486
+ getCollector: mock(() => ({
487
+ collector: {
488
+ id: "test-collector",
489
+ execute: collectorExecute,
490
+ mergeResult: mock(() => ({})),
491
+ },
492
+ })),
493
+ getCollectors: mock(() => []),
494
+ };
495
+
496
+ const queue =
497
+ mockQueueManager.getQueue<HealthCheckJobPayload>("health-checks");
498
+ let capturedHandler:
499
+ | ((job: { data: HealthCheckJobPayload }) => Promise<void>)
500
+ | undefined;
501
+ (queue.consume as any) = mock(
502
+ async (
503
+ handler: (job: { data: HealthCheckJobPayload }) => Promise<void>,
504
+ ) => {
505
+ capturedHandler = handler;
506
+ },
507
+ );
508
+
509
+ await setupHealthCheckWorker({
510
+ db: mockDb as unknown as Parameters<
511
+ typeof setupHealthCheckWorker
512
+ >[0]["db"],
513
+ registry: mockRegistry,
514
+ collectorRegistry: mockCollectorRegistry as unknown as Parameters<
515
+ typeof setupHealthCheckWorker
516
+ >[0]["collectorRegistry"],
517
+ logger: mockLogger,
518
+ queueManager: mockQueueManager,
519
+ signalService: mockSignalService,
520
+ catalogClient: mockCatalogClient as unknown as Parameters<
521
+ typeof setupHealthCheckWorker
522
+ >[0]["catalogClient"],
523
+ notificationClient: {
524
+ notifyForSubscription: () => Promise.resolve({ notifiedCount: 0 }),
525
+ } as unknown as Parameters<
526
+ typeof setupHealthCheckWorker
527
+ >[0]["notificationClient"],
528
+ maintenanceClient: mockMaintenanceClient as unknown as Parameters<
529
+ typeof setupHealthCheckWorker
530
+ >[0]["maintenanceClient"],
531
+ incidentClient: mockIncidentClient as unknown as Parameters<
532
+ typeof setupHealthCheckWorker
533
+ >[0]["incidentClient"],
534
+ getEmitHook: () => undefined,
535
+ cache: passthroughCache,
536
+ });
537
+
538
+ if (capturedHandler) {
539
+ // The collector runs early in the execution sequence; downstream
540
+ // aggregation/persistence touches DB surfaces the lightweight mock
541
+ // doesn't model, so tolerate a later throw — the run-context we
542
+ // assert on is captured synchronously at collector-execute time.
543
+ await capturedHandler({
544
+ data: { configId: "config-1", systemId: "system-1" },
545
+ }).catch(() => {});
546
+ }
547
+
548
+ expect(collectorExecute).toHaveBeenCalled();
549
+ expect(capturedRunContext).toEqual({
550
+ check: { id: "config-1", name: "config-1", intervalSeconds: 45 },
551
+ system: { id: "system-1", name: "web-01" },
552
+ });
553
+ });
554
+ });
418
555
  });
@@ -8,6 +8,7 @@ import {
8
8
  type BaseStrategyConfig,
9
9
  type ConnectedClient,
10
10
  type TransportClient,
11
+ type CollectorRunContext,
11
12
  } from "@checkstack/backend-api";
12
13
  import { QueueManager } from "@checkstack/queue-api";
13
14
  import {
@@ -62,8 +63,13 @@ type IncidentClient = InferClient<typeof IncidentApi>;
62
63
  type NotificationClient = InferClient<typeof NotificationApi>;
63
64
 
64
65
  /**
65
- * Emit the checkCompleted hook if available.
66
- * Extracted to avoid duplicating the hook emission pattern across success/error paths.
66
+ * Emit the checkCompleted hook if available, plus the narrower
67
+ * `checkFailed` hook when the result wasn't `healthy` (so operators
68
+ * can wire a typed "trigger on failure" automation without having to
69
+ * filter `checkCompleted` themselves).
70
+ *
71
+ * Extracted to avoid duplicating the hook emission pattern across
72
+ * success/error paths.
67
73
  */
68
74
  async function emitCheckCompletedHook({
69
75
  getEmitHook,
@@ -81,14 +87,26 @@ async function emitCheckCompletedHook({
81
87
  result: Record<string, unknown> | undefined;
82
88
  }): Promise<void> {
83
89
  const emitHook = getEmitHook();
84
- if (emitHook) {
85
- await emitHook(healthCheckHooks.checkCompleted, {
90
+ if (!emitHook) return;
91
+ const timestamp = new Date().toISOString();
92
+ await emitHook(healthCheckHooks.checkCompleted, {
93
+ systemId,
94
+ configurationId,
95
+ status,
96
+ latencyMs,
97
+ result,
98
+ timestamp,
99
+ });
100
+ // Narrow follow-up — informational for automation triggers; the
101
+ // auto-incident pipeline still runs on its own thresholds.
102
+ if (status !== "healthy") {
103
+ await emitHook(healthCheckHooks.checkFailed, {
86
104
  systemId,
87
105
  configurationId,
88
106
  status,
89
107
  latencyMs,
90
108
  result,
91
- timestamp: new Date().toISOString(),
109
+ timestamp,
92
110
  });
93
111
  }
94
112
  }
@@ -102,9 +120,11 @@ export interface HealthCheckJobPayload {
102
120
  }
103
121
 
104
122
  /**
105
- * Queue name for health check execution
123
+ * Queue name for health check execution. Exported so consumers like
124
+ * the `healthcheck.run_now` automation action can enqueue a one-off
125
+ * job without re-importing the recurring-job factory.
106
126
  */
107
- const HEALTH_CHECK_QUEUE = "health-checks";
127
+ export const HEALTH_CHECK_QUEUE = "health-checks";
108
128
 
109
129
  /**
110
130
  * Worker group for health check execution (work-queue mode)
@@ -179,6 +199,14 @@ async function maybeOpenAutoIncidentForCheck(props: {
179
199
  systemName: string;
180
200
  configurationId: string;
181
201
  configurationName: string;
202
+ /**
203
+ * Same closure-based getter the queue executor uses elsewhere; let
204
+ * us fire the `flapping_detected` automation hook from inside the
205
+ * flapping evaluator without re-threading `emitHook` through every
206
+ * intermediate caller. Optional — when absent, the hook simply
207
+ * doesn't fire (e.g. in unit tests that don't care about it).
208
+ */
209
+ getEmitHook?: () => EmitHookFn | undefined;
182
210
  previousState: {
183
211
  checkStatuses: Array<{
184
212
  configurationId: string;
@@ -202,6 +230,7 @@ async function maybeOpenAutoIncidentForCheck(props: {
202
230
  systemName,
203
231
  configurationId,
204
232
  configurationName,
233
+ getEmitHook,
205
234
  previousState,
206
235
  newState,
207
236
  } = props;
@@ -287,6 +316,33 @@ async function maybeOpenAutoIncidentForCheck(props: {
287
316
  policy,
288
317
  recentTransitionCount: count,
289
318
  });
319
+
320
+ // Fire the informational `flapping_detected` automation hook
321
+ // independently of the auto-incident decision: an operator may
322
+ // care about flapping even with the auto-incident pipeline
323
+ // turned off.
324
+ if (
325
+ policy.flappingTrigger.enabled &&
326
+ count >= policy.flappingTrigger.transitions
327
+ ) {
328
+ const emit = getEmitHook?.();
329
+ if (emit) {
330
+ try {
331
+ await emit(healthCheckHooks.flappingDetected, {
332
+ systemId,
333
+ configurationId,
334
+ transitionCount: count,
335
+ windowMinutes: policy.flappingTrigger.windowMinutes,
336
+ timestamp: new Date().toISOString(),
337
+ });
338
+ } catch (error) {
339
+ logger.warn(
340
+ `Failed to emit healthcheck.flapping_detected hook for ${systemId}/${configurationId}:`,
341
+ error,
342
+ );
343
+ }
344
+ }
345
+ }
290
346
  } catch (error) {
291
347
  logger.warn(
292
348
  `Failed to record unhealthy transition for ${systemId}/${configurationId}:`,
@@ -612,6 +668,17 @@ async function executeHealthCheckJob(props: {
612
668
  logger.debug(`Could not fetch system name for ${systemId}, using ID`);
613
669
  }
614
670
 
671
+ // Curated, read-only run-context metadata exposed to collectors.
672
+ // Metadata only - never secrets or config.
673
+ const runContext: CollectorRunContext = {
674
+ check: {
675
+ id: configId,
676
+ name: configRow.configName || configId,
677
+ intervalSeconds: configRow.interval,
678
+ },
679
+ system: { id: systemId, name: systemName },
680
+ };
681
+
615
682
  const strategy = registry.getStrategy(configRow.strategyId);
616
683
  if (!strategy) {
617
684
  logger.warn(
@@ -662,6 +729,7 @@ async function executeHealthCheckJob(props: {
662
729
  config: collectorEntry.config,
663
730
  client: connectedClient!.client,
664
731
  pluginId: configRow.strategyId,
732
+ runContext,
665
733
  });
666
734
 
667
735
  // Check for collector-level error
@@ -861,6 +929,7 @@ async function executeHealthCheckJob(props: {
861
929
  systemName,
862
930
  configurationId: configId,
863
931
  configurationName: configRow.configName,
932
+ getEmitHook,
864
933
  previousState,
865
934
  newState,
866
935
  });
@@ -971,16 +1040,20 @@ async function executeHealthCheckJob(props: {
971
1040
  // Emit integration hooks for external integrations
972
1041
  const emitHook = getEmitHook();
973
1042
  if (emitHook) {
1043
+ const healthyChecks = newState.checkStatuses.filter(
1044
+ (c) => c.status === "healthy",
1045
+ ).length;
1046
+ const totalChecks = newState.checkStatuses.length;
1047
+ const timestamp = new Date().toISOString();
1048
+
974
1049
  if (newState.status === "healthy" && previousStatus !== "healthy") {
975
1050
  // Recovery: system became healthy
976
1051
  await emitHook(healthCheckHooks.systemHealthy, {
977
1052
  systemId,
978
1053
  previousStatus,
979
- healthyChecks: newState.checkStatuses.filter(
980
- (c) => c.status === "healthy",
981
- ).length,
982
- totalChecks: newState.checkStatuses.length,
983
- timestamp: new Date().toISOString(),
1054
+ healthyChecks,
1055
+ totalChecks,
1056
+ timestamp,
984
1057
  });
985
1058
  logger.debug(
986
1059
  `Emitted systemHealthy hook: ${previousStatus} → ${newState.status}`,
@@ -994,16 +1067,28 @@ async function executeHealthCheckJob(props: {
994
1067
  systemId,
995
1068
  previousStatus,
996
1069
  newStatus: newState.status,
997
- healthyChecks: newState.checkStatuses.filter(
998
- (c) => c.status === "healthy",
999
- ).length,
1000
- totalChecks: newState.checkStatuses.length,
1001
- timestamp: new Date().toISOString(),
1070
+ healthyChecks,
1071
+ totalChecks,
1072
+ timestamp,
1002
1073
  });
1003
1074
  logger.debug(
1004
1075
  `Emitted systemDegraded hook: ${previousStatus} → ${newState.status}`,
1005
1076
  );
1006
1077
  }
1078
+
1079
+ // Umbrella hook — fires on every transition. Emitted alongside
1080
+ // the directional hooks so existing subscribers stay unchanged
1081
+ // while new automation triggers can react to any change.
1082
+ if (previousStatus !== newState.status) {
1083
+ await emitHook(healthCheckHooks.systemHealthChanged, {
1084
+ systemId,
1085
+ previousStatus,
1086
+ newStatus: newState.status,
1087
+ healthyChecks,
1088
+ totalChecks,
1089
+ timestamp,
1090
+ });
1091
+ }
1007
1092
  }
1008
1093
  }
1009
1094
 
@@ -1018,6 +1103,7 @@ async function executeHealthCheckJob(props: {
1018
1103
  systemName,
1019
1104
  configurationId: configId,
1020
1105
  configurationName: configRow.configName,
1106
+ getEmitHook,
1021
1107
  previousState,
1022
1108
  newState,
1023
1109
  });
@@ -1120,16 +1206,20 @@ async function executeHealthCheckJob(props: {
1120
1206
  // Emit integration hooks for external integrations
1121
1207
  const emitHook = getEmitHook();
1122
1208
  if (emitHook) {
1209
+ const healthyChecks = newState.checkStatuses.filter(
1210
+ (c) => c.status === "healthy",
1211
+ ).length;
1212
+ const totalChecks = newState.checkStatuses.length;
1213
+ const timestamp = new Date().toISOString();
1214
+
1123
1215
  if (newState.status === "healthy" && previousStatus !== "healthy") {
1124
1216
  // Recovery: system became healthy
1125
1217
  await emitHook(healthCheckHooks.systemHealthy, {
1126
1218
  systemId,
1127
1219
  previousStatus,
1128
- healthyChecks: newState.checkStatuses.filter(
1129
- (c) => c.status === "healthy",
1130
- ).length,
1131
- totalChecks: newState.checkStatuses.length,
1132
- timestamp: new Date().toISOString(),
1220
+ healthyChecks,
1221
+ totalChecks,
1222
+ timestamp,
1133
1223
  });
1134
1224
  logger.debug(
1135
1225
  `Emitted systemHealthy hook: ${previousStatus} → ${newState.status}`,
@@ -1143,16 +1233,28 @@ async function executeHealthCheckJob(props: {
1143
1233
  systemId,
1144
1234
  previousStatus,
1145
1235
  newStatus: newState.status,
1146
- healthyChecks: newState.checkStatuses.filter(
1147
- (c) => c.status === "healthy",
1148
- ).length,
1149
- totalChecks: newState.checkStatuses.length,
1150
- timestamp: new Date().toISOString(),
1236
+ healthyChecks,
1237
+ totalChecks,
1238
+ timestamp,
1151
1239
  });
1152
1240
  logger.debug(
1153
1241
  `Emitted systemDegraded hook: ${previousStatus} → ${newState.status}`,
1154
1242
  );
1155
1243
  }
1244
+
1245
+ // Umbrella hook — fires on every transition. Emitted alongside
1246
+ // the directional hooks so existing subscribers stay unchanged
1247
+ // while new automation triggers can react to any change.
1248
+ if (previousStatus !== newState.status) {
1249
+ await emitHook(healthCheckHooks.systemHealthChanged, {
1250
+ systemId,
1251
+ previousStatus,
1252
+ newStatus: newState.status,
1253
+ healthyChecks,
1254
+ totalChecks,
1255
+ timestamp,
1256
+ });
1257
+ }
1156
1258
  }
1157
1259
  }
1158
1260
 
@@ -1167,6 +1269,7 @@ async function executeHealthCheckJob(props: {
1167
1269
  systemName,
1168
1270
  configurationId: configId,
1169
1271
  configurationName: configName,
1272
+ getEmitHook,
1170
1273
  previousState,
1171
1274
  newState,
1172
1275
  });
@@ -68,6 +68,10 @@ describe("HealthCheck Router", () => {
68
68
  getRedacted: mock(async () => undefined),
69
69
  };
70
70
 
71
+ const mockCatalogClient = {
72
+ getSystem: mock(async () => null),
73
+ };
74
+
71
75
  const router = createHealthCheckRouter({
72
76
  database: mockDb as never,
73
77
  registry: mockRegistry,
@@ -76,6 +80,7 @@ describe("HealthCheck Router", () => {
76
80
  getEmitHook: () => undefined,
77
81
  cache: passthroughCache,
78
82
  configService: mockConfigService as never,
83
+ catalogClient: mockCatalogClient as never,
79
84
  });
80
85
 
81
86
  it("getStrategies returns strategies from registry", async () => {
package/src/router.ts CHANGED
@@ -17,6 +17,7 @@ import * as schema from "./schema";
17
17
  import { toJsonSchemaWithChartMeta } from "./schema-utils";
18
18
  import type { InferClient } from "@checkstack/common";
19
19
  import { GitOpsApi } from "@checkstack/gitops-common";
20
+ import { CatalogApi } from "@checkstack/catalog-common";
20
21
  import type { HealthCheckCache } from "./cache";
21
22
 
22
23
  /**
@@ -33,14 +34,24 @@ export const createHealthCheckRouter = (opts: {
33
34
  getEmitHook: () => ((hook: { id: string }, payload: Record<string, unknown>) => Promise<void>) | undefined;
34
35
  cache: HealthCheckCache;
35
36
  configService: ConfigService;
37
+ catalogClient: InferClient<typeof CatalogApi>;
36
38
  }) => {
37
- const { database, registry, collectorRegistry, getEmitHook, cache, configService } = opts;
39
+ const {
40
+ database,
41
+ registry,
42
+ collectorRegistry,
43
+ getEmitHook,
44
+ cache,
45
+ configService,
46
+ catalogClient,
47
+ } = opts;
38
48
  // Create service instance once - shared across all handlers
39
49
  const service = new HealthCheckService(
40
50
  database,
41
51
  registry,
42
52
  collectorRegistry,
43
53
  configService,
54
+ catalogClient,
44
55
  );
45
56
 
46
57
  // Create contract implementer with context type AND auto auth middleware
@@ -0,0 +1,184 @@
1
+ import { describe, it, expect, mock, beforeEach } from "bun:test";
2
+ import { HealthCheckService } from "./service";
3
+
4
+ /**
5
+ * Tests for getAssignmentsForSatellite run-context population:
6
+ * - assignments carry configName (from the config row's name)
7
+ * - systemName resolves via the optional catalog client, falling back to
8
+ * systemId when no client is wired or the lookup fails.
9
+ */
10
+ describe("HealthCheckService.getAssignmentsForSatellite", () => {
11
+ const SATELLITE_ID = "sat-1";
12
+
13
+ type Association = {
14
+ systemId: string;
15
+ configurationId: string;
16
+ satelliteIds: string[] | null;
17
+ enabled: boolean;
18
+ };
19
+
20
+ type Config = {
21
+ id: string;
22
+ name: string;
23
+ strategyId: string;
24
+ config: Record<string, unknown>;
25
+ collectors: unknown[] | null;
26
+ intervalSeconds: number;
27
+ paused: boolean;
28
+ };
29
+
30
+ let associations: Association[] = [];
31
+ let configs: Config[] = [];
32
+
33
+ /**
34
+ * Mock db: the method issues two distinct select shapes:
35
+ * - associations: .select({...}).from(systemHealthChecks) -> awaited array
36
+ * - config: .select().from(...).where(...) -> awaited array
37
+ * We disambiguate by call order: the first select() resolves associations,
38
+ * subsequent select().from().where() resolve a single matching config.
39
+ */
40
+ function createMockDb() {
41
+ let firstSelect = true;
42
+ return {
43
+ select: mock(() => {
44
+ if (firstSelect) {
45
+ firstSelect = false;
46
+ return {
47
+ from: mock(() => Promise.resolve([...associations])),
48
+ };
49
+ }
50
+ return {
51
+ from: mock(() => ({
52
+ where: mock(() => {
53
+ // Return the next unmatched config in order; the loop fetches
54
+ // one config per matching association.
55
+ return Promise.resolve(configs.length > 0 ? [configs[0]] : []);
56
+ }),
57
+ })),
58
+ };
59
+ }),
60
+ };
61
+ }
62
+
63
+ beforeEach(() => {
64
+ associations = [];
65
+ configs = [];
66
+ });
67
+
68
+ it("populates configName and resolves systemName via the catalog client", async () => {
69
+ associations = [
70
+ {
71
+ systemId: "system-1",
72
+ configurationId: "config-1",
73
+ satelliteIds: [SATELLITE_ID],
74
+ enabled: true,
75
+ },
76
+ ];
77
+ configs = [
78
+ {
79
+ id: "config-1",
80
+ name: "API health",
81
+ strategyId: "http",
82
+ config: { url: "https://example.com" },
83
+ collectors: null,
84
+ intervalSeconds: 60,
85
+ paused: false,
86
+ },
87
+ ];
88
+
89
+ const getSystem = mock(() =>
90
+ Promise.resolve({ id: "system-1", name: "Production API" }),
91
+ );
92
+ const catalogClient = { getSystem } as never;
93
+
94
+ const mockDb = createMockDb();
95
+ const service = new HealthCheckService(
96
+ mockDb as never,
97
+ {} as never,
98
+ {} as never,
99
+ undefined,
100
+ catalogClient,
101
+ );
102
+
103
+ const result = await service.getAssignmentsForSatellite(SATELLITE_ID);
104
+
105
+ expect(result).toHaveLength(1);
106
+ expect(result[0].configName).toBe("API health");
107
+ expect(result[0].systemName).toBe("Production API");
108
+ expect(getSystem).toHaveBeenCalledWith({ systemId: "system-1" });
109
+ });
110
+
111
+ it("falls back to systemId when no catalog client is provided", async () => {
112
+ associations = [
113
+ {
114
+ systemId: "system-1",
115
+ configurationId: "config-1",
116
+ satelliteIds: [SATELLITE_ID],
117
+ enabled: true,
118
+ },
119
+ ];
120
+ configs = [
121
+ {
122
+ id: "config-1",
123
+ name: "API health",
124
+ strategyId: "http",
125
+ config: {},
126
+ collectors: null,
127
+ intervalSeconds: 30,
128
+ paused: false,
129
+ },
130
+ ];
131
+
132
+ const mockDb = createMockDb();
133
+ const service = new HealthCheckService(
134
+ mockDb as never,
135
+ {} as never,
136
+ {} as never,
137
+ );
138
+
139
+ const result = await service.getAssignmentsForSatellite(SATELLITE_ID);
140
+
141
+ expect(result).toHaveLength(1);
142
+ expect(result[0].configName).toBe("API health");
143
+ expect(result[0].systemName).toBe("system-1");
144
+ });
145
+
146
+ it("falls back to systemId when the catalog lookup throws", async () => {
147
+ associations = [
148
+ {
149
+ systemId: "system-1",
150
+ configurationId: "config-1",
151
+ satelliteIds: [SATELLITE_ID],
152
+ enabled: true,
153
+ },
154
+ ];
155
+ configs = [
156
+ {
157
+ id: "config-1",
158
+ name: "API health",
159
+ strategyId: "http",
160
+ config: {},
161
+ collectors: null,
162
+ intervalSeconds: 60,
163
+ paused: false,
164
+ },
165
+ ];
166
+
167
+ const getSystem = mock(() => Promise.reject(new Error("catalog down")));
168
+ const catalogClient = { getSystem } as never;
169
+
170
+ const mockDb = createMockDb();
171
+ const service = new HealthCheckService(
172
+ mockDb as never,
173
+ {} as never,
174
+ {} as never,
175
+ undefined,
176
+ catalogClient,
177
+ );
178
+
179
+ const result = await service.getAssignmentsForSatellite(SATELLITE_ID);
180
+
181
+ expect(result).toHaveLength(1);
182
+ expect(result[0].systemName).toBe("system-1");
183
+ });
184
+ });