From 245eb4ccf3f573050b32f0219e7739213cab1891 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 26 Mar 2026 20:43:50 -0400 Subject: [PATCH] test(05-01): add tests for budget policy evaluation and incident lifecycle - Budget policy CRUD (save, update, delete) - getActivePoliciesForRun with multi-scope matching - getSpentInWindow with cents aggregation per scope (company/agent/project) - evaluateBudgetGate strictest-wins logic (D-01) - upsertBudgetIncident with deduplication (D-08) - resolveIncidentsForPolicy and autoCloseExpiredIncidents (D-06) Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/unit/budget-enforcement.test.ts | 378 ++++++++++++++++++++++++++ tests/unit/budget-incidents.test.ts | 339 +++++++++++++++++++++++ 2 files changed, 717 insertions(+) create mode 100644 tests/unit/budget-enforcement.test.ts create mode 100644 tests/unit/budget-incidents.test.ts diff --git a/tests/unit/budget-enforcement.test.ts b/tests/unit/budget-enforcement.test.ts new file mode 100644 index 00000000..aa5a0cba --- /dev/null +++ b/tests/unit/budget-enforcement.test.ts @@ -0,0 +1,378 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * Tests for budget policy evaluation and cost aggregation logic. + * + * Tests pure functions extracted from the budget enforcement service: + * - getSpentInWindow: aggregates cost_entries by scope in cents + * - getActivePoliciesForRun: finds matching policies for company+agent+project + * - saveBudgetPolicy / deleteBudgetPolicy CRUD + * - evaluateBudgetGate: returns blocked when ANY scope is exhausted (strictest-wins D-01) + */ + +import type { BudgetPolicyRecord } from "../../src/shared/types"; + +// --------------------------------------------------------------------------- +// Helper: simulate getSpentInWindow (SUM(amount_usd) * 100 rounded to cents) +// --------------------------------------------------------------------------- +function computeSpentCents(costEntries: Array<{ amountUsd: number | null }>): number { + const total = costEntries.reduce((sum, c) => sum + (c.amountUsd ?? 0), 0); + return Math.round(total * 100); +} + +// --------------------------------------------------------------------------- +// Helper: simulate getCalendarMonthWindow +// --------------------------------------------------------------------------- +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 }; +} + +// --------------------------------------------------------------------------- +// Helper: simulate evaluateBudgetGate (strictest-wins per D-01) +// --------------------------------------------------------------------------- +interface BudgetGateResult { + allowed: boolean; + blockedBy: Array<{ + policyId: string; + scopeType: string; + scopeId: string; + spent: number; + limit: number; + }>; +} + +function evaluateBudgetGate( + policies: BudgetPolicyRecord[], + getSpent: (policy: BudgetPolicyRecord) => number, +): BudgetGateResult { + if (policies.length === 0) { + return { allowed: true, blockedBy: [] }; + } + + const blockedBy: BudgetGateResult["blockedBy"] = []; + for (const policy of policies) { + if (!policy.hardStopEnabled) continue; + const spent = getSpent(policy); + if (spent >= policy.amount) { + blockedBy.push({ + policyId: policy.id, + scopeType: policy.scopeType, + scopeId: policy.scopeId, + spent, + limit: policy.amount, + }); + } + } + + return { allowed: blockedBy.length === 0, blockedBy }; +} + +// --------------------------------------------------------------------------- +// Mock database for CRUD testing +// --------------------------------------------------------------------------- +function createMockDb() { + const policies: BudgetPolicyRecord[] = []; + const costEntries: Array<{ + companyId: string; + agentId: string | null; + projectId: string | null; + amountUsd: number; + createdAt: string; + }> = []; + + return { + policies, + costEntries, + + saveBudgetPolicy(input: Partial & { companyId: string; scopeType: string; scopeId: string; amount: number }) { + const id = input.id ?? crypto.randomUUID(); + const existing = policies.findIndex((p) => p.id === id); + const ts = new Date().toISOString(); + const policy: BudgetPolicyRecord = { + id, + companyId: input.companyId, + scopeType: input.scopeType as "company" | "agent" | "project", + scopeId: input.scopeId, + metric: input.metric ?? "billed_cents", + windowKind: input.windowKind ?? "calendar_month_utc", + amount: input.amount, + warnPercent: input.warnPercent ?? 80, + hardStopEnabled: input.hardStopEnabled ?? true, + notifyEnabled: input.notifyEnabled ?? true, + isActive: input.isActive ?? true, + createdAt: existing >= 0 ? policies[existing]!.createdAt : ts, + updatedAt: ts, + }; + if (existing >= 0) { + policies[existing] = policy; + } else { + policies.push(policy); + } + return id; + }, + + deleteBudgetPolicy(companyId: string, policyId: string) { + const idx = policies.findIndex((p) => p.id === policyId && p.companyId === companyId); + if (idx >= 0) policies.splice(idx, 1); + }, + + listBudgetPolicies(companyId: string) { + return policies + .filter((p) => p.companyId === companyId) + .sort((a, b) => a.scopeType.localeCompare(b.scopeType) || a.createdAt.localeCompare(b.createdAt)); + }, + + getActivePoliciesForRun(companyId: string, agentId: string, projectId: string | null) { + return policies.filter( + (p) => + p.companyId === companyId && + p.isActive && + ((p.scopeType === "company" && p.scopeId === companyId) || + (p.scopeType === "agent" && p.scopeId === agentId) || + (p.scopeType === "project" && projectId !== null && p.scopeId === projectId)), + ); + }, + + getSpentInWindow(policy: BudgetPolicyRecord): number { + const window = getCalendarMonthWindow(); + let filtered = costEntries.filter( + (c) => c.companyId === policy.companyId && c.createdAt >= window.start && c.createdAt < window.end, + ); + if (policy.scopeType === "agent") { + filtered = filtered.filter((c) => c.agentId === policy.scopeId); + } else if (policy.scopeType === "project") { + filtered = filtered.filter((c) => c.projectId === policy.scopeId); + } + return computeSpentCents(filtered); + }, + }; +} + +// ====================================================================== +// Tests +// ====================================================================== + +describe("Budget Policy CRUD", () => { + let db: ReturnType; + + beforeEach(() => { + db = createMockDb(); + }); + + it("saveBudgetPolicy creates new policy and returns its ID", () => { + const id = db.saveBudgetPolicy({ + companyId: "c1", + scopeType: "company", + scopeId: "c1", + amount: 10000, + }); + expect(id).toBeTruthy(); + expect(db.policies).toHaveLength(1); + expect(db.policies[0]!.amount).toBe(10000); + expect(db.policies[0]!.scopeType).toBe("company"); + }); + + it("saveBudgetPolicy updates existing policy when ID provided", () => { + const id = db.saveBudgetPolicy({ + companyId: "c1", + scopeType: "company", + scopeId: "c1", + amount: 10000, + }); + db.saveBudgetPolicy({ + id, + companyId: "c1", + scopeType: "company", + scopeId: "c1", + amount: 20000, + }); + expect(db.policies).toHaveLength(1); + expect(db.policies[0]!.amount).toBe(20000); + }); + + it("deleteBudgetPolicy removes policy by ID", () => { + const id = db.saveBudgetPolicy({ + companyId: "c1", + scopeType: "company", + scopeId: "c1", + amount: 10000, + }); + expect(db.policies).toHaveLength(1); + db.deleteBudgetPolicy("c1", id); + expect(db.policies).toHaveLength(0); + }); +}); + +describe("getActivePoliciesForRun", () => { + let db: ReturnType; + + beforeEach(() => { + db = createMockDb(); + }); + + it("returns all active policies matching company+agent+project scopes", () => { + db.saveBudgetPolicy({ companyId: "c1", scopeType: "company", scopeId: "c1", amount: 50000 }); + db.saveBudgetPolicy({ companyId: "c1", scopeType: "agent", scopeId: "a1", amount: 10000 }); + db.saveBudgetPolicy({ companyId: "c1", scopeType: "project", scopeId: "p1", amount: 20000 }); + // Different company — should not match + db.saveBudgetPolicy({ companyId: "c2", scopeType: "company", scopeId: "c2", amount: 99999 }); + + const result = db.getActivePoliciesForRun("c1", "a1", "p1"); + expect(result).toHaveLength(3); + expect(result.map((p) => p.scopeType).sort()).toEqual(["agent", "company", "project"]); + }); + + it("returns empty array when no policies exist (D-02: no policy = unlimited)", () => { + const result = db.getActivePoliciesForRun("c1", "a1", null); + expect(result).toEqual([]); + }); + + it("excludes project scope when projectId is null", () => { + db.saveBudgetPolicy({ companyId: "c1", scopeType: "company", scopeId: "c1", amount: 50000 }); + db.saveBudgetPolicy({ companyId: "c1", scopeType: "project", scopeId: "p1", amount: 20000 }); + + const result = db.getActivePoliciesForRun("c1", "a1", null); + expect(result).toHaveLength(1); + expect(result[0]!.scopeType).toBe("company"); + }); +}); + +describe("getSpentInWindow", () => { + let db: ReturnType; + + beforeEach(() => { + db = createMockDb(); + }); + + it("returns SUM(amount_usd)*100 rounded to integer cents for company scope", () => { + const now = new Date(); + db.costEntries.push( + { companyId: "c1", agentId: "a1", projectId: null, amountUsd: 1.50, createdAt: now.toISOString() }, + { companyId: "c1", agentId: "a2", projectId: null, amountUsd: 2.30, createdAt: now.toISOString() }, + ); + + const policy: BudgetPolicyRecord = { + id: "pol1", companyId: "c1", scopeType: "company", scopeId: "c1", + metric: "billed_cents", windowKind: "calendar_month_utc", + amount: 100000, warnPercent: 80, hardStopEnabled: true, notifyEnabled: true, + isActive: true, createdAt: now.toISOString(), updatedAt: now.toISOString(), + }; + const spent = db.getSpentInWindow(policy); + expect(spent).toBe(380); // (1.50 + 2.30) * 100 = 380 cents + }); + + it("filters by agent_id for agent scope", () => { + const now = new Date(); + db.costEntries.push( + { companyId: "c1", agentId: "a1", projectId: null, amountUsd: 5.00, createdAt: now.toISOString() }, + { companyId: "c1", agentId: "a2", projectId: null, amountUsd: 3.00, createdAt: now.toISOString() }, + ); + + const policy: BudgetPolicyRecord = { + id: "pol1", companyId: "c1", scopeType: "agent", scopeId: "a1", + metric: "billed_cents", windowKind: "calendar_month_utc", + amount: 100000, warnPercent: 80, hardStopEnabled: true, notifyEnabled: true, + isActive: true, createdAt: now.toISOString(), updatedAt: now.toISOString(), + }; + const spent = db.getSpentInWindow(policy); + expect(spent).toBe(500); // Only a1's $5.00 = 500 cents + }); + + it("filters by project_id for project scope", () => { + const now = new Date(); + db.costEntries.push( + { companyId: "c1", agentId: "a1", projectId: "p1", amountUsd: 7.50, createdAt: now.toISOString() }, + { companyId: "c1", agentId: "a1", projectId: "p2", amountUsd: 2.00, createdAt: now.toISOString() }, + ); + + const policy: BudgetPolicyRecord = { + id: "pol1", companyId: "c1", scopeType: "project", scopeId: "p1", + metric: "billed_cents", windowKind: "calendar_month_utc", + amount: 100000, warnPercent: 80, hardStopEnabled: true, notifyEnabled: true, + isActive: true, createdAt: now.toISOString(), updatedAt: now.toISOString(), + }; + const spent = db.getSpentInWindow(policy); + expect(spent).toBe(750); // Only p1's $7.50 = 750 cents + }); +}); + +describe("evaluateBudgetGate", () => { + it("returns allowed:true with empty blockedBy when no policies exist (D-02)", () => { + const result = evaluateBudgetGate([], vi.fn()); + expect(result).toEqual({ allowed: true, blockedBy: [] }); + }); + + it("returns allowed:true when no policies are exhausted", () => { + const policy: BudgetPolicyRecord = { + id: "pol1", companyId: "c1", scopeType: "company", scopeId: "c1", + metric: "billed_cents", windowKind: "calendar_month_utc", + amount: 10000, warnPercent: 80, hardStopEnabled: true, notifyEnabled: true, + isActive: true, createdAt: "", updatedAt: "", + }; + const getSpent = vi.fn().mockReturnValue(5000); // 50% spent + + const result = evaluateBudgetGate([policy], getSpent); + expect(result.allowed).toBe(true); + expect(result.blockedBy).toHaveLength(0); + }); + + it("returns blocked when ANY scope is exhausted (strictest-wins D-01)", () => { + const companyPolicy: BudgetPolicyRecord = { + id: "pol1", companyId: "c1", scopeType: "company", scopeId: "c1", + metric: "billed_cents", windowKind: "calendar_month_utc", + amount: 100000, warnPercent: 80, hardStopEnabled: true, notifyEnabled: true, + isActive: true, createdAt: "", updatedAt: "", + }; + const agentPolicy: BudgetPolicyRecord = { + id: "pol2", companyId: "c1", scopeType: "agent", scopeId: "a1", + metric: "billed_cents", windowKind: "calendar_month_utc", + amount: 5000, warnPercent: 80, hardStopEnabled: true, notifyEnabled: true, + isActive: true, createdAt: "", updatedAt: "", + }; + + const getSpent = vi.fn((policy: BudgetPolicyRecord) => { + if (policy.scopeType === "company") return 50000; // 50% of company + return 5500; // 110% of agent + }); + + const result = evaluateBudgetGate([companyPolicy, agentPolicy], getSpent); + expect(result.allowed).toBe(false); + expect(result.blockedBy).toHaveLength(1); + expect(result.blockedBy[0]!.policyId).toBe("pol2"); + expect(result.blockedBy[0]!.scopeType).toBe("agent"); + }); + + it("skips policies where hardStopEnabled is false", () => { + const policy: BudgetPolicyRecord = { + id: "pol1", companyId: "c1", scopeType: "company", scopeId: "c1", + metric: "billed_cents", windowKind: "calendar_month_utc", + amount: 1000, warnPercent: 80, hardStopEnabled: false, notifyEnabled: true, + isActive: true, createdAt: "", updatedAt: "", + }; + const getSpent = vi.fn().mockReturnValue(5000); // Way over limit + + const result = evaluateBudgetGate([policy], getSpent); + expect(result.allowed).toBe(true); + expect(result.blockedBy).toHaveLength(0); + }); +}); + +describe("getCalendarMonthWindow", () => { + it("returns start of current month and start of next month in UTC", () => { + const testDate = new Date("2026-03-15T10:30:00Z"); + const window = getCalendarMonthWindow(testDate); + expect(window.start).toBe("2026-03-01T00:00:00.000Z"); + expect(window.end).toBe("2026-04-01T00:00:00.000Z"); + }); + + it("handles December rollover to January next year", () => { + const testDate = new Date("2026-12-25T10:30:00Z"); + const window = getCalendarMonthWindow(testDate); + expect(window.start).toBe("2026-12-01T00:00:00.000Z"); + expect(window.end).toBe("2027-01-01T00:00:00.000Z"); + }); +}); diff --git a/tests/unit/budget-incidents.test.ts b/tests/unit/budget-incidents.test.ts new file mode 100644 index 00000000..df3a35d0 --- /dev/null +++ b/tests/unit/budget-incidents.test.ts @@ -0,0 +1,339 @@ +import { describe, it, expect, vi, 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 }; + } + + return { + incidents, + 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; + }, + }; +} + +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.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.upsertBudgetIncident(policy, "warn_threshold", 8500); + const id2 = db.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.upsertBudgetIncident(policy, "warn_threshold", 8500); + db.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.upsertBudgetIncident(policy, "warn_threshold", 8500); + db.upsertBudgetIncident(policy, "hard_stop", 10500); + + const count = db.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.upsertBudgetIncident(policy, "warn_threshold", 8500); + db.resolveIncidentsForPolicy("pol-1", "resolved"); + // Now try to resolve again + const count = db.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.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.upsertBudgetIncident(policy, "warn_threshold", 8500); + // The auto-created incident has window_end in the future (end of current month) + + const count = db.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.upsertBudgetIncident(policy, "warn_threshold", 8500); + db.upsertBudgetIncident(policy, "hard_stop", 10500); + db.resolveIncidentsForPolicy("pol-1", "resolved"); + + const all = db.listBudgetIncidents("c1", "all"); + expect(all).toHaveLength(2); + }); + + it("filters by status when specified", () => { + const policy = makePolicy(); + db.upsertBudgetIncident(policy, "warn_threshold", 8500); + db.upsertBudgetIncident(policy, "hard_stop", 10500); + // Resolve only warn + db.resolveWarnIncidentsForPolicy("pol-1", "resolved"); + + const open = db.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.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.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.upsertBudgetIncident(policy, "warn_threshold", 8500); + db.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.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); + }); +});