@checkstack/incident-backend 1.2.0 → 1.4.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.
@@ -0,0 +1,523 @@
1
+ /**
2
+ * Behaviour tests for the incident automation actions. Triggers don't
3
+ * need their own tests — they're plain shape declarations against the
4
+ * existing hooks (`incidentHooks`) and the registry tests in
5
+ * `core/automation-backend` cover registration validity.
6
+ */
7
+ import { describe, it, expect, mock } from "bun:test";
8
+ import { SYSTEM_ACTOR } from "@checkstack/common";
9
+ import { createMockLogger } from "@checkstack/test-utils-backend";
10
+
11
+ import { createIncidentActions } from "./automations";
12
+ import {
13
+ deriveIncidentTriggerEvents,
14
+ INCIDENT_TRIGGER_EVENTS,
15
+ } from "./incident-entity";
16
+ import type { IncidentService } from "./service";
17
+
18
+ /**
19
+ * A default existing incident the stubbed `getIncident` returns. The
20
+ * status-flipping actions (resolve / add_update / update_status) now route
21
+ * through the reactive entity, which re-reads post-write state via
22
+ * `getIncident`, so the stub must answer it.
23
+ */
24
+ const DEFAULT_INCIDENT = {
25
+ id: "INC-1",
26
+ status: "investigating" as const,
27
+ severity: "critical" as const,
28
+ systemIds: ["sys-1"],
29
+ };
30
+
31
+ const makeServiceStub = (overrides: Partial<IncidentService> = {}) =>
32
+ ({
33
+ createIncident: mock(),
34
+ resolveIncident: mock(),
35
+ addUpdate: mock(),
36
+ getIncident: mock(async () => DEFAULT_INCIDENT),
37
+ ...overrides,
38
+ }) as unknown as IncidentService;
39
+
40
+ const logger = createMockLogger();
41
+
42
+ const actionContext = {
43
+ consumedArtifacts: {},
44
+ runId: "run-1",
45
+ automationId: "auto-1",
46
+ contextKey: "INC-1",
47
+ logger,
48
+ getService: async <T,>(): Promise<T> => {
49
+ throw new Error("not used");
50
+ },
51
+ };
52
+
53
+ describe("incident automation actions", () => {
54
+ describe("incident.create", () => {
55
+ it("calls service.createIncident with the config payload", async () => {
56
+ const created = {
57
+ id: "INC-1",
58
+ status: "investigating",
59
+ severity: "critical",
60
+ systemIds: ["sys-1"],
61
+ };
62
+ const service = makeServiceStub({
63
+ createIncident: mock(
64
+ async () => created,
65
+ ) as unknown as IncidentService["createIncident"],
66
+ });
67
+ const [createAction] = createIncidentActions({ service });
68
+ const result = await createAction.execute({
69
+ ...actionContext,
70
+ config: {
71
+ title: "DB down",
72
+ severity: "critical",
73
+ systemIds: ["sys-1"],
74
+ suppressNotifications: false,
75
+ } as never,
76
+ });
77
+ expect(result.success).toBe(true);
78
+ expect((result.artifact as { incidentId: string }).incidentId).toBe(
79
+ "INC-1",
80
+ );
81
+ // The action now reserves an id up front and passes it (with no user)
82
+ // so the reactive `incident` entity can key on it and snapshot a null
83
+ // `prev` before the insert (§10.1).
84
+ expect(service.createIncident).toHaveBeenCalledWith(
85
+ {
86
+ title: "DB down",
87
+ description: undefined,
88
+ severity: "critical",
89
+ systemIds: ["sys-1"],
90
+ initialMessage: undefined,
91
+ suppressNotifications: false,
92
+ },
93
+ undefined,
94
+ expect.any(String),
95
+ );
96
+ });
97
+
98
+ it("dedupe_open_for_system reuses an existing open incident on the system", async () => {
99
+ const existing = {
100
+ id: "INC-OPEN",
101
+ status: "investigating",
102
+ severity: "critical",
103
+ systemIds: ["sys-1"],
104
+ };
105
+ const service = makeServiceStub({
106
+ // The action now delegates the dedup-serialized find-then-create to
107
+ // the service (which wraps it in an advisory lock).
108
+ createIncidentDedupedForSystem: mock(async () => ({
109
+ incident: existing,
110
+ reused: true,
111
+ })) as unknown as IncidentService["createIncidentDedupedForSystem"],
112
+ createIncident: mock() as unknown as IncidentService["createIncident"],
113
+ });
114
+ const [createAction] = createIncidentActions({ service });
115
+ const result = await createAction.execute({
116
+ ...actionContext,
117
+ config: {
118
+ title: "DB down",
119
+ severity: "critical",
120
+ systemIds: ["sys-1"],
121
+ suppressNotifications: false,
122
+ dedupe_open_for_system: true,
123
+ } as never,
124
+ });
125
+ expect(result.success).toBe(true);
126
+ expect((result.artifact as { incidentId: string }).incidentId).toBe(
127
+ "INC-OPEN",
128
+ );
129
+ // Reused via the dedup method — no direct createIncident call.
130
+ expect(service.createIncident).not.toHaveBeenCalled();
131
+ expect(service.createIncidentDedupedForSystem).toHaveBeenCalledTimes(1);
132
+ });
133
+
134
+ it("dedupe_open_for_system creates when no open incident exists", async () => {
135
+ const created = {
136
+ id: "INC-NEW",
137
+ status: "investigating",
138
+ severity: "critical",
139
+ systemIds: ["sys-1"],
140
+ };
141
+ const service = makeServiceStub({
142
+ createIncidentDedupedForSystem: mock(async () => ({
143
+ incident: created,
144
+ reused: false,
145
+ })) as unknown as IncidentService["createIncidentDedupedForSystem"],
146
+ });
147
+ const [createAction] = createIncidentActions({ service });
148
+ const result = await createAction.execute({
149
+ ...actionContext,
150
+ config: {
151
+ title: "DB down",
152
+ severity: "critical",
153
+ systemIds: ["sys-1"],
154
+ suppressNotifications: false,
155
+ dedupe_open_for_system: true,
156
+ } as never,
157
+ });
158
+ expect((result.artifact as { incidentId: string }).incidentId).toBe(
159
+ "INC-NEW",
160
+ );
161
+ expect(service.createIncidentDedupedForSystem).toHaveBeenCalledTimes(1);
162
+ });
163
+
164
+ it("without the flag always creates (no dedup lookup)", async () => {
165
+ const service = makeServiceStub({
166
+ findActiveIncidentForSystem: mock(
167
+ async () => ({
168
+ id: "INC-OPEN",
169
+ status: "investigating",
170
+ severity: "critical",
171
+ systemIds: ["sys-1"],
172
+ }),
173
+ ) as unknown as IncidentService["findActiveIncidentForSystem"],
174
+ createIncident: mock(
175
+ async () => ({
176
+ id: "INC-NEW",
177
+ status: "investigating",
178
+ severity: "critical",
179
+ systemIds: ["sys-1"],
180
+ }),
181
+ ) as unknown as IncidentService["createIncident"],
182
+ });
183
+ const [createAction] = createIncidentActions({ service });
184
+ const result = await createAction.execute({
185
+ ...actionContext,
186
+ config: {
187
+ title: "DB down",
188
+ severity: "critical",
189
+ systemIds: ["sys-1"],
190
+ suppressNotifications: false,
191
+ // dedupe_open_for_system omitted (defaults false)
192
+ } as never,
193
+ });
194
+ expect((result.artifact as { incidentId: string }).incidentId).toBe(
195
+ "INC-NEW",
196
+ );
197
+ expect(service.findActiveIncidentForSystem).not.toHaveBeenCalled();
198
+ });
199
+
200
+ // 6(a): an action-created incident is now reactive — the create runs
201
+ // through `handle.mutate`, so the deriver fires `incident.created`.
202
+ it("drives the create through handle.mutate (action-created incident is reactive)", async () => {
203
+ const created = {
204
+ id: "INC-NEW",
205
+ status: "investigating" as const,
206
+ severity: "critical" as const,
207
+ systemIds: ["sys-1"],
208
+ };
209
+ const service = makeServiceStub({
210
+ createIncident: mock(
211
+ async () => created,
212
+ ) as unknown as IncidentService["createIncident"],
213
+ });
214
+ const mutate = mock(
215
+ async (input: { id: string; apply: () => Promise<unknown> }) =>
216
+ input.apply(),
217
+ );
218
+ const handle = { kind: "incident", mutate } as never;
219
+ const [createAction] = createIncidentActions({
220
+ service,
221
+ getIncidentEntity: () => handle,
222
+ });
223
+ await createAction.execute({
224
+ ...actionContext,
225
+ config: {
226
+ title: "DB down",
227
+ severity: "critical",
228
+ systemIds: ["sys-1"],
229
+ suppressNotifications: false,
230
+ } as never,
231
+ });
232
+ // The create was routed through the entity handle (reactive), keyed on
233
+ // the reserved id passed to the service create.
234
+ expect(mutate).toHaveBeenCalledTimes(1);
235
+ const mutateArg = mutate.mock.calls[0]![0] as { id: string };
236
+ expect(service.createIncident).toHaveBeenCalledWith(
237
+ expect.anything(),
238
+ undefined,
239
+ mutateArg.id,
240
+ );
241
+ });
242
+
243
+ it("dedupe reuse drives NO handle.mutate (no duplicate incident.created)", async () => {
244
+ const existing = {
245
+ id: "INC-OPEN",
246
+ status: "investigating" as const,
247
+ severity: "critical" as const,
248
+ systemIds: ["sys-1"],
249
+ };
250
+ const service = makeServiceStub({
251
+ createIncidentDedupedForSystem: mock(async () => ({
252
+ incident: existing,
253
+ reused: true,
254
+ })) as unknown as IncidentService["createIncidentDedupedForSystem"],
255
+ });
256
+ const mutate = mock(
257
+ async (input: { id: string; apply: () => Promise<unknown> }) =>
258
+ input.apply(),
259
+ );
260
+ const handle = { kind: "incident", mutate } as never;
261
+ const [createAction] = createIncidentActions({
262
+ service,
263
+ getIncidentEntity: () => handle,
264
+ });
265
+ await createAction.execute({
266
+ ...actionContext,
267
+ config: {
268
+ title: "DB down",
269
+ severity: "critical",
270
+ systemIds: ["sys-1"],
271
+ suppressNotifications: false,
272
+ dedupe_open_for_system: true,
273
+ } as never,
274
+ });
275
+ // A reused incident is unchanged → no entity write at all.
276
+ expect(mutate).not.toHaveBeenCalled();
277
+ });
278
+ });
279
+
280
+ describe("incident.resolve", () => {
281
+ it("returns failure when the incident doesn't exist", async () => {
282
+ const service = makeServiceStub({
283
+ // The existence guard (re-read before the driven write) sees no row.
284
+ getIncident: mock(
285
+ async () => undefined,
286
+ ) as unknown as IncidentService["getIncident"],
287
+ resolveIncident: mock(
288
+ async () => undefined,
289
+ ) as unknown as IncidentService["resolveIncident"],
290
+ });
291
+ const actions = createIncidentActions({ service });
292
+ const resolveAction = actions[1];
293
+ const result = await resolveAction.execute({
294
+ ...actionContext,
295
+ config: { incidentId: "missing" } as never,
296
+ });
297
+ expect(result.success).toBe(false);
298
+ expect(result.error).toMatch(/not found/i);
299
+ // Guard short-circuits before attempting the resolve.
300
+ expect(service.resolveIncident).not.toHaveBeenCalled();
301
+ });
302
+
303
+ it("calls service.resolveIncident on the happy path", async () => {
304
+ const resolved = {
305
+ id: "INC-1",
306
+ status: "resolved",
307
+ severity: "critical",
308
+ systemIds: ["sys-1"],
309
+ };
310
+ const service = makeServiceStub({
311
+ resolveIncident: mock(
312
+ async () => resolved,
313
+ ) as unknown as IncidentService["resolveIncident"],
314
+ });
315
+ const actions = createIncidentActions({ service });
316
+ const resolveAction = actions[1];
317
+ const result = await resolveAction.execute({
318
+ ...actionContext,
319
+ config: { incidentId: "INC-1", message: "Fixed" } as never,
320
+ });
321
+ expect(result.success).toBe(true);
322
+ expect(service.resolveIncident).toHaveBeenCalledWith("INC-1", "Fixed");
323
+ });
324
+
325
+ // 6(b) regression: an action-driven resolve must route through the reactive
326
+ // entity (like the RPC router) so it appends an `entity_transitions` row,
327
+ // emits `ENTITY_CHANGED` (waking `wait_until`), and fires the
328
+ // `incident.resolved` deriver — not call the service directly.
329
+ it("routes the resolve through handle.mutate (transition + incident.resolved deriver)", async () => {
330
+ const resolved = {
331
+ id: "INC-1",
332
+ status: "resolved" as const,
333
+ severity: "critical" as const,
334
+ systemIds: ["sys-1"],
335
+ };
336
+ const service = makeServiceStub({
337
+ // `prev` (before resolve) for the deriver assertion below.
338
+ getIncident: mock(async () => ({
339
+ id: "INC-1",
340
+ status: "investigating" as const,
341
+ severity: "critical" as const,
342
+ systemIds: ["sys-1"],
343
+ })) as unknown as IncidentService["getIncident"],
344
+ resolveIncident: mock(
345
+ async () => resolved,
346
+ ) as unknown as IncidentService["resolveIncident"],
347
+ });
348
+ const mutate = mock(
349
+ async (input: {
350
+ id: string;
351
+ opts?: { runId?: string };
352
+ apply: () => Promise<unknown>;
353
+ }) => input.apply(),
354
+ );
355
+ const handle = { kind: "incident", mutate } as never;
356
+ const resolveAction = createIncidentActions({
357
+ service,
358
+ getIncidentEntity: () => handle,
359
+ })[1];
360
+
361
+ const result = await resolveAction.execute({
362
+ ...actionContext,
363
+ config: { incidentId: "INC-1", message: "Fixed" } as never,
364
+ });
365
+
366
+ expect(result.success).toBe(true);
367
+ // The write was driven through the entity handle, keyed on the incident
368
+ // id, with the dispatch `runId` for secret masking.
369
+ expect(mutate).toHaveBeenCalledTimes(1);
370
+ const mutateArg = mutate.mock.calls[0]![0] as {
371
+ id: string;
372
+ opts?: { runId?: string };
373
+ };
374
+ expect(mutateArg.id).toBe("INC-1");
375
+ expect(mutateArg.opts?.runId).toBe("run-1");
376
+
377
+ // The post-write reactive state is `resolved` — feeding the prev→next
378
+ // change through the deriver fires `incident.resolved` (the wake/route).
379
+ const events = deriveIncidentTriggerEvents({
380
+ kind: "incident",
381
+ id: "INC-1",
382
+ prev: { status: "investigating", severity: "critical", systemIds: ["sys-1"] },
383
+ next: { status: "resolved", severity: "critical", systemIds: ["sys-1"] },
384
+ delta: { status: "resolved" },
385
+ changedFields: ["status"],
386
+ actor: SYSTEM_ACTOR,
387
+ occurredAt: new Date().toISOString(),
388
+ });
389
+ expect(events).toEqual([INCIDENT_TRIGGER_EVENTS.resolved]);
390
+ });
391
+ });
392
+
393
+ describe("incident.add_update", () => {
394
+ it("forwards message + statusChange to service.addUpdate", async () => {
395
+ const update = {
396
+ id: "upd-1",
397
+ incidentId: "INC-1",
398
+ message: "msg",
399
+ createdAt: new Date(),
400
+ };
401
+ const service = makeServiceStub({
402
+ addUpdate: mock(
403
+ async () => update,
404
+ ) as unknown as IncidentService["addUpdate"],
405
+ });
406
+ const actions = createIncidentActions({ service });
407
+ const addUpdateAction = actions[2];
408
+ const result = await addUpdateAction.execute({
409
+ ...actionContext,
410
+ config: {
411
+ incidentId: "INC-1",
412
+ message: "Investigating",
413
+ statusChange: "identified",
414
+ } as never,
415
+ });
416
+ expect(result.success).toBe(true);
417
+ expect(service.addUpdate).toHaveBeenCalledWith({
418
+ incidentId: "INC-1",
419
+ message: "Investigating",
420
+ statusChange: "identified",
421
+ });
422
+ });
423
+ });
424
+
425
+ describe("incident.update_status", () => {
426
+ it("delegates to addUpdate with a generated audit message", async () => {
427
+ const update = {
428
+ id: "upd-2",
429
+ incidentId: "INC-1",
430
+ message: "Status changed to monitoring",
431
+ createdAt: new Date(),
432
+ };
433
+ const service = makeServiceStub({
434
+ addUpdate: mock(
435
+ async () => update,
436
+ ) as unknown as IncidentService["addUpdate"],
437
+ });
438
+ const actions = createIncidentActions({ service });
439
+ const updateStatusAction = actions[3];
440
+ const result = await updateStatusAction.execute({
441
+ ...actionContext,
442
+ config: { incidentId: "INC-1", status: "monitoring" } as never,
443
+ });
444
+ expect(result.success).toBe(true);
445
+ expect(service.addUpdate).toHaveBeenCalledWith({
446
+ incidentId: "INC-1",
447
+ message: "Status changed to monitoring",
448
+ statusChange: "monitoring",
449
+ });
450
+ });
451
+ });
452
+
453
+ describe("incident artifact (produces / consumes)", () => {
454
+ it("incident.create declares produces: incident", () => {
455
+ const [createAction] = createIncidentActions({
456
+ service: makeServiceStub(),
457
+ });
458
+ expect(createAction.produces).toBe("incident");
459
+ });
460
+
461
+ it("incident.resolve consumes the upstream incident artifact when incidentId is omitted", async () => {
462
+ const resolved = {
463
+ id: "INC-9",
464
+ status: "resolved",
465
+ severity: "critical",
466
+ systemIds: ["sys-1"],
467
+ };
468
+ const service = makeServiceStub({
469
+ resolveIncident: mock(
470
+ async () => resolved,
471
+ ) as unknown as IncidentService["resolveIncident"],
472
+ });
473
+ const resolveAction = createIncidentActions({ service })[1];
474
+ expect(resolveAction.consumes).toEqual(["incident"]);
475
+
476
+ const result = await resolveAction.execute({
477
+ ...actionContext,
478
+ // No incidentId in config — falls back to the consumed artifact.
479
+ config: { message: "recovered" } as never,
480
+ consumedArtifacts: {
481
+ incident: { incidentId: "INC-9", status: "investigating" },
482
+ },
483
+ });
484
+ expect(result.success).toBe(true);
485
+ expect(service.resolveIncident).toHaveBeenCalledWith("INC-9", "recovered");
486
+ });
487
+
488
+ it("incident.resolve config incidentId takes priority over the artifact", async () => {
489
+ const service = makeServiceStub({
490
+ resolveIncident: mock(
491
+ async () => ({
492
+ id: "INC-CONFIG",
493
+ status: "resolved",
494
+ severity: "high",
495
+ systemIds: [],
496
+ }),
497
+ ) as unknown as IncidentService["resolveIncident"],
498
+ });
499
+ const resolveAction = createIncidentActions({ service })[1];
500
+ await resolveAction.execute({
501
+ ...actionContext,
502
+ config: { incidentId: "INC-CONFIG" } as never,
503
+ consumedArtifacts: { incident: { incidentId: "INC-ARTIFACT" } },
504
+ });
505
+ expect(service.resolveIncident).toHaveBeenCalledWith(
506
+ "INC-CONFIG",
507
+ undefined,
508
+ );
509
+ });
510
+
511
+ it("incident.resolve fails clearly when neither config nor artifact has an id", async () => {
512
+ const service = makeServiceStub();
513
+ const resolveAction = createIncidentActions({ service })[1];
514
+ const result = await resolveAction.execute({
515
+ ...actionContext,
516
+ config: {} as never,
517
+ consumedArtifacts: {},
518
+ });
519
+ expect(result.success).toBe(false);
520
+ expect(service.resolveIncident).not.toHaveBeenCalled();
521
+ });
522
+ });
523
+ });