Update features and dependencies

This commit is contained in:
Arnaud
2026-07-01 11:10:37 +02:00
parent 9d9dfb46c6
commit 8b57a3fd87
107 changed files with 14654 additions and 458 deletions
@@ -0,0 +1,154 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// --- Mock dependencies ---
const mockSession = {
user: { id: "user-1", name: "Test User", email: "test@test.com", tier: "free", role: "user" },
};
vi.mock("@/lib/api-auth", () => ({
requireSessionOrApiKey: vi.fn(),
requireSession: vi.fn(),
}));
vi.mock("@/lib/tiers", () => ({
checkTierLimit: vi.fn(),
incrementUsage: vi.fn(),
TierLimitError: class TierLimitError extends Error {},
}));
vi.mock("@/lib/webhooks", () => ({
dispatchWebhook: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
applyRateLimit: vi.fn().mockResolvedValue(null),
}));
const { mockTransaction, mockInsertValues, mockSelectChain } = vi.hoisted(() => {
const mockInsertValues = vi.fn().mockResolvedValue(undefined);
const mockSelectChain = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
offset: vi.fn().mockResolvedValue([]),
};
return { mockTransaction: vi.fn(), mockInsertValues, mockSelectChain };
});
vi.mock("@epicure/db", () => ({
db: {
select: vi.fn(() => mockSelectChain),
insert: vi.fn(() => ({ values: mockInsertValues })),
transaction: mockTransaction,
query: {
recipes: { findFirst: vi.fn().mockResolvedValue({ id: "new-id", title: "Test Recipe" }) },
},
},
recipes: { id: "id", authorId: "author_id", title: "title", visibility: "visibility" },
recipeIngredients: {},
recipeSteps: {},
eq: vi.fn((a, b) => ({ a, b, op: "eq" })),
desc: vi.fn((a) => ({ a, op: "desc" })),
and: vi.fn((...args) => ({ args, op: "and" })),
count: vi.fn(() => ({ op: "count" })),
ilike: vi.fn((a, b) => ({ a, b, op: "ilike" })),
or: vi.fn((...args) => ({ args, op: "or" })),
sql: vi.fn(),
}));
const { requireSessionOrApiKey } = await import("@/lib/api-auth");
import { GET, POST } from "../route";
function makeRequest(method: string, body?: unknown, search = "") {
const url = `http://localhost/api/v1/recipes${search}`;
return new NextRequest(url, {
method,
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireSessionOrApiKey).mockResolvedValue({ session: mockSession as never, response: null });
mockTransaction.mockImplementation(async (fn: (tx: unknown) => Promise<unknown>) => {
const tx = {
insert: vi.fn(() => ({ values: vi.fn().mockResolvedValue(undefined) })),
};
return fn(tx);
});
});
describe("GET /api/v1/recipes", () => {
it("returns 200 with empty list", async () => {
mockSelectChain.offset.mockResolvedValue([]);
const res = await GET(makeRequest("GET"));
expect(res.status).toBe(200);
const body = await res.json() as { data: unknown[] };
expect(Array.isArray(body.data)).toBe(true);
});
it("returns 401 when not authenticated", async () => {
vi.mocked(requireSessionOrApiKey).mockResolvedValue({
session: null,
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
} as never);
const res = await GET(makeRequest("GET"));
expect(res.status).toBe(401);
});
});
describe("POST /api/v1/recipes", () => {
const validRecipe = {
title: "Test Recipe",
baseServings: 4,
visibility: "private",
ingredients: [{ rawName: "flour", quantity: "2", unit: "cups" }],
steps: [{ instruction: "Mix everything" }],
};
it("returns 201 on valid recipe creation", async () => {
const res = await POST(makeRequest("POST", validRecipe));
expect(res.status).toBe(201);
const body = await res.json() as { id: string };
expect(body.id).toBeTruthy();
});
it("returns 400 on invalid body (missing title)", async () => {
const res = await POST(makeRequest("POST", { baseServings: 4 }));
expect(res.status).toBe(400);
});
it("returns 400 when title is empty", async () => {
const res = await POST(makeRequest("POST", { ...validRecipe, title: "" }));
expect(res.status).toBe(400);
});
it("returns 400 when title is too long", async () => {
const res = await POST(makeRequest("POST", { ...validRecipe, title: "x".repeat(201) }));
expect(res.status).toBe(400);
});
it("returns 403 when tier limit reached", async () => {
const { checkTierLimit } = await import("@/lib/tiers");
const { TierLimitError } = await import("@/lib/tiers");
vi.mocked(checkTierLimit).mockRejectedValue(new TierLimitError("recipe", "free"));
const res = await POST(makeRequest("POST", validRecipe));
expect(res.status).toBe(403);
});
it("returns 401 when not authenticated", async () => {
vi.mocked(requireSessionOrApiKey).mockResolvedValue({
session: null,
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
} as never);
const res = await POST(makeRequest("POST", validRecipe));
expect(res.status).toBe(401);
});
});