import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { costRoutes } from "../routes/costs.js"; import { errorHandler } from "../middleware/index.js"; function makeDb(overrides: Record = {}) { const selectChain = { from: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), leftJoin: vi.fn().mockReturnThis(), innerJoin: vi.fn().mockReturnThis(), groupBy: vi.fn().mockReturnThis(), orderBy: vi.fn().mockReturnThis(), limit: vi.fn().mockReturnThis(), then: vi.fn().mockResolvedValue([]), }; const thenableChain = Object.assign(Promise.resolve([]), selectChain); return { select: vi.fn().mockReturnValue(thenableChain), insert: vi.fn().mockReturnValue({ values: vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([]) }), }), update: vi.fn().mockReturnValue({ set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }), }), ...overrides, }; } const mockCompanyService = vi.hoisted(() => ({ getById: vi.fn(), update: vi.fn(), })); const mockAgentService = vi.hoisted(() => ({ getById: vi.fn(), update: vi.fn(), })); const mockHeartbeatService = vi.hoisted(() => ({ cancelBudgetScopeWork: vi.fn().mockResolvedValue(undefined), })); const mockLogActivity = vi.hoisted(() => vi.fn()); const mockFetchAllQuotaWindows = vi.hoisted(() => vi.fn()); const mockCostService = vi.hoisted(() => ({ createEvent: vi.fn(), summary: vi.fn().mockResolvedValue({ spendCents: 0 }), byAgent: vi.fn().mockResolvedValue([]), byAgentModel: vi.fn().mockResolvedValue([]), byProvider: vi.fn().mockResolvedValue([]), byBiller: vi.fn().mockResolvedValue([]), windowSpend: vi.fn().mockResolvedValue([]), byProject: vi.fn().mockResolvedValue([]), })); const mockFinanceService = vi.hoisted(() => ({ createEvent: vi.fn(), summary: vi.fn().mockResolvedValue({ debitCents: 0, creditCents: 0, netCents: 0, estimatedDebitCents: 0, eventCount: 0 }), byBiller: vi.fn().mockResolvedValue([]), byKind: vi.fn().mockResolvedValue([]), list: vi.fn().mockResolvedValue([]), })); const mockBudgetService = vi.hoisted(() => ({ overview: vi.fn().mockResolvedValue({ companyId: "company-1", policies: [], activeIncidents: [], pausedAgentCount: 0, pausedProjectCount: 0, pendingApprovalCount: 0, }), upsertPolicy: vi.fn(), resolveIncident: vi.fn(), })); vi.mock("../services/index.js", () => ({ budgetService: () => mockBudgetService, costService: () => mockCostService, financeService: () => mockFinanceService, companyService: () => mockCompanyService, agentService: () => mockAgentService, heartbeatService: () => mockHeartbeatService, logActivity: mockLogActivity, })); vi.mock("../services/quota-windows.js", () => ({ fetchAllQuotaWindows: mockFetchAllQuotaWindows, })); function createApp() { const app = express(); app.use(express.json()); app.use((req, _res, next) => { req.actor = { type: "board", userId: "board-user", source: "local_implicit" }; next(); }); app.use("/api", costRoutes(makeDb() as any)); app.use(errorHandler); return app; } function createAppWithActor(actor: any) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { req.actor = actor; next(); }); app.use("/api", costRoutes(makeDb() as any)); app.use(errorHandler); return app; } beforeEach(() => { vi.clearAllMocks(); mockCompanyService.update.mockResolvedValue({ id: "company-1", name: "Paperclip", budgetMonthlyCents: 100, spentMonthlyCents: 0, }); mockAgentService.update.mockResolvedValue({ id: "agent-1", companyId: "company-1", name: "Budget Agent", budgetMonthlyCents: 100, spentMonthlyCents: 0, }); mockBudgetService.upsertPolicy.mockResolvedValue(undefined); }); describe("cost routes", () => { it("accepts valid ISO date strings and passes them to cost summary routes", async () => { const app = createApp(); const res = await request(app) .get("/api/companies/company-1/costs/summary") .query({ from: "2026-01-01T00:00:00.000Z", to: "2026-01-31T23:59:59.999Z" }); expect(res.status).toBe(200); }); it("returns 400 for an invalid 'from' date string", async () => { const app = createApp(); const res = await request(app) .get("/api/companies/company-1/costs/summary") .query({ from: "not-a-date" }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/invalid 'from' date/i); }); it("returns 400 for an invalid 'to' date string", async () => { const app = createApp(); const res = await request(app) .get("/api/companies/company-1/costs/summary") .query({ to: "banana" }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/invalid 'to' date/i); }); it("returns finance summary rows for valid requests", async () => { const app = createApp(); const res = await request(app) .get("/api/companies/company-1/costs/finance-summary") .query({ from: "2026-02-01T00:00:00.000Z", to: "2026-02-28T23:59:59.999Z" }); expect(res.status).toBe(200); expect(mockFinanceService.summary).toHaveBeenCalled(); }); it("returns 400 for invalid finance event list limits", async () => { const app = createApp(); const res = await request(app) .get("/api/companies/company-1/costs/finance-events") .query({ limit: "0" }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/invalid 'limit'/i); }); it("accepts valid finance event list limits", async () => { const app = createApp(); const res = await request(app) .get("/api/companies/company-1/costs/finance-events") .query({ limit: "25" }); expect(res.status).toBe(200); expect(mockFinanceService.list).toHaveBeenCalledWith("company-1", undefined, 25); }); it("rejects company budget updates for board users outside the company", async () => { const app = createAppWithActor({ type: "board", userId: "board-user", source: "session", isInstanceAdmin: false, companyIds: ["company-2"], }); const res = await request(app) .patch("/api/companies/company-1/budgets") .send({ budgetMonthlyCents: 2500 }); expect(res.status).toBe(403); expect(mockCompanyService.update).not.toHaveBeenCalled(); }); it("rejects agent budget updates for board users outside the agent company", async () => { mockAgentService.getById.mockResolvedValue({ id: "agent-1", companyId: "company-1", name: "Budget Agent", budgetMonthlyCents: 100, spentMonthlyCents: 0, }); const app = createAppWithActor({ type: "board", userId: "board-user", source: "session", isInstanceAdmin: false, companyIds: ["company-2"], }); const res = await request(app) .patch("/api/agents/agent-1/budgets") .send({ budgetMonthlyCents: 2500 }); expect(res.status).toBe(403); expect(mockAgentService.update).not.toHaveBeenCalled(); }); });