Update features and dependencies
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user