diff --git a/packages/tracker/package.json b/packages/tracker/package.json index 8b60ba0d..6f654a01 100644 --- a/packages/tracker/package.json +++ b/packages/tracker/package.json @@ -6,7 +6,9 @@ "license": "ISC", "author": "", "main": "src/index.ts", - "scripts": {}, + "scripts": { + "test": "bun test" + }, "dependencies": { "@openstatus/db": "workspace:*", "@openstatus/tinybird": "workspace:*", @@ -14,6 +16,7 @@ }, "devDependencies": { "@openstatus/tsconfig": "workspace:*", + "bun-types": "catalog:", "typescript": "catalog:" } } diff --git a/packages/tracker/src/tracker.test.ts b/packages/tracker/src/tracker.test.ts new file mode 100644 index 00000000..0c4fddf3 --- /dev/null +++ b/packages/tracker/src/tracker.test.ts @@ -0,0 +1,409 @@ +import { describe, expect, test } from "bun:test"; + +import type { + Incident, + Maintenance, + StatusReport, + StatusReportUpdate, +} from "@openstatus/db/src/schema"; + +import { blacklistDates } from "./blacklist"; +import { classNames, statusDetails } from "./config"; +import { Tracker } from "./tracker"; +import { Status } from "./types"; + +// Tracker's internal data shape (one bucket per day). Kept inline so these +// tests need no DB/env — mirrors the `{ day, count, ok }` rows in `mock.ts`. +function day(date: string, count: number, ok: number) { + return { day: date, count, ok }; +} + +// Typed fixture factories (cast-free, complete objects + Partial overrides), +// matching the repo convention in packages/notifications/ms-teams. +type ReportWithUpdates = StatusReport & { + statusReportUpdates?: StatusReportUpdate[]; +}; + +function createIncident(overrides: Partial = {}): Incident { + return { + id: 1, + title: "", + summary: "", + status: "investigating", + monitorId: 1, + workspaceId: 1, + startedAt: new Date("2024-01-01T00:00:00.000Z"), + acknowledgedAt: null, + acknowledgedBy: null, + resolvedAt: null, + resolvedBy: null, + incidentScreenshotUrl: null, + recoveryScreenshotUrl: null, + autoResolved: false, + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-01-01T00:00:00.000Z"), + ...overrides, + }; +} + +function createMaintenance(overrides: Partial = {}): Maintenance { + return { + id: 1, + title: "Scheduled maintenance", + message: "Maintenance window", + from: new Date("2024-01-01T00:00:00.000Z"), + to: new Date("2024-01-01T01:00:00.000Z"), + workspaceId: 1, + pageId: null, + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-01-01T00:00:00.000Z"), + ...overrides, + }; +} + +function createStatusReportUpdate( + overrides: Partial = {}, +): StatusReportUpdate { + return { + id: 1, + status: "investigating", + date: new Date("2024-01-01T00:00:00.000Z"), + message: "Looking into it", + statusReportId: 1, + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-01-01T00:00:00.000Z"), + ...overrides, + }; +} + +function createStatusReport( + overrides: Partial = {}, +): ReportWithUpdates { + return { + id: 1, + status: "investigating", + title: "Status report", + workspaceId: 1, + pageId: null, + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-01-01T00:00:00.000Z"), + ...overrides, + }; +} + +const HOUR = 60 * 60 * 1000; + +describe("Tracker", () => { + describe("totalUptime", () => { + test("defaults to 100% when there is no data", () => { + expect(new Tracker({}).totalUptime).toBe(100); + }); + + test("is 100% when every check is ok", () => { + const tracker = new Tracker({ data: [day("2024-01-01", 864, 864)] }); + expect(tracker.totalUptime).toBe(100); + }); + + test("computes the ok/count ratio as a percentage", () => { + const tracker = new Tracker({ data: [day("2024-01-01", 200, 150)] }); + expect(tracker.totalUptime).toBe(75); + }); + + test("rounds to two decimal places", () => { + const tracker = new Tracker({ data: [day("2024-01-01", 3, 2)] }); + expect(tracker.totalUptime).toBe(66.67); + }); + + test("aggregates across multiple days", () => { + const tracker = new Tracker({ + data: [day("2024-01-02", 100, 100), day("2024-01-01", 100, 0)], + }); + expect(tracker.totalUptime).toBe(50); + }); + }); + + describe("isDataMissing", () => { + test("is true with no data", () => { + expect(new Tracker({}).isDataMissing).toBe(true); + }); + + test("is true when all buckets have a zero count", () => { + const tracker = new Tracker({ data: [day("2024-01-01", 0, 0)] }); + expect(tracker.isDataMissing).toBe(true); + }); + + test("is false once there is at least one check", () => { + const tracker = new Tracker({ data: [day("2024-01-01", 1, 1)] }); + expect(tracker.isDataMissing).toBe(false); + }); + }); + + describe("currentStatus: uptime thresholds", () => { + const cases: Array<[string, number, number, Status]> = [ + ["100% → Operational", 100, 100, Status.Operational], + ["exactly 98% → Operational", 100, 98, Status.Operational], + ["just below 98% → Degraded", 10_000, 9_799, Status.DegradedPerformance], + ["exactly 60% → Degraded", 100, 60, Status.DegradedPerformance], + ["just below 60% → Partial Outage", 10_000, 5_999, Status.PartialOutage], + ["just above 30% → Partial Outage", 10_000, 3_001, Status.PartialOutage], + ["0% → Major Outage", 100, 0, Status.MajorOutage], + ]; + + test.each(cases)("%s", (_label, count, ok, expected) => { + const tracker = new Tracker({ data: [day("2024-01-01", count, ok)] }); + expect(tracker.currentStatus).toBe(expected); + }); + + // NOTE: the lower boundary is `> 30`, while the other two are `>=`. So + // exactly 30% uptime is Major (not Partial) Outage. Pinning current + // behavior — the asymmetry is worth confirming with maintainers. + test("exactly 30% → Major Outage (boundary is `> 30`, not `>= 30`)", () => { + const tracker = new Tracker({ data: [day("2024-01-01", 100, 30)] }); + expect(tracker.currentStatus).toBe(Status.MajorOutage); + }); + }); + + describe("currentStatus: precedence", () => { + // currentStatus order: maintenance > report > incident > uptime. + const downData = [day("2024-01-01", 100, 0)]; // uptime → Major Outage + + test("ongoing maintenance wins over report, incident, and uptime", () => { + const now = Date.now(); + const tracker = new Tracker({ + data: downData, + maintenances: [ + createMaintenance({ + from: new Date(now - HOUR), + to: new Date(now + HOUR), + }), + ], + statusReports: [createStatusReport({ status: "investigating" })], + incidents: [createIncident({ resolvedAt: null })], + }); + expect(tracker.currentStatus).toBe(Status.UnderMaintenance); + }); + + test("ongoing report wins over incident and uptime", () => { + const tracker = new Tracker({ + data: downData, + statusReports: [createStatusReport({ status: "investigating" })], + incidents: [createIncident({ resolvedAt: null })], + }); + expect(tracker.currentStatus).toBe(Status.DegradedPerformance); + }); + + test("ongoing incident wins over uptime", () => { + const tracker = new Tracker({ + data: downData, + incidents: [createIncident({ resolvedAt: null })], + }); + expect(tracker.currentStatus).toBe(Status.Incident); + }); + + test("a 'monitoring' report is not treated as ongoing", () => { + const tracker = new Tracker({ + data: downData, + statusReports: [createStatusReport({ status: "monitoring" })], + incidents: [createIncident({ resolvedAt: null })], + }); + // report no longer ongoing → falls through to the ongoing incident + expect(tracker.currentStatus).toBe(Status.Incident); + }); + + test("a 'resolved' report is not treated as ongoing", () => { + const tracker = new Tracker({ + data: downData, + statusReports: [createStatusReport({ status: "resolved" })], + incidents: [createIncident({ resolvedAt: null })], + }); + // report no longer ongoing → falls through to the ongoing incident + expect(tracker.currentStatus).toBe(Status.Incident); + }); + + test("a resolved incident does not trigger Incident status", () => { + const tracker = new Tracker({ + data: [day("2024-01-01", 100, 100)], + incidents: [ + createIncident({ resolvedAt: new Date("2024-01-01T01:00:00.000Z") }), + ], + }); + expect(tracker.currentStatus).toBe(Status.Operational); + }); + + test("past and future maintenance windows are not ongoing", () => { + const now = Date.now(); + const past = new Tracker({ + data: [day("2024-01-01", 100, 100)], + maintenances: [ + createMaintenance({ + from: new Date(now - 2 * HOUR), + to: new Date(now - HOUR), + }), + ], + }); + const future = new Tracker({ + data: [day("2024-01-01", 100, 100)], + maintenances: [ + createMaintenance({ + from: new Date(now + HOUR), + to: new Date(now + 2 * HOUR), + }), + ], + }); + expect(past.currentStatus).toBe(Status.Operational); + expect(future.currentStatus).toBe(Status.Operational); + }); + }); + + describe("currentStatus: missing data", () => { + // For an explicit zero-count bucket, currentStatus reports Operational + // (uptime defaults to 100%) while the SAME tracker's `days` view reports + // Unknown/"Missing" for that bucket. Pinning the inconsistency on identical + // input; this is a question for maintainers, not a unilateral change. + test("reports Operational for a zero-count bucket that `days` calls Unknown", () => { + const tracker = new Tracker({ data: [day("2024-01-01", 0, 0)] }); + expect(tracker.isDataMissing).toBe(true); + expect(tracker.currentStatus).toBe(Status.Operational); + expect(tracker.days[0].status).toBe(Status.Unknown); + }); + }); + + describe("status presentation getters", () => { + test("currentVariant / currentDetails / currentClassName align with config", () => { + const tracker = new Tracker({ data: [day("2024-01-01", 100, 100)] }); + expect(tracker.currentStatus).toBe(Status.Operational); + expect(tracker.currentVariant).toBe("up"); + expect(tracker.currentDetails).toEqual(statusDetails[Status.Operational]); + expect(tracker.currentClassName).toBe(classNames.up); + }); + + test("toString is the short label of the current status", () => { + const tracker = new Tracker({ data: [day("2024-01-01", 100, 0)] }); + expect(tracker.currentStatus).toBe(Status.MajorOutage); + expect(tracker.toString).toBe(statusDetails[Status.MajorOutage].short); + }); + + test("getters reflect a non-operational status (under maintenance)", () => { + const now = Date.now(); + const tracker = new Tracker({ + data: [day("2024-01-01", 100, 100)], + maintenances: [ + createMaintenance({ + from: new Date(now - HOUR), + to: new Date(now + HOUR), + }), + ], + }); + expect(tracker.currentStatus).toBe(Status.UnderMaintenance); + expect(tracker.currentVariant).toBe("maintenance"); + expect(tracker.currentDetails).toEqual( + statusDetails[Status.UnderMaintenance], + ); + expect(tracker.currentClassName).toBe(classNames.maintenance); + }); + }); + + describe("days", () => { + test("maps an operational bucket with its label and variant", () => { + const [bucket] = new Tracker({ + data: [day("2024-01-01", 864, 864)], + }).days; + expect(bucket.status).toBe(Status.Operational); + expect(bucket.label).toBe(statusDetails[Status.Operational].short); + expect(bucket.variant).toBe("up"); + }); + + test("labels a zero-count bucket as Missing/Unknown", () => { + const [bucket] = new Tracker({ data: [day("2024-01-01", 0, 0)] }).days; + expect(bucket.status).toBe(Status.Unknown); + expect(bucket.label).toBe("Missing"); + }); + + test("annotates blacklisted dates with their reason", () => { + const [bucket] = new Tracker({ + // 2023-10-18: documented Vercel→Fly migration gap (see blacklist.ts) + data: [day("2023-10-18 00:00:00", 864, 864)], + }).days; + expect(bucket.blacklist).toBe(blacklistDates["Wed Oct 18 2023"]); + }); + + test("leaves non-blacklisted dates unannotated", () => { + const [bucket] = new Tracker({ + data: [day("2024-01-01 00:00:00", 864, 864)], + }).days; + expect(bucket.blacklist).toBeUndefined(); + }); + + describe("per-day precedence (maintenance > report > incident > uptime)", () => { + const DAY = "2024-01-01"; + const okData = [day(`${DAY} 00:00:00`, 864, 864)]; + + test("maintenance overlapping the day wins", () => { + const [bucket] = new Tracker({ + data: okData, + maintenances: [ + createMaintenance({ + from: new Date(`${DAY}T00:00:00.000Z`), + to: new Date(`${DAY}T12:00:00.000Z`), + }), + ], + statusReports: [ + createStatusReport({ + statusReportUpdates: [ + createStatusReportUpdate({ + date: new Date(`${DAY}T06:00:00.000Z`), + }), + ], + }), + ], + incidents: [ + createIncident({ startedAt: new Date(`${DAY}T03:00:00.000Z`) }), + ], + }).days; + expect(bucket.status).toBe(Status.UnderMaintenance); + }); + + test("a report dated on the day wins over an incident", () => { + const [bucket] = new Tracker({ + data: okData, + statusReports: [ + createStatusReport({ + statusReportUpdates: [ + createStatusReportUpdate({ + date: new Date(`${DAY}T06:00:00.000Z`), + }), + ], + }), + ], + incidents: [ + createIncident({ startedAt: new Date(`${DAY}T03:00:00.000Z`) }), + ], + }).days; + expect(bucket.status).toBe(Status.DegradedPerformance); + }); + + test("an incident on the day wins over uptime", () => { + const [bucket] = new Tracker({ + data: okData, + incidents: [ + createIncident({ startedAt: new Date(`${DAY}T03:00:00.000Z`) }), + ], + }).days; + expect(bucket.status).toBe(Status.Incident); + }); + + test("an incident resolved on a previous day does not affect this bucket", () => { + const [bucket] = new Tracker({ + data: [day("2024-01-02 00:00:00", 864, 864)], + incidents: [ + createIncident({ + startedAt: new Date("2024-01-01T12:00:00.000Z"), + resolvedAt: new Date("2024-01-01T13:00:00.000Z"), + }), + ], + }).days; + expect(bucket.status).toBe(Status.Operational); + }); + }); + }); +}); diff --git a/packages/tracker/tsconfig.json b/packages/tracker/tsconfig.json index bded80f3..f40fdec7 100644 --- a/packages/tracker/tsconfig.json +++ b/packages/tracker/tsconfig.json @@ -2,6 +2,7 @@ "extends": "@openstatus/tsconfig/base.json", "include": ["src", "*.ts"], "compilerOptions": { - "target": "ES2021" + "target": "ES2021", + "types": ["bun-types"] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7555312..1e204f9f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2667,6 +2667,9 @@ importers: '@openstatus/tsconfig': specifier: workspace:* version: link:../tsconfig + bun-types: + specifier: 'catalog:' + version: 1.3.14 typescript: specifier: 'catalog:' version: 5.9.3