524310433c
AI routes had no consistent error handling — a provider failure (e.g. OpenRouter returning a degraded-model error) crashed the route as an uncaught 500 and, worse, still charged the user's monthly aiCall quota for a request that never succeeded. Adds withAiQuota()/aiErrorResponse() (lib/ai/ai-error.ts) and applies them across all 12 AI generation routes: charges the quota, runs the AI call, refunds the credit and returns a clean user-facing message if it throws. Frontend needs no changes — existing dialogs already toast whatever `error` string comes back. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
160 lines
4.5 KiB
TypeScript
160 lines
4.5 KiB
TypeScript
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() }),
|
|
})
|
|
);
|
|
});
|
|
});
|