230 lines
9.8 KiB
TypeScript
230 lines
9.8 KiB
TypeScript
import test from "node:test";
|
||
import assert from "node:assert/strict";
|
||
import {
|
||
rotationIndex,
|
||
shiftFor,
|
||
shiftsInRange,
|
||
formatInTimezone,
|
||
utcForTimezone,
|
||
} from "../../scripts/sre/oncall-rotation.mjs";
|
||
|
||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||
|
||
const PST = "America/Los_Angeles";
|
||
const HOUR = 3600_000;
|
||
const DAY = 24 * HOUR;
|
||
const WEEK = 7 * DAY;
|
||
|
||
const ROTATION = {
|
||
timezone: PST,
|
||
shiftHours: 168, // one week
|
||
startsAt: "2026-06-01T09:00:00-07:00", // Monday 09:00 PDT
|
||
members: ["alice", "bob", "carol", "dave"],
|
||
};
|
||
|
||
// ─── 1. formatInTimezone ────────────────────────────────────────────────────
|
||
|
||
test("formatInTimezone: formats a UTC instant in PST", () => {
|
||
// 2026-06-25T16:00:00Z = 2026-06-25T09:00:00-07:00 (PDT in June)
|
||
const out = formatInTimezone(new Date("2026-06-25T16:00:00Z"), PST);
|
||
assert.equal(out, "2026-06-25T09:00:00-07:00");
|
||
});
|
||
|
||
test("formatInTimezone: PDT in June, PST in January", () => {
|
||
// June → UTC-7 (PDT)
|
||
const june = formatInTimezone(new Date("2026-06-15T12:00:00Z"), PST);
|
||
assert.match(june, /-07:00$/);
|
||
// January → UTC-8 (PST)
|
||
const jan = formatInTimezone(new Date("2026-01-15T12:00:00Z"), PST);
|
||
assert.match(jan, /-08:00$/);
|
||
});
|
||
|
||
// ─── 2. utcForTimezone ──────────────────────────────────────────────────────
|
||
|
||
test("utcForTimezone: PDT noon is 19:00Z", () => {
|
||
const out = utcForTimezone(new Date("2026-06-15T12:00:00-07:00"), PST);
|
||
assert.equal(new Date(out).toISOString(), "2026-06-15T19:00:00.000Z");
|
||
});
|
||
|
||
test("utcForTimezone: PST noon is 20:00Z", () => {
|
||
const out = utcForTimezone(new Date("2026-01-15T12:00:00-08:00"), PST);
|
||
assert.equal(new Date(out).toISOString(), "2026-01-15T20:00:00.000Z");
|
||
});
|
||
|
||
// ─── 3. rotationIndex ───────────────────────────────────────────────────────
|
||
|
||
test("rotationIndex: starts at 0 for the rotation start instant", () => {
|
||
const startMs = Date.parse("2026-06-01T16:00:00Z"); // 09:00 PDT
|
||
assert.equal(rotationIndex(startMs, ROTATION), 0);
|
||
});
|
||
|
||
test("rotationIndex: advances to 1 exactly one shift later", () => {
|
||
const t = Date.parse("2026-06-08T16:00:00Z"); // one week later, 09:00 PDT
|
||
assert.equal(rotationIndex(t, ROTATION), 1);
|
||
});
|
||
|
||
test("rotationIndex: mid-week is still shift 0", () => {
|
||
const t = Date.parse("2026-06-03T16:00:00Z"); // Wednesday
|
||
assert.equal(rotationIndex(t, ROTATION), 0);
|
||
});
|
||
|
||
test("rotationIndex: wraps around after `members.length` shifts", () => {
|
||
// 4 members × 1 week each = 4 weeks later = back to alice (index 0).
|
||
const t = Date.parse("2026-06-01T16:00:00Z") + 4 * WEEK;
|
||
assert.equal(rotationIndex(t, ROTATION), 0);
|
||
});
|
||
|
||
test("rotationIndex: second cycle hits bob at week 5", () => {
|
||
const t = Date.parse("2026-06-01T16:00:00Z") + 5 * WEEK;
|
||
assert.equal(rotationIndex(t, ROTATION), 1);
|
||
});
|
||
|
||
// ─── 4. shiftFor ────────────────────────────────────────────────────────────
|
||
|
||
test("shiftFor: at the very start of the rotation", () => {
|
||
const startMs = Date.parse("2026-06-01T16:00:00Z");
|
||
const shift = shiftFor(startMs, ROTATION);
|
||
assert.equal(shift.member, "alice");
|
||
assert.equal(shift.shiftNumber, 0);
|
||
assert.equal(shift.startsAt, "2026-06-01T16:00:00.000Z");
|
||
});
|
||
|
||
test("shiftFor: at the start of week 2 (boundary)", () => {
|
||
const t = Date.parse("2026-06-08T16:00:00Z");
|
||
const shift = shiftFor(t, ROTATION);
|
||
assert.equal(shift.member, "bob");
|
||
assert.equal(shift.shiftNumber, 1);
|
||
assert.equal(shift.startsAt, "2026-06-08T16:00:00.000Z");
|
||
});
|
||
|
||
test("shiftFor: 1ms before the boundary still belongs to current shift", () => {
|
||
const t = Date.parse("2026-06-08T15:59:59.999Z");
|
||
const shift = shiftFor(t, ROTATION);
|
||
assert.equal(shift.member, "alice");
|
||
});
|
||
|
||
test("shiftFor: DST transition stays in the same shift", () => {
|
||
// DST in US jumped forward on 2026-03-08. Check that an instant
|
||
// straddling the spring-forward boundary still resolves to a single
|
||
// shift (no off-by-one).
|
||
const dstRotation = {
|
||
timezone: PST,
|
||
shiftHours: 168,
|
||
startsAt: "2026-03-01T09:00:00-08:00", // PST
|
||
members: ["alice", "bob"],
|
||
};
|
||
// 2026-03-08T02:30 PST does not exist (skipped by DST). The instant
|
||
// 2026-03-08T10:30:00Z = 03:30 PDT (which exists).
|
||
const afterDst = Date.parse("2026-03-08T10:30:00Z");
|
||
const beforeDst = Date.parse("2026-03-08T08:30:00Z"); // 00:30 PST
|
||
assert.equal(shiftFor(afterDst, dstRotation).member, shiftFor(beforeDst, dstRotation).member);
|
||
});
|
||
|
||
// ─── 5. shiftsInRange ───────────────────────────────────────────────────────
|
||
|
||
test("shiftsInRange: single shift fully inside the range", () => {
|
||
const shifts = shiftsInRange("2026-06-02T00:00:00Z", "2026-06-03T00:00:00Z", ROTATION);
|
||
assert.equal(shifts.length, 1);
|
||
assert.equal(shifts[0].member, "alice");
|
||
});
|
||
|
||
test("shiftsInRange: range spanning two shifts returns both", () => {
|
||
const shifts = shiftsInRange("2026-06-02T00:00:00Z", "2026-06-15T00:00:00Z", ROTATION);
|
||
assert.equal(shifts.length, 2);
|
||
assert.equal(shifts[0].member, "alice");
|
||
assert.equal(shifts[1].member, "bob");
|
||
});
|
||
|
||
test("shiftsInRange: range spanning one full cycle returns all 4 members", () => {
|
||
const shifts = shiftsInRange("2026-06-01T00:00:00Z", "2026-07-01T00:00:00Z", ROTATION);
|
||
assert.equal(shifts.length, 4);
|
||
assert.deepEqual(
|
||
shifts.map((s) => s.member),
|
||
["alice", "bob", "carol", "dave"],
|
||
);
|
||
});
|
||
|
||
test("shiftsInRange: empty range returns empty array", () => {
|
||
// Range entirely before the rotation starts.
|
||
const shifts = shiftsInRange("2026-05-01T00:00:00Z", "2026-05-02T00:00:00Z", ROTATION);
|
||
assert.equal(shifts.length, 0);
|
||
});
|
||
|
||
test("shiftsInRange: reversed range throws", () => {
|
||
assert.throws(
|
||
() => shiftsInRange("2026-06-15T00:00:00Z", "2026-06-01T00:00:00Z", ROTATION),
|
||
/empty or reversed/,
|
||
);
|
||
});
|
||
|
||
test("shiftsInRange: invalid datetime throws", () => {
|
||
assert.throws(
|
||
() => shiftsInRange("not-a-date", "2026-06-15T00:00:00Z", ROTATION),
|
||
/invalid date/,
|
||
);
|
||
});
|
||
|
||
// ─── 6. Cross-week + cross-DST handoff ─────────────────────────────────────
|
||
|
||
test("cross-week: handoff happens at the same wall-clock time each Monday", () => {
|
||
// Check that week 1 ends and week 2 begins at 09:00 PDT for both shifts.
|
||
const shifts = shiftsInRange("2026-06-07T00:00:00Z", "2026-06-15T00:00:00Z", ROTATION);
|
||
assert.equal(shifts.length, 2);
|
||
// Alice's shift ends at 2026-06-08T16:00:00Z.
|
||
assert.equal(shifts[0].endsAt, "2026-06-08T16:00:00.000Z");
|
||
// Bob's shift starts at 2026-06-08T16:00:00Z.
|
||
assert.equal(shifts[1].startsAt, "2026-06-08T16:00:00.000Z");
|
||
});
|
||
|
||
test("cross-DST: week spans the spring-forward boundary cleanly", () => {
|
||
// 2026-03-01 starts PST (UTC-8); 2026-03-08 jumps to PDT (UTC-7).
|
||
const dstRotation = {
|
||
timezone: PST,
|
||
shiftHours: 168,
|
||
startsAt: "2026-03-01T09:00:00-08:00",
|
||
members: ["alice", "bob"],
|
||
};
|
||
const shifts = shiftsInRange("2026-03-01T00:00:00Z", "2026-03-15T00:00:00Z", dstRotation);
|
||
assert.equal(shifts.length, 2);
|
||
// Both shifts should be 168 hours in duration, but their UTC start/end
|
||
// times shift by 1 hour because of DST.
|
||
assert.equal(shifts[0].member, "alice");
|
||
assert.equal(shifts[1].member, "bob");
|
||
// The UTC duration of each shift is still 168 hours (the UTC offset
|
||
// shifted, but the duration in real time is invariant).
|
||
const dur0 = Date.parse(shifts[0].endsAt) - Date.parse(shifts[0].startsAt);
|
||
assert.equal(dur0, 168 * HOUR);
|
||
});
|
||
|
||
// ─── 7. Negative / pre-rotation instants ───────────────────────────────────
|
||
|
||
test("rotationIndex: pre-rotation instant uses correct wrap-around", () => {
|
||
// A shift before `startsAt` should land on the member who WOULD have
|
||
// been on call at that earlier time. We walk backwards from startsAt.
|
||
const oneWeekBefore = Date.parse("2026-06-01T16:00:00Z") - WEEK;
|
||
// One shift earlier = last member (dave).
|
||
assert.equal(rotationIndex(oneWeekBefore, ROTATION), 3);
|
||
});
|
||
|
||
test("shiftFor: pre-rotation instant gives the expected member", () => {
|
||
const oneWeekBefore = Date.parse("2026-06-01T16:00:00Z") - WEEK;
|
||
const shift = shiftFor(oneWeekBefore, ROTATION);
|
||
assert.equal(shift.member, "dave");
|
||
assert.equal(shift.shiftNumber, -1);
|
||
});
|
||
|
||
// ─── 8. Different shift lengths ─────────────────────────────────────────────
|
||
|
||
test("rotationIndex: 24h shifts cycle faster than weekly shifts", () => {
|
||
const dailyRotation = { ...ROTATION, shiftHours: 24 };
|
||
const t = Date.parse("2026-06-03T16:00:00Z"); // 2 days after start
|
||
assert.equal(rotationIndex(t, dailyRotation), 2);
|
||
});
|
||
|
||
test("rotationIndex: 12h shifts cycle every half-day", () => {
|
||
const halfDayRotation = { ...ROTATION, shiftHours: 12 };
|
||
const t = Date.parse("2026-06-01T22:00:00Z"); // 6 hours after start
|
||
assert.equal(rotationIndex(t, halfDayRotation), 0);
|
||
const t2 = Date.parse("2026-06-02T04:00:00Z"); // 12 hours after start
|
||
assert.equal(rotationIndex(t2, halfDayRotation), 1);
|
||
}); |