import { test } from "node:test";
import assert from "node:assert/strict";
import {
initialState, step, timelineBroken, formatDuration, composeMessage, sendAlert, STARTING_GRACE_MS,
} from "../src/logic.js";
const up = (version = "v1", node = "n1") => ({ health: { status: 200, ready: true, version, node }, timelineStatus: 200 });
const down = (status = 502) => ({ health: { status, ready: false } });
const T = 60_000;
function run(obsList, start = initialState(), t0 = 1_000_000) {
let s = start;
const all = [];
obsList.forEach((o, i) => {
const r = step(s, o, t0 + i * T);
s = r.state;
all.push(...r.actions);
});
return { s, all };
}
test("healthy checks record version and node, no actions", () => {
const { s, all } = run([up("v1", "a"), up("v1", "a")]);
assert.deepEqual(all, []);
assert.equal(s.version, "v1");
assert.equal(s.node, "a");
assert.equal(s.incident, null);
});
test("one unhealthy check does not alert", () => {
const { all } = run([up(), down(), up()]);
assert.deepEqual(all, []);
});
test("two consecutive unhealthy checks alert once", () => {
const { s, all } = run([up(), down(), down(), down(), down()]);
assert.equal(all.length, 1);
assert.equal(all[0].kind, "down");
assert.equal(all[0].since, 1_000_000 + T);
assert.equal(s.incident, "down");
});
test("ready:false with 200 counts as unhealthy", () => {
const notReady = { health: { status: 200, ready: false, version: "v1" } };
const { all } = run([up(), notReady, notReady]);
assert.equal(all[0].kind, "down");
});
test("recovery reports outage length from first failure", () => {
const { s, all } = run([up(), down(), down(), down(), up()]);
assert.deepEqual(all.map((a) => a.kind), ["down", "recovered"]);
assert.equal(all[1].outageMs, 3 * T);
assert.equal(s.incident, null);
});
test("no recovery message without a prior alert", () => {
const { all } = run([up(), down(), up()]);
assert.deepEqual(all, []);
});
test("timeline 5xx twice alerts broken once", () => {
const bad = { ...up(), timelineStatus: 500 };
const { s, all } = run([up(), bad, bad, bad]);
assert.deepEqual(all.map((a) => a.kind), ["broken"]);
assert.equal(s.incident, "broken");
});
test("single timeline 5xx does not alert", () => {
const bad = { ...up(), timelineStatus: 500 };
assert.deepEqual(run([up(), bad, up()]).all, []);
});
test("broken recovers when timeline answers", () => {
const bad = { ...up(), timelineStatus: 500 };
const { all } = run([up(), bad, bad, up()]);
assert.deepEqual(all.map((a) => a.kind), ["broken", "recovered"]);
});
test("503 within grace of a version change is not an incident", () => {
const starting = { ...up("v2"), timelineStatus: 503 };
const { all, s } = run([up("v1"), starting, starting, starting]);
assert.deepEqual(all, []);
assert.equal(s.brokenStreak, 0);
});
test("503 after the grace window counts", () => {
const starting = { ...up("v2"), timelineStatus: 503 };
const n = STARTING_GRACE_MS / T + 3;
const { all } = run([up("v1"), ...Array(n).fill(starting)]);
assert.deepEqual(all.map((a) => a.kind), ["broken"]);
});
test("503 without a version change counts", () => {
const bad = { ...up(), timelineStatus: 503 };
assert.deepEqual(run([up(), bad, bad]).all.map((a) => a.kind), ["broken"]);
});
test("timelineBroken ignores null and 4xx", () => {
assert.equal(timelineBroken(null, null, 0), false);
assert.equal(timelineBroken(404, null, 0), false);
assert.equal(timelineBroken(500, null, 0), true);
});
test("down then host back but timeline still broken hands over to broken", () => {
const bad = { ...up(), timelineStatus: 500 };
const { s, all } = run([up(), down(), down(), bad]);
assert.deepEqual(all.map((a) => a.kind), ["down", "recovered"]);
assert.equal(s.incident, "broken");
});
test("first ever observation is not a version change", () => {
const { s } = run([up("v1")]);
assert.equal(s.versionChangedAt, null);
});
test("formatDuration", () => {
assert.equal(formatDuration(45_000), "45s");
assert.equal(formatDuration(125_000), "2m 5s");
assert.equal(formatDuration(3_700_000), "1h 1m");
});
test("composeMessage carries state and link", () => {
const s = { ...initialState(), lastStatus: 502, version: "v9", node: "n7" };
const m = composeMessage({ kind: "down", since: 0 }, s, "https://forge.example");
assert.match(m.subject, /1970-01-01T00:00:00.000Z/);
for (const part of ["502", "v9", "n7", "https://forge.example"]) assert.ok(m.body.includes(part));
const r = composeMessage({ kind: "recovered", since: 0, outageMs: 90_000 }, s, "u");
assert.ok(r.subject.includes("1m 30s"));
});
test("sendAlert calls the Email Sending binding with the documented shape", async () => {
const calls = [];
const env = { ALERT_TO: "[email protected]", ALERT_FROM: "[email protected]", ALERT_EMAIL: { send: async (m) => calls.push(m) } };
const state = { ...initialState(), lastStatus: 502 };
await sendAlert(env, { kind: "down", since: 0 }, state, "https://forge.example");
assert.equal(calls.length, 1);
assert.deepEqual(Object.keys(calls[0]).sort(), ["from", "subject", "text", "to"]);
assert.equal(calls[0].to, "[email protected]");
assert.equal(calls[0].from.email, "[email protected]");
assert.equal(typeof calls[0].from.name, "string");
assert.ok(calls[0].text.includes("https://forge.example"));
});