Files
Epicure/apps/web/lib/__tests__/tiers.test.ts
T
Arnaud f6975e98a9 fix: recipe/storage tier limits are lifetime totals, not monthly (v0.58.0)
Recipe count and storage usage shared the monthly user_usage bucket with
AI calls, so both incorrectly reset every month even though nothing was
deleted. Only AI calls should be monthly.

Recipe count and storage are now derived live from real data (recipes,
recipe/review photos, avatar) instead of a counter — deleting a photo or
recipe is itself the "decrement", no extra wiring needed. Storage size is
tracked per-row (recipePhotos.sizeMb, ratings.photoSizeMb, users.avatarSizeMb)
and threaded through presign -> upload -> save.

Also fixes avatar removal silently no-oping (client sent a field the PATCH
schema didn't recognize).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 20:32:31 +02:00

178 lines
7.1 KiB
TypeScript

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);
});
});