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>
This commit is contained in:
@@ -3,43 +3,31 @@ 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" },
|
||||
users: { id: "id", tier: "tier" },
|
||||
userUsage: {
|
||||
userId: "user_id",
|
||||
month: "month",
|
||||
aiCallsUsed: "ai_calls_used",
|
||||
recipeCount: "recipe_count",
|
||||
storageUsedMb: "storage_used_mb",
|
||||
},
|
||||
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, incrementUsage, refundAiCall } = await import("../tiers");
|
||||
const { checkAndIncrementTierLimit, getRecipeCount, getStorageUsedMb, refundAiCall } = await import("../tiers");
|
||||
|
||||
function makeChain(finalValue: unknown) {
|
||||
const chain = {
|
||||
return {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
innerJoin: 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(() => {
|
||||
@@ -65,45 +53,118 @@ describe("checkAndIncrementTierLimit", () => {
|
||||
maxPublicRecipes: 3,
|
||||
};
|
||||
|
||||
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 }]);
|
||||
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();
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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([]);
|
||||
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", "aiCall")).rejects.toThrow(TierLimitError);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
it("throws TierLimitError for recipe key when limit reached", async () => {
|
||||
mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
|
||||
mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
|
||||
mockDb.execute.mockResolvedValueOnce([]);
|
||||
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", "recipe")).rejects.toThrow(TierLimitError);
|
||||
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("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 }]);
|
||||
it("returns 0 when no rows come back", async () => {
|
||||
mockDb.select.mockReturnValueOnce(makeChain([]));
|
||||
await expect(getRecipeCount("user1")).resolves.toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// caller still thinks it's "free" (stale session), but DB says "pro"
|
||||
await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
|
||||
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("does not throw when tier definition does not exist", async () => {
|
||||
mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
|
||||
it("treats missing rows as 0", async () => {
|
||||
mockDb.select.mockReturnValueOnce(makeChain([]));
|
||||
mockDb.select.mockReturnValueOnce(makeChain([]));
|
||||
mockDb.select.mockReturnValueOnce(makeChain([]));
|
||||
|
||||
await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
|
||||
expect(mockDb.execute).not.toHaveBeenCalled();
|
||||
await expect(getStorageUsedMb("user1")).resolves.toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,60 +175,3 @@ describe("refundAiCall", () => {
|
||||
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() }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user