| 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; |
| 3 | |
| 4 | import { |
| 5 | type IncidentFacts, |
| 6 | applyChange, |
| 7 | applyRoles, |
| 8 | checkChange, |
| 9 | checkDeclare, |
| 10 | checkMaintenance, |
| 11 | checkMaintenanceChange, |
| 12 | checkPostmortem, |
| 13 | duration, |
| 14 | durations, |
| 15 | notifyByDefault, |
| 16 | postmortemReady, |
| 17 | shortId, |
| 18 | } from "./incidents.ts"; |
| 19 | import { postmortemDraft, timelineText } from "./postmortem.ts"; |
| 20 | |
| 21 | const NOW = new Date("2026-10-05T12:00:00Z"); |
| 22 | const KNOWN = ["git", "api", "docs"]; |
| 23 | const base = { |
| 24 | title: " Pushes failing ", |
| 25 | severity: "sev2", |
| 26 | message: "We are looking into it.\r\n\r\n\r\nMore soon.", |
| 27 | components: [ |
| 28 | { key: "git", impact: "major_outage" }, |
| 29 | { key: "api", impact: "operational" }, |
| 30 | ], |
| 31 | commander: " Ana@G1T.sh ", |
| 32 | notify: true, |
| 33 | by: "staff@g1t.sh", |
| 34 | }; |
| 35 | |
| 36 | test("declaring: cleaned and checked; operational parts are left out", () => { |
| 37 | const checked = checkDeclare(base, KNOWN, NOW); |
| 38 | assert.ok(checked.ok); |
| 39 | assert.equal(checked.value.title, "Pushes failing"); |
| 40 | assert.equal(checked.value.status, "investigating"); |
| 41 | assert.equal(checked.value.message, "We are looking into it.\n\nMore soon."); |
| 42 | assert.deepEqual(checked.value.components, [{ key: "git", impact: "major_outage" }]); |
| 43 | assert.equal(checked.value.commander, "ana@g1t.sh"); |
| 44 | assert.equal(checked.value.started_at, null); |
| 45 | assert.equal(checked.value.notify, true); |
| 46 | }); |
| 47 | |
| 48 | test("declaring is refused without what it needs", () => { |
| 49 | assert.equal(checkDeclare({ ...base, title: "" }, KNOWN, NOW).ok, false); |
| 50 | assert.equal(checkDeclare({ ...base, severity: "sev9" }, KNOWN, NOW).ok, false); |
| 51 | assert.equal(checkDeclare({ ...base, status: "resolved" }, KNOWN, NOW).ok, false); |
| 52 | assert.equal(checkDeclare({ ...base, components: [{ key: "git", impact: "operational" }] }, KNOWN, NOW).ok, false); |
| 53 | assert.equal(checkDeclare({ ...base, components: [{ key: "nope", impact: "degraded" }] }, KNOWN, NOW).ok, false); |
| 54 | assert.equal(checkDeclare({ ...base, components: [{ key: "git", impact: "meh" }] }, KNOWN, NOW).ok, false); |
| 55 | assert.equal(checkDeclare({ ...base, commander: "not an email" }, KNOWN, NOW).ok, false); |
| 56 | assert.equal(checkDeclare({ ...base, message: " " }, KNOWN, NOW).ok, false); |
| 57 | assert.equal(checkDeclare({ ...base, started_at: "2026-10-06T00:00:00Z" }, KNOWN, NOW).ok, false); |
| 58 | const backdated = checkDeclare({ ...base, started_at: "2026-10-05T11:00:00Z" }, KNOWN, NOW); |
| 59 | assert.ok(backdated.ok && backdated.value.started_at === "2026-10-05T11:00:00.000Z"); |
| 60 | }); |
| 61 | |
| 62 | test("severities: SEV1 and SEV2 email subscribers by default", () => { |
| 63 | assert.deepEqual((["sev1", "sev2", "sev3", "sev4"] as const).map(notifyByDefault), [true, true, false, false]); |
| 64 | }); |
| 65 | |
| 66 | test("an update needs words when it is public; a note can be just a change", () => { |
| 67 | assert.equal(checkChange({ public: true, message: " ", by: "x" }, KNOWN).ok, false); |
| 68 | const note = checkChange({ public: false, message: "", severity: "sev1", by: "x" }, KNOWN); |
| 69 | assert.ok(note.ok && note.value.severity === "sev1" && note.value.notify === false); |
| 70 | assert.equal(checkChange({ public: true, message: "ok", status: "panicking", by: "x" }, KNOWN).ok, false); |
| 71 | assert.equal(checkChange({ public: true, message: "ok", impacts: [{ key: "zzz", impact: "degraded" }], by: "x" }, KNOWN).ok, false); |
| 72 | const notify = checkChange({ public: false, message: "internal", notify: true, by: "x" }, KNOWN); |
| 73 | assert.ok(notify.ok && notify.value.notify === false, "a note is never emailed"); |
| 74 | }); |
| 75 | |
| 76 | const open: IncidentFacts = { |
| 77 | status: "investigating", |
| 78 | severity: "sev2", |
| 79 | visibility: "public", |
| 80 | components: [{ key: "git", impact: "major_outage" }], |
| 81 | started_at: "2026-10-05T11:30:00.000Z", |
| 82 | acknowledged_at: "2026-10-05T11:35:00.000Z", |
| 83 | mitigated_at: null, |
| 84 | resolved_at: null, |
| 85 | commander: null, |
| 86 | communications: null, |
| 87 | }; |
| 88 | const names = new Map([["git", "Git and repositories"]]); |
| 89 | |
| 90 | function change(input: Record<string, unknown>) { |
| 91 | const checked = checkChange({ by: "staff@g1t.sh", ...input }, KNOWN); |
| 92 | assert.ok(checked.ok, checked.ok ? "" : checked.error); |
| 93 | return checked.value; |
| 94 | } |
| 95 | |
| 96 | test("lifecycle: identified, then monitoring marks it mitigated, then resolved", () => { |
| 97 | const a = applyChange(open, change({ public: true, status: "identified", message: "A bad deploy." }), NOW, names); |
| 98 | assert.ok(a.ok); |
| 99 | assert.equal(a.value.next.status, "identified"); |
| 100 | assert.equal(a.value.next.mitigated_at, null); |
| 101 | assert.deepEqual( |
| 102 | a.value.entries.map((e) => [e.kind, e.public]), |
| 103 | [ |
| 104 | ["status", false], |
| 105 | ["update", true], |
| 106 | ], |
| 107 | ); |
| 108 | assert.equal(a.value.entries[1]!.status, "identified"); |
| 109 | assert.equal(a.value.entries[0]!.text, "Investigating → Identified."); |
| 110 | |
| 111 | const b = applyChange(a.value.next, change({ public: true, status: "monitoring", message: "Rolled back." }), new Date("2026-10-05T12:10:00Z")); |
| 112 | assert.ok(b.ok && b.value.next.mitigated_at === "2026-10-05T12:10:00.000Z"); |
| 113 | |
| 114 | const c = applyChange(b.value.next, change({ public: true, status: "resolved", message: "Fixed." }), new Date("2026-10-05T12:40:00Z")); |
| 115 | assert.ok(c.ok); |
| 116 | assert.equal(c.value.next.resolved_at, "2026-10-05T12:40:00.000Z"); |
| 117 | assert.equal(c.value.next.mitigated_at, "2026-10-05T12:10:00.000Z", "mitigated keeps its first time"); |
| 118 | assert.deepEqual(durations(c.value.next), { to_acknowledge: 300, to_mitigate: 2400, to_resolve: 4200 }); |
| 119 | }); |
| 120 | |
| 121 | test("lifecycle: resolving straight away marks it mitigated too; reopening clears resolved", () => { |
| 122 | const done = applyChange(open, change({ public: true, status: "resolved", message: "It was a blip." }), NOW); |
| 123 | assert.ok(done.ok && done.value.next.mitigated_at === NOW.toISOString() && done.value.next.resolved_at === NOW.toISOString()); |
| 124 | const again = applyChange(done.value.next, change({ public: true, status: "investigating", message: "It is back." }), NOW); |
| 125 | assert.ok(again.ok); |
| 126 | assert.equal(again.value.next.resolved_at, null); |
| 127 | assert.match(again.value.entries[0]!.text, /^Reopened/); |
| 128 | }); |
| 129 | |
| 130 | test("lifecycle: a published incident's status changes only with public words", () => { |
| 131 | const quiet = applyChange(open, change({ public: false, status: "identified", message: "Found it." }), NOW); |
| 132 | assert.equal(quiet.ok, false); |
| 133 | const note = applyChange(open, change({ public: false, message: "Paging the storage team." }), NOW); |
| 134 | assert.ok(note.ok && note.value.entries.length === 1 && note.value.entries[0]!.kind === "note" && !note.value.entries[0]!.public); |
| 135 | assert.equal(applyChange(open, change({ public: false, message: "" }), NOW).ok, false, "nothing to post"); |
| 136 | }); |
| 137 | |
| 138 | test("lifecycle: severity and impact changes are written on the timeline", () => { |
| 139 | const r = applyChange( |
| 140 | open, |
| 141 | change({ |
| 142 | public: false, |
| 143 | message: "", |
| 144 | severity: "sev1", |
| 145 | impacts: [ |
| 146 | { key: "git", impact: "partial_outage" }, |
| 147 | { key: "api", impact: "degraded" }, |
| 148 | ], |
| 149 | }), |
| 150 | NOW, |
| 151 | names, |
| 152 | ); |
| 153 | assert.ok(r.ok); |
| 154 | assert.equal(r.value.next.severity, "sev1"); |
| 155 | assert.deepEqual(r.value.next.components, [ |
| 156 | { key: "git", impact: "partial_outage" }, |
| 157 | { key: "api", impact: "degraded" }, |
| 158 | ]); |
| 159 | assert.deepEqual( |
| 160 | r.value.entries.map((e) => e.text), |
| 161 | ["Severity SEV2 → SEV1.", "Git and repositories: Major outage → Partial outage.", "api: Operational → Degraded performance."], |
| 162 | ); |
| 163 | }); |
| 164 | |
| 165 | test("lifecycle: drafts take notes but nothing public; dismissed takes nothing; the first touch acknowledges", () => { |
| 166 | const draft: IncidentFacts = { ...open, visibility: "draft", acknowledged_at: null }; |
| 167 | assert.equal(applyChange(draft, change({ public: true, message: "Hi" }), NOW).ok, false); |
| 168 | const note = applyChange(draft, change({ public: false, message: "Looking.", status: "identified" }), NOW); |
| 169 | assert.ok(note.ok); |
| 170 | assert.equal(note.value.next.acknowledged_at, NOW.toISOString()); |
| 171 | assert.equal(note.value.entries[0]!.kind, "acknowledged"); |
| 172 | assert.equal(note.value.next.status, "identified", "a draft's status can move without words"); |
| 173 | assert.equal(applyChange({ ...open, visibility: "dismissed" }, change({ public: false, message: "x" }), NOW).ok, false); |
| 174 | }); |
| 175 | |
| 176 | test("roles: each change is a line, and nobody is said plainly", () => { |
| 177 | const r = applyRoles({ ...open, commander: "ana@g1t.sh" }, { commander: null, communications: "bo@g1t.sh", by: "x" }, NOW); |
| 178 | assert.deepEqual( |
| 179 | r.entries.map((e) => e.text), |
| 180 | ["Incident commander: nobody.", "Communications: bo@g1t.sh."], |
| 181 | ); |
| 182 | assert.equal(applyRoles(open, { commander: null, communications: null, by: "x" }, NOW).entries.length, 0); |
| 183 | }); |
| 184 | |
| 185 | test("durations read plainly", () => { |
| 186 | assert.equal(duration(null), "—"); |
| 187 | assert.equal(duration(42), "42s"); |
| 188 | assert.equal(duration(720), "12m"); |
| 189 | assert.equal(duration(7500), "2h 05m"); |
| 190 | assert.equal(duration(3 * 86400 + 4 * 3600), "3d 4h"); |
| 191 | }); |
| 192 | |
| 193 | test("maintenance: a window in the future, under 72 hours, on known parts", () => { |
| 194 | const m = { |
| 195 | title: "Database upgrade", |
| 196 | message: "Pushes pause for a few minutes.", |
| 197 | components: ["git"], |
| 198 | starts_at: "2026-10-06T02:00Z", |
| 199 | ends_at: "2026-10-06T03:00Z", |
| 200 | by: "x", |
| 201 | }; |
| 202 | const checked = checkMaintenance(m, KNOWN, NOW); |
| 203 | assert.ok(checked.ok && checked.value.ends_at === "2026-10-06T03:00:00.000Z"); |
| 204 | assert.equal(checkMaintenance({ ...m, ends_at: "2026-10-06T01:00Z" }, KNOWN, NOW).ok, false); |
| 205 | assert.equal(checkMaintenance({ ...m, starts_at: "2026-10-04T01:00Z", ends_at: "2026-10-04T02:00Z" }, KNOWN, NOW).ok, false); |
| 206 | assert.equal(checkMaintenance({ ...m, ends_at: "2026-10-10T03:00Z" }, KNOWN, NOW).ok, false); |
| 207 | assert.equal(checkMaintenance({ ...m, components: [] }, KNOWN, NOW).ok, false); |
| 208 | assert.equal(checkMaintenanceChange({ action: "update", message: "", by: "x" }).ok, false); |
| 209 | assert.ok(checkMaintenanceChange({ action: "cancel", message: "", by: "x" }).ok); |
| 210 | }); |
| 211 | |
| 212 | test("postmortems: published only with a summary and a cause; the draft is filled from the incident", () => { |
| 213 | const pm = checkPostmortem({ |
| 214 | summary: "A deploy broke pushes.", |
| 215 | impact: "", |
| 216 | timeline: "", |
| 217 | root_cause: "", |
| 218 | went_well: "", |
| 219 | went_badly: "", |
| 220 | action_items: "", |
| 221 | by: "x", |
| 222 | }); |
| 223 | assert.ok(pm.ok); |
| 224 | assert.match(postmortemReady(pm.value) ?? "", /caused/); |
| 225 | assert.equal(postmortemReady({ ...pm.value, root_cause: "A missing index." }), null); |
| 226 | |
| 227 | const timeline = [ |
| 228 | { id: "1", at: "2026-10-05T11:35:00Z", by: "x", kind: "declared" as const, public: false, status: null, text: "Declared SEV2.", notified: null }, |
| 229 | { id: "2", at: "2026-10-05T11:36:00Z", by: "x", kind: "acknowledged" as const, public: false, status: null, text: "Acknowledged.", notified: null }, |
| 230 | { |
| 231 | id: "3", |
| 232 | at: "2026-10-05T11:40:00Z", |
| 233 | by: "x", |
| 234 | kind: "update" as const, |
| 235 | public: true, |
| 236 | status: "investigating" as const, |
| 237 | text: "Pushes fail.\nLooking.", |
| 238 | notified: 3, |
| 239 | }, |
| 240 | ]; |
| 241 | assert.equal(timelineText(timeline), "- 5 Oct 11:35 UTC: Declared SEV2.\n- 5 Oct 11:40 UTC: Status page: Pushes fail. Looking."); |
| 242 | const draft = postmortemDraft( |
| 243 | { |
| 244 | started_at: "2026-10-05T11:30:00Z", |
| 245 | resolved_at: "2026-10-05T12:40:00Z", |
| 246 | components: [{ key: "git", impact: "major_outage" }], |
| 247 | durations: { to_acknowledge: 300, to_mitigate: null, to_resolve: 4200 }, |
| 248 | }, |
| 249 | timeline, |
| 250 | [{ id: "f", title: "Alert on push errors", owner: "ana@g1t.sh", done_at: null, created_at: "", created_by: "" }], |
| 251 | names, |
| 252 | ); |
| 253 | assert.equal(draft.impact, "Git and repositories (major outage) affected for 1h 10m, from 5 Oct 11:30 UTC to 5 Oct 12:40 UTC."); |
| 254 | assert.equal(draft.action_items, "- Alert on push errors"); |
| 255 | }); |
| 256 | |
| 257 | test("short ids are ten unambiguous characters", () => { |
| 258 | const id = shortId(); |
| 259 | assert.match(id, /^[a-km-np-z2-9]{10}$/); |
| 260 | assert.notEqual(shortId(), id); |
| 261 | }); |