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
@@ -30,7 +30,8 @@ export async function GET(req: NextRequest, { params }: Params) {
counts[row.type] = row.cnt;
}
// Check for session to return user's reactions
// Optional auth — GET reactions is public; session present means also return user's own reactions
// response intentionally ignored: unauthenticated callers get counts only with empty userReactions
const { session } = await requireSession();
let userReactions: string[] = [];
@@ -6,7 +6,7 @@ import { requireSession } from "@/lib/api-auth";
type Params = { params: Promise<{ id: string }> };
const Schema = z.object({
servings: z.number().int().min(1).optional(),
servings: z.number().int().min(1).max(1000).optional(),
notes: z.string().max(2000).optional(),
deductFromPantry: z.boolean().default(true),
});
@@ -56,7 +56,7 @@ export async function POST(_req: NextRequest, { params }: Params) {
})),
});
await db.update(recipes).set({ nutritionData: result }).where(eq(recipes.id, id));
await db.update(recipes).set({ nutritionData: result }).where(and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)));
return NextResponse.json({ nutrition: result });
}
+14 -11
View File
@@ -4,15 +4,17 @@ import { eq, and, max } from "@epicure/db";
import { z } from "zod";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { dispatchWebhook } from "@/lib/webhooks";
import { parseQuantity } from "@/lib/parse-quantity";
const UpdateRecipeSchema = z.object({
title: z.string().min(1).max(200).optional(),
description: z.string().optional(),
description: z.string().max(2000).optional(),
baseServings: z.number().int().min(1).max(100).optional(),
visibility: z.enum(["private", "unlisted", "public"]).optional(),
difficulty: z.enum(["easy", "medium", "hard"]).nullable().optional(),
prepMins: z.number().int().min(0).nullable().optional(),
cookMins: z.number().int().min(0).nullable().optional(),
prepMins: z.number().int().min(0).max(1440).nullable().optional(),
cookMins: z.number().int().min(0).max(1440).nullable().optional(),
tags: z.array(z.string().min(1).max(50)).max(20).optional(),
dietaryTags: z.object({
vegan: z.boolean().optional(),
vegetarian: z.boolean().optional(),
@@ -23,17 +25,17 @@ const UpdateRecipeSchema = z.object({
kosher: z.boolean().optional(),
}).optional(),
ingredients: z.array(z.object({
rawName: z.string().min(1),
quantity: z.string().optional(),
unit: z.string().optional(),
note: z.string().optional(),
rawName: z.string().min(1).max(200),
quantity: z.union([z.number(), z.string().max(50)]).optional().transform(parseQuantity),
unit: z.string().max(50).optional(),
note: z.string().max(500).optional(),
order: z.number().int().default(0),
})).optional(),
})).max(100).optional(),
steps: z.array(z.object({
instruction: z.string().min(1),
timerSeconds: z.number().int().optional(),
instruction: z.string().min(1).max(2000),
timerSeconds: z.number().int().min(0).max(86400).optional(),
order: z.number().int(),
})).optional(),
})).max(100).optional(),
});
type Params = { params: Promise<{ id: string }> };
@@ -119,6 +121,7 @@ export async function PUT(req: NextRequest, { params }: Params) {
if (data.difficulty !== undefined) updates.difficulty = data.difficulty ?? undefined;
if (data.prepMins !== undefined) updates.prepMins = data.prepMins ?? undefined;
if (data.cookMins !== undefined) updates.cookMins = data.cookMins ?? undefined;
if (data.tags !== undefined) updates.tags = data.tags;
if (data.dietaryTags !== undefined) updates.dietaryTags = data.dietaryTags;
await tx.update(recipes).set(updates).where(eq(recipes.id, id));
@@ -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);
});
});
@@ -0,0 +1,96 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
const mockSession = {
user: { id: "user-1", name: "Test", email: "t@t.com", tier: "free" },
};
const { mockRequireSession } = vi.hoisted(() => ({
mockRequireSession: vi.fn(),
}));
vi.mock("@/lib/api-auth", () => ({
requireSession: mockRequireSession,
}));
vi.mock("@epicure/db", () => ({
db: {
delete: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })),
update: vi.fn(() => ({
set: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })),
})),
},
recipes: { id: "id", authorId: "author_id", visibility: "visibility" },
eq: vi.fn(),
and: vi.fn(),
inArray: vi.fn(),
}));
import { DELETE, PATCH } from "../route";
function makeRequest(method: string, body: unknown) {
return new NextRequest(`http://localhost/api/v1/recipes/bulk`, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.clearAllMocks();
mockRequireSession.mockResolvedValue({ session: mockSession, response: null });
});
describe("DELETE /api/v1/recipes/bulk", () => {
it("returns 200 on valid ids", async () => {
const res = await DELETE(makeRequest("DELETE", { ids: ["r1", "r2"] }));
expect(res.status).toBe(200);
const body = await res.json() as { ok: boolean };
expect(body.ok).toBe(true);
});
it("returns 400 when ids is empty array", async () => {
const res = await DELETE(makeRequest("DELETE", { ids: [] }));
expect(res.status).toBe(400);
});
it("returns 400 when body is invalid", async () => {
const res = await DELETE(makeRequest("DELETE", { notIds: "wrong" }));
expect(res.status).toBe(400);
});
it("returns 401 when not authenticated", async () => {
mockRequireSession.mockResolvedValue({
session: null,
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
});
const res = await DELETE(makeRequest("DELETE", { ids: ["r1"] }));
expect(res.status).toBe(401);
});
});
describe("PATCH /api/v1/recipes/bulk", () => {
it("returns 200 on valid visibility update", async () => {
const res = await PATCH(makeRequest("PATCH", { ids: ["r1"], visibility: "public" }));
expect(res.status).toBe(200);
});
it("returns 400 when visibility is missing", async () => {
const res = await PATCH(makeRequest("PATCH", { ids: ["r1"] }));
expect(res.status).toBe(400);
});
it("returns 400 when ids is empty", async () => {
const res = await PATCH(makeRequest("PATCH", { ids: [], visibility: "public" }));
expect(res.status).toBe(400);
});
it("returns 401 when not authenticated", async () => {
mockRequireSession.mockResolvedValue({
session: null,
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
});
const res = await PATCH(makeRequest("PATCH", { ids: ["r1"], visibility: "public" }));
expect(res.status).toBe(401);
});
});
+6 -6
View File
@@ -13,22 +13,22 @@ const BulkUpdateSchema = z.object({
});
export async function DELETE(req: NextRequest) {
const session = await requireSession();
if (session instanceof NextResponse) return session;
const { session, response } = await requireSession();
if (response) return response;
const body = BulkDeleteSchema.safeParse(await req.json());
if (!body.success) return NextResponse.json({ error: "Invalid request" }, { status: 400 });
await db
.delete(recipes)
.where(and(inArray(recipes.id, body.data.ids), eq(recipes.authorId, session.user.id)));
.where(and(inArray(recipes.id, body.data.ids), eq(recipes.authorId, session!.user.id)));
return NextResponse.json({ ok: true });
}
export async function PATCH(req: NextRequest) {
const session = await requireSession();
if (session instanceof NextResponse) return session;
const { session, response } = await requireSession();
if (response) return response;
const body = BulkUpdateSchema.safeParse(await req.json());
if (!body.success) return NextResponse.json({ error: "Invalid request" }, { status: 400 });
@@ -39,7 +39,7 @@ export async function PATCH(req: NextRequest) {
await db
.update(recipes)
.set({ visibility, updatedAt: new Date() })
.where(and(inArray(recipes.id, ids), eq(recipes.authorId, session.user.id)));
.where(and(inArray(recipes.id, ids), eq(recipes.authorId, session!.user.id)));
return NextResponse.json({ ok: true });
}
+24 -52
View File
@@ -3,56 +3,21 @@ import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
import { eq, desc, and } from "@epicure/db";
import { z } from "zod";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
import { checkAndIncrementTierLimit, TierLimitError } from "@/lib/tiers";
import { dispatchWebhook } from "@/lib/webhooks";
const UNICODE_FRACTIONS: Record<string, number> = {
"½": 0.5, "⅓": 1/3, "⅔": 2/3, "¼": 0.25, "¾": 0.75,
"⅕": 0.2, "⅖": 0.4, "⅗": 0.6, "⅘": 0.8,
"⅙": 1/6, "⅚": 5/6, "⅛": 0.125, "⅜": 0.375, "⅝": 0.625, "⅞": 0.875,
};
function parseQuantity(v: string | number | undefined): string | undefined {
if (v === undefined || v === "") return undefined;
const s = String(v).trim();
if (!s) return undefined;
if (UNICODE_FRACTIONS[s] !== undefined) return String(UNICODE_FRACTIONS[s]);
for (const [frac, val] of Object.entries(UNICODE_FRACTIONS)) {
if (s.endsWith(frac)) {
const whole = s.slice(0, -frac.length).trim();
if (!whole) return String(val);
const w = parseFloat(whole);
if (!isNaN(w)) return String(w + val);
}
}
const mixed = s.match(/^(\d+)\s+(\d+)\s*\/\s*(\d+)$/);
if (mixed) {
const den = parseInt(mixed[3]!);
if (den !== 0) return String(parseInt(mixed[1]!) + parseInt(mixed[2]!) / den);
}
const slash = s.match(/^(\d+)\s*\/\s*(\d+)$/);
if (slash) {
const den = parseInt(slash[2]!);
if (den !== 0) return String(parseInt(slash[1]!) / den);
}
const n = parseFloat(s);
return isNaN(n) ? undefined : String(n);
}
import { parseQuantity } from "@/lib/parse-quantity";
const CreateRecipeSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().optional(),
description: z.string().max(2000).optional(),
baseServings: z.number().int().min(1).max(100).default(4),
visibility: z.enum(["private", "unlisted", "public"]).default("private"),
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
prepMins: z.number().int().min(0).optional(),
cookMins: z.number().int().min(0).optional(),
prepMins: z.number().int().min(0).max(1440).optional(),
cookMins: z.number().int().min(0).max(1440).optional(),
tags: z.array(z.string().min(1).max(50)).max(20).default([]),
aiGenerated: z.boolean().optional(),
language: z.string().max(10).optional(),
dietaryTags: z.object({
vegan: z.boolean().optional(),
vegetarian: z.boolean().optional(),
@@ -63,17 +28,17 @@ const CreateRecipeSchema = z.object({
kosher: z.boolean().optional(),
}).optional(),
ingredients: z.array(z.object({
rawName: z.string().min(1),
rawName: z.string().min(1).max(200),
quantity: z.union([z.number(), z.string()]).optional().transform(parseQuantity),
unit: z.string().optional(),
note: z.string().optional(),
unit: z.string().max(50).optional(),
note: z.string().max(500).optional(),
order: z.number().int().default(0),
})).default([]),
})).max(100).default([]),
steps: z.array(z.object({
instruction: z.string().min(1),
timerSeconds: z.number().int().optional(),
instruction: z.string().min(1).max(2000),
timerSeconds: z.number().int().min(0).max(86400).optional(),
order: z.number().int().optional(),
})).default([]),
})).max(100).default([]),
});
export async function GET(req: NextRequest) {
@@ -109,7 +74,14 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "recipe");
try {
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "recipe");
} catch (err) {
if (err instanceof TierLimitError) {
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
}
throw err;
}
const id = crypto.randomUUID();
const now = new Date();
@@ -126,8 +98,10 @@ export async function POST(req: NextRequest) {
difficulty: data.difficulty,
prepMins: data.prepMins,
cookMins: data.cookMins,
tags: data.tags,
dietaryTags: data.dietaryTags ?? {},
aiGenerated: data.aiGenerated ?? false,
language: data.language,
createdAt: now,
updatedAt: now,
});
@@ -159,8 +133,6 @@ export async function POST(req: NextRequest) {
}
});
await incrementUsage(session!.user.id, "recipe");
const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, id) });
void dispatchWebhook(session!.user.id, "recipe.created", { id, title: data.title });
return NextResponse.json(recipe, { status: 201 });