import { describe, it, expect, vi, beforeEach } from "vitest"; import { TierLimitError } from "../tiers"; const mockDb = vi.hoisted(() => ({ select: vi.fn(), insert: vi.fn(), execute: vi.fn(), })); vi.mock("@epicure/db", () => ({ db: mockDb, tierDefinitions: { tier: "tier" }, userUsage: { userId: "user_id", month: "month", aiCallsUsed: "ai_calls_used", recipeCount: "recipe_count", storageUsedMb: "storage_used_mb", }, eq: vi.fn((col, val) => ({ col, val, op: "eq" })), and: vi.fn((...args) => ({ args, op: "and" })), sql: vi.fn((strings, ...values) => ({ strings, values, op: "sql" })), })); // Import after mock const { checkAndIncrementTierLimit, incrementUsage, refundAiCall } = await import("../tiers"); function makeChain(finalValue: unknown) { const chain = { from: vi.fn().mockReturnThis(), where: vi.fn().mockResolvedValue(finalValue), }; return chain; } function makeInsertChain() { const chain = { values: vi.fn().mockReturnThis(), onConflictDoUpdate: vi.fn().mockResolvedValue(undefined), }; return chain; } beforeEach(() => { vi.clearAllMocks(); }); describe("TierLimitError", () => { it("has correct name and message", () => { const err = new TierLimitError("aiCall", "free"); expect(err.name).toBe("TierLimitError"); expect(err.message).toContain("aiCall"); expect(err.message).toContain("free"); expect(err).toBeInstanceOf(Error); }); }); describe("checkAndIncrementTierLimit", () => { const tierDef = { tier: "free", maxRecipes: 10, aiCallsPerMonth: 5, storageMb: 100, maxPublicRecipes: 3, }; it("does not throw when the atomic upsert returns a row (under limit)", async () => { mockDb.select.mockReturnValueOnce(makeChain([tierDef])); mockDb.execute.mockResolvedValueOnce([{ aiCallsUsed: 4 }]); await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined(); }); it("throws TierLimitError when the upsert's WHERE clause excludes the row (limit reached)", async () => { mockDb.select.mockReturnValueOnce(makeChain([tierDef])); mockDb.execute.mockResolvedValueOnce([]); await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).rejects.toThrow(TierLimitError); }); it("throws TierLimitError for recipe key when limit reached", async () => { mockDb.select.mockReturnValueOnce(makeChain([tierDef])); mockDb.execute.mockResolvedValueOnce([]); await expect(checkAndIncrementTierLimit("user1", "free", "recipe")).rejects.toThrow(TierLimitError); }); it("does not throw when tier definition does not exist", async () => { mockDb.select.mockReturnValueOnce(makeChain([])); await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined(); expect(mockDb.execute).not.toHaveBeenCalled(); }); }); describe("refundAiCall", () => { it("issues a decrement-with-floor UPDATE for the current month", async () => { mockDb.execute.mockResolvedValueOnce(undefined); await refundAiCall("user1"); expect(mockDb.execute).toHaveBeenCalledTimes(1); }); }); describe("incrementUsage", () => { it("calls insert with correct aiCall initial values", async () => { const chain = makeInsertChain(); mockDb.insert.mockReturnValue(chain); await incrementUsage("user1", "aiCall"); expect(chain.values).toHaveBeenCalledWith( expect.objectContaining({ userId: "user1", aiCallsUsed: 1, recipeCount: 0, storageUsedMb: 0, }) ); }); it("calls insert with correct recipe initial values", async () => { const chain = makeInsertChain(); mockDb.insert.mockReturnValue(chain); await incrementUsage("user1", "recipe"); expect(chain.values).toHaveBeenCalledWith( expect.objectContaining({ recipeCount: 1, aiCallsUsed: 0, storageUsedMb: 0, }) ); }); it("respects custom amount", async () => { const chain = makeInsertChain(); mockDb.insert.mockReturnValue(chain); await incrementUsage("user1", "storage", 50); expect(chain.values).toHaveBeenCalledWith( expect.objectContaining({ storageUsedMb: 50 }) ); }); it("uses onConflictDoUpdate to increment (not overwrite)", async () => { const chain = makeInsertChain(); mockDb.insert.mockReturnValue(chain); await incrementUsage("user1", "aiCall"); expect(chain.onConflictDoUpdate).toHaveBeenCalledWith( expect.objectContaining({ set: expect.objectContaining({ aiCallsUsed: expect.anything() }), }) ); }); });