import { describe, it, expect, vi, beforeEach } from "vitest"; import { TierLimitError } from "../tiers"; const mockDb = vi.hoisted(() => ({ select: vi.fn(), execute: vi.fn(), })); vi.mock("@epicure/db", () => ({ db: mockDb, tierDefinitions: { tier: "tier" }, users: { id: "id", tier: "tier", avatarSizeMb: "avatar_size_mb" }, userUsage: { userId: "user_id", month: "month", aiCallsUsed: "ai_calls_used" }, recipes: { id: "id", authorId: "author_id" }, recipePhotos: { recipeId: "recipe_id", sizeMb: "size_mb" }, ratings: { userId: "user_id", photoSizeMb: "photo_size_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, getRecipeCount, getStorageUsedMb, refundAiCall } = await import("../tiers"); function makeChain(finalValue: unknown) { return { from: vi.fn().mockReturnThis(), innerJoin: vi.fn().mockReturnThis(), where: vi.fn().mockResolvedValue(finalValue), }; } 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, }; describe("aiCall (monthly, atomic upsert)", () => { it("does not throw when the atomic upsert returns a row (under limit)", async () => { mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }])); 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([{ tier: "free" }])); mockDb.select.mockReturnValueOnce(makeChain([tierDef])); mockDb.execute.mockResolvedValueOnce([]); await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).rejects.toThrow(TierLimitError); }); it("re-reads the current tier from the DB instead of trusting the caller's fallbackTier", async () => { mockDb.select.mockReturnValueOnce(makeChain([{ tier: "pro" }])); mockDb.select.mockReturnValueOnce(makeChain([{ ...tierDef, tier: "pro" }])); mockDb.execute.mockResolvedValueOnce([{ aiCallsUsed: 4 }]); // caller still thinks it's "free" (stale session), but DB says "pro" await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined(); }); it("does not throw when tier definition does not exist", async () => { mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }])); mockDb.select.mockReturnValueOnce(makeChain([])); await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined(); expect(mockDb.execute).not.toHaveBeenCalled(); }); }); describe("recipe (lifetime total, live count)", () => { it("does not throw when the live count is under the limit", async () => { mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }])); mockDb.select.mockReturnValueOnce(makeChain([tierDef])); mockDb.select.mockReturnValueOnce(makeChain([{ n: 9 }])); await expect(checkAndIncrementTierLimit("user1", "free", "recipe")).resolves.toBeUndefined(); expect(mockDb.execute).not.toHaveBeenCalled(); }); it("throws TierLimitError when the live count has already reached the limit", async () => { mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }])); mockDb.select.mockReturnValueOnce(makeChain([tierDef])); mockDb.select.mockReturnValueOnce(makeChain([{ n: 10 }])); await expect(checkAndIncrementTierLimit("user1", "free", "recipe")).rejects.toThrow(TierLimitError); }); it("does not query the count when the tier is unlimited", async () => { mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }])); mockDb.select.mockReturnValueOnce(makeChain([{ ...tierDef, maxRecipes: -1 }])); await expect(checkAndIncrementTierLimit("user1", "free", "recipe")).resolves.toBeUndefined(); expect(mockDb.select).toHaveBeenCalledTimes(2); }); }); describe("storage (lifetime total, live sum)", () => { it("does not throw when the live sum is under the limit", async () => { mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }])); mockDb.select.mockReturnValueOnce(makeChain([tierDef])); mockDb.select.mockReturnValueOnce(makeChain([{ total: 40 }])); // recipePhotos mockDb.select.mockReturnValueOnce(makeChain([{ total: 10 }])); // ratings mockDb.select.mockReturnValueOnce(makeChain([{ avatarSizeMb: 5 }])); // users await expect(checkAndIncrementTierLimit("user1", "free", "storage", 20)).resolves.toBeUndefined(); }); it("throws TierLimitError when the live sum plus amount exceeds the limit", async () => { mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }])); mockDb.select.mockReturnValueOnce(makeChain([tierDef])); mockDb.select.mockReturnValueOnce(makeChain([{ total: 80 }])); mockDb.select.mockReturnValueOnce(makeChain([{ total: 15 }])); mockDb.select.mockReturnValueOnce(makeChain([{ avatarSizeMb: 5 }])); await expect(checkAndIncrementTierLimit("user1", "free", "storage", 10)).rejects.toThrow(TierLimitError); }); }); }); describe("getRecipeCount", () => { it("returns the live count", async () => { mockDb.select.mockReturnValueOnce(makeChain([{ n: 7 }])); await expect(getRecipeCount("user1")).resolves.toBe(7); }); it("returns 0 when no rows come back", async () => { mockDb.select.mockReturnValueOnce(makeChain([])); await expect(getRecipeCount("user1")).resolves.toBe(0); }); }); describe("getStorageUsedMb", () => { it("sums recipe photos, review photos, and avatar", async () => { mockDb.select.mockReturnValueOnce(makeChain([{ total: 40 }])); mockDb.select.mockReturnValueOnce(makeChain([{ total: 10 }])); mockDb.select.mockReturnValueOnce(makeChain([{ avatarSizeMb: 5 }])); await expect(getStorageUsedMb("user1")).resolves.toBe(55); }); it("treats missing rows as 0", async () => { mockDb.select.mockReturnValueOnce(makeChain([])); mockDb.select.mockReturnValueOnce(makeChain([])); mockDb.select.mockReturnValueOnce(makeChain([])); await expect(getStorageUsedMb("user1")).resolves.toBe(0); }); }); 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); }); });