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) <noreply@anthropic.com>
This commit is contained in:
1 parent
2be909b67c
commit
245eb4ccf3
2 files changed
+717
No files matched your search
@@ -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<BudgetPolicyRecord> & { 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<typeof createMockDb>;
|
||||
|
||||
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<typeof createMockDb>;
|
||||
|
||||
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<typeof createMockDb>;
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): 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<typeof createMockIncidentDb>;
|
||||
|
||||
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<typeof createMockIncidentDb>;
|
||||
|
||||
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<typeof createMockIncidentDb>;
|
||||
|
||||
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<typeof createMockIncidentDb>;
|
||||
|
||||
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<typeof createMockIncidentDb>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user