import { describe, it, expect, beforeEach } from "vitest"; /** * Tests for budget incident lifecycle: creation, deduplication (D-08), * resolution, and auto-close of expired incidents (D-06). */ import type { BudgetPolicyRecord, BudgetIncidentRecord } from "../../src/shared/types"; // --------------------------------------------------------------------------- // Mock incident store simulating upsertBudgetIncident, resolveIncidentsForPolicy, // autoCloseExpiredIncidents behaviors from database.ts // --------------------------------------------------------------------------- function createMockIncidentDb() { const incidents: BudgetIncidentRecord[] = []; let idCounter = 0; function getCalendarMonthWindow(now: Date = new Date()): { start: string; end: string } { const year = now.getUTCFullYear(); const month = now.getUTCMonth(); const start = new Date(Date.UTC(year, month, 1)).toISOString(); const end = new Date(Date.UTC(year, month + 1, 1)).toISOString(); return { start, end }; } const budget = { getCalendarMonthWindow, upsertBudgetIncident( policy: BudgetPolicyRecord, thresholdType: "warn_threshold" | "hard_stop", amountObserved: number, ): string { const window = getCalendarMonthWindow(); const existing = incidents.find( (i) => i.policyId === policy.id && i.windowStart === window.start && i.thresholdType === thresholdType && i.status === "open", ); if (existing) { existing.amountObserved = amountObserved; existing.updatedAt = new Date().toISOString(); return existing.id; } const id = `inc-${++idCounter}`; const ts = new Date().toISOString(); incidents.push({ id, companyId: policy.companyId, policyId: policy.id, scopeType: policy.scopeType, scopeId: policy.scopeId, metric: policy.metric, windowKind: policy.windowKind, windowStart: window.start, windowEnd: window.end, thresholdType, amountLimit: policy.amount, amountObserved, status: "open", resolvedAt: null, createdAt: ts, updatedAt: ts, }); return id; }, resolveIncidentsForPolicy(policyId: string, resolutionStatus: string): number { let count = 0; const ts = new Date().toISOString(); for (const incident of incidents) { if (incident.policyId === policyId && incident.status === "open") { incident.status = resolutionStatus as BudgetIncidentRecord["status"]; incident.resolvedAt = ts; incident.updatedAt = ts; count++; } } return count; }, resolveWarnIncidentsForPolicy(policyId: string, resolutionStatus: string): number { let count = 0; const ts = new Date().toISOString(); for (const incident of incidents) { if (incident.policyId === policyId && incident.status === "open" && incident.thresholdType === "warn_threshold") { incident.status = resolutionStatus as BudgetIncidentRecord["status"]; incident.resolvedAt = ts; incident.updatedAt = ts; count++; } } return count; }, autoCloseExpiredIncidents(companyId: string): number { let count = 0; const now = new Date().toISOString(); for (const incident of incidents) { if ( incident.companyId === companyId && incident.status === "open" && incident.windowEnd <= now ) { incident.status = "auto_closed"; incident.resolvedAt = now; incident.updatedAt = now; count++; } } return count; }, listBudgetIncidents(companyId: string, statusFilter: string = "all"): BudgetIncidentRecord[] { return incidents .filter((i) => i.companyId === companyId && (statusFilter === "all" || i.status === statusFilter)) .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); }, resolveBudgetIncident(incidentId: string, companyId: string): boolean { const incident = incidents.find((i) => i.id === incidentId && i.companyId === companyId && i.status === "open"); if (!incident) return false; const ts = new Date().toISOString(); incident.status = "resolved"; incident.resolvedAt = ts; incident.updatedAt = ts; return true; }, }; return { incidents, core: { budget } }; } function makePolicy(overrides: Partial = {}): BudgetPolicyRecord { return { id: "pol-1", companyId: "c1", scopeType: "agent", scopeId: "a1", metric: "billed_cents", windowKind: "calendar_month_utc", amount: 10000, warnPercent: 80, hardStopEnabled: true, notifyEnabled: true, isActive: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ...overrides, }; } // ====================================================================== // Tests // ====================================================================== describe("upsertBudgetIncident", () => { let db: ReturnType; beforeEach(() => { db = createMockIncidentDb(); }); it("creates new incident when none exists for policy+window+threshold_type", () => { const policy = makePolicy(); const id = db.core.budget.upsertBudgetIncident(policy, "warn_threshold", 8500); expect(id).toBeTruthy(); expect(db.incidents).toHaveLength(1); expect(db.incidents[0]!.thresholdType).toBe("warn_threshold"); expect(db.incidents[0]!.amountObserved).toBe(8500); expect(db.incidents[0]!.status).toBe("open"); }); it("updates amount_observed when open incident already exists (D-08 dedup)", () => { const policy = makePolicy(); const id1 = db.core.budget.upsertBudgetIncident(policy, "warn_threshold", 8500); const id2 = db.core.budget.upsertBudgetIncident(policy, "warn_threshold", 9200); expect(id1).toBe(id2); // Same incident, not a new one expect(db.incidents).toHaveLength(1); // Still just one expect(db.incidents[0]!.amountObserved).toBe(9200); // Updated }); it("creates separate incidents for different threshold types", () => { const policy = makePolicy(); db.core.budget.upsertBudgetIncident(policy, "warn_threshold", 8500); db.core.budget.upsertBudgetIncident(policy, "hard_stop", 10500); expect(db.incidents).toHaveLength(2); expect(db.incidents[0]!.thresholdType).toBe("warn_threshold"); expect(db.incidents[1]!.thresholdType).toBe("hard_stop"); }); }); describe("resolveIncidentsForPolicy", () => { let db: ReturnType; beforeEach(() => { db = createMockIncidentDb(); }); it("sets status to provided resolution reason for all open incidents", () => { const policy = makePolicy(); db.core.budget.upsertBudgetIncident(policy, "warn_threshold", 8500); db.core.budget.upsertBudgetIncident(policy, "hard_stop", 10500); const count = db.core.budget.resolveIncidentsForPolicy("pol-1", "auto_resolved_limit_increase"); expect(count).toBe(2); expect(db.incidents[0]!.status).toBe("auto_resolved_limit_increase"); expect(db.incidents[1]!.status).toBe("auto_resolved_limit_increase"); expect(db.incidents[0]!.resolvedAt).toBeTruthy(); }); it("does not affect already-resolved incidents", () => { const policy = makePolicy(); db.core.budget.upsertBudgetIncident(policy, "warn_threshold", 8500); db.core.budget.resolveIncidentsForPolicy("pol-1", "resolved"); // Now try to resolve again const count = db.core.budget.resolveIncidentsForPolicy("pol-1", "auto_resolved_limit_increase"); expect(count).toBe(0); expect(db.incidents[0]!.status).toBe("resolved"); // Unchanged }); }); describe("autoCloseExpiredIncidents", () => { let db: ReturnType; beforeEach(() => { db = createMockIncidentDb(); }); it("closes incidents whose window_end is in the past", () => { const _policy = makePolicy(); // Manually create an expired incident db.incidents.push({ id: "expired-1", companyId: "c1", policyId: "pol-1", scopeType: "agent", scopeId: "a1", metric: "billed_cents", windowKind: "calendar_month_utc", windowStart: "2025-01-01T00:00:00.000Z", windowEnd: "2025-02-01T00:00:00.000Z", // In the past thresholdType: "warn_threshold", amountLimit: 10000, amountObserved: 8500, status: "open", resolvedAt: null, createdAt: "2025-01-15T00:00:00.000Z", updatedAt: "2025-01-15T00:00:00.000Z", }); const count = db.core.budget.autoCloseExpiredIncidents("c1"); expect(count).toBe(1); expect(db.incidents[0]!.status).toBe("auto_closed"); expect(db.incidents[0]!.resolvedAt).toBeTruthy(); }); it("does not close incidents whose window_end is in the future", () => { const policy = makePolicy(); db.core.budget.upsertBudgetIncident(policy, "warn_threshold", 8500); // The auto-created incident has window_end in the future (end of current month) const count = db.core.budget.autoCloseExpiredIncidents("c1"); expect(count).toBe(0); expect(db.incidents[0]!.status).toBe("open"); }); }); describe("listBudgetIncidents", () => { let db: ReturnType; beforeEach(() => { db = createMockIncidentDb(); }); it("returns all incidents for a company when status is 'all'", () => { const policy = makePolicy(); db.core.budget.upsertBudgetIncident(policy, "warn_threshold", 8500); db.core.budget.upsertBudgetIncident(policy, "hard_stop", 10500); db.core.budget.resolveIncidentsForPolicy("pol-1", "resolved"); const all = db.core.budget.listBudgetIncidents("c1", "all"); expect(all).toHaveLength(2); }); it("filters by status when specified", () => { const policy = makePolicy(); db.core.budget.upsertBudgetIncident(policy, "warn_threshold", 8500); db.core.budget.upsertBudgetIncident(policy, "hard_stop", 10500); // Resolve only warn db.core.budget.resolveWarnIncidentsForPolicy("pol-1", "resolved"); const open = db.core.budget.listBudgetIncidents("c1", "open"); expect(open).toHaveLength(1); expect(open[0]!.thresholdType).toBe("hard_stop"); }); }); describe("checkAutoResolve (D-06)", () => { let db: ReturnType; beforeEach(() => { db = createMockIncidentDb(); }); it("auto-resolves incidents when policy limit increases above observed amount", () => { const policy = makePolicy({ amount: 10000 }); db.core.budget.upsertBudgetIncident(policy, "hard_stop", 10500); expect(db.incidents[0]!.status).toBe("open"); // Simulate limit increase: now checking if spent < new limit // If spent (10500) < new policy.amount (20000), resolve const spent = 10500; const newAmount = 20000; if (spent < newAmount) { db.core.budget.resolveIncidentsForPolicy("pol-1", "auto_resolved_limit_increase"); } expect(db.incidents[0]!.status).toBe("auto_resolved_limit_increase"); }); it("auto-resolves only warn incidents when below warn threshold but above hard stop", () => { const policy = makePolicy({ amount: 10000, warnPercent: 80 }); db.core.budget.upsertBudgetIncident(policy, "warn_threshold", 8500); db.core.budget.upsertBudgetIncident(policy, "hard_stop", 10500); // Spent is now 7500 (below warn threshold of 8000 = 10000*80/100) const spent = 7500; const _warnAmount = Math.floor((policy.amount * policy.warnPercent) / 100); if (spent < policy.amount) { db.core.budget.resolveIncidentsForPolicy("pol-1", "auto_resolved_limit_increase"); } // Both resolved since spent < policy.amount expect(db.incidents.every((i) => i.status === "auto_resolved_limit_increase")).toBe(true); }); });