diff --git a/src/app/api/expand-instructions/__tests__/route.test.ts b/src/app/api/expand-instructions/__tests__/route.test.ts new file mode 100644 index 0000000..419a21a --- /dev/null +++ b/src/app/api/expand-instructions/__tests__/route.test.ts @@ -0,0 +1,490 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { POST } from "../route"; +import { NextRequest } from "next/server"; + +// Mock OpenRouter SDK +const mockSend = vi.fn(); +vi.mock("@openrouter/sdk", () => { + return { + OpenRouter: class MockOpenRouter { + chat = { + send: mockSend, + }; + }, + }; +}); + +// Valid request fixture +const validRequest = { + recipeName: "Scrambled Eggs", + ingredients: ["2 eggs", "1 tbsp butter", "salt and pepper"], + instructions: ["Beat eggs", "Melt butter", "Scramble eggs", "Season and serve"], +}; + +// Valid response fixture +const validInstructions = [ + "Step 1: Crack 2 eggs into a bowl and beat until combined", + "Step 2: Heat a pan over medium heat and add butter", + "Step 3: Pour eggs into pan and gently scramble for 2-3 minutes", + "Step 4: Season with salt and pepper to taste", +]; + +const validResponse = JSON.stringify({ detailedInstructions: validInstructions }); + +// Helper to create a NextRequest +function createRequest(body: unknown) { + return new NextRequest("http://localhost/api/expand-instructions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +// ============================================================ +// Integration Tests: POST Handler +// ============================================================ + +describe("POST /api/expand-instructions", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Default: API returns valid response + mockSend.mockResolvedValue({ + choices: [{ message: { content: validResponse } }], + }); + }); + + // ============================================================ + // Input Validation Tests + // ============================================================ + + describe("input validation", () => { + it("returns 400 when recipeName is missing", async () => { + const request = createRequest({ + ingredients: ["egg"], + instructions: ["cook"], + }); + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(400); + expect(json.error).toBe("Recipe name is required"); + expect(mockSend).not.toHaveBeenCalled(); + }); + + it("returns 400 when recipeName is empty string", async () => { + const request = createRequest({ + recipeName: "", + ingredients: ["egg"], + instructions: ["cook"], + }); + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(400); + expect(json.error).toBe("Recipe name is required"); + expect(mockSend).not.toHaveBeenCalled(); + }); + + it("returns 400 when recipeName is not a string", async () => { + const request = createRequest({ + recipeName: 123, + ingredients: ["egg"], + instructions: ["cook"], + }); + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(400); + expect(json.error).toBe("Recipe name is required"); + expect(mockSend).not.toHaveBeenCalled(); + }); + + it("returns 400 when ingredients is missing", async () => { + const request = createRequest({ + recipeName: "Test", + instructions: ["cook"], + }); + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(400); + expect(json.error).toBe("Ingredients array is required"); + expect(mockSend).not.toHaveBeenCalled(); + }); + + it("returns 400 when ingredients is empty array", async () => { + const request = createRequest({ + recipeName: "Test", + ingredients: [], + instructions: ["cook"], + }); + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(400); + expect(json.error).toBe("Ingredients array is required"); + expect(mockSend).not.toHaveBeenCalled(); + }); + + it("returns 400 when ingredients is not an array", async () => { + const request = createRequest({ + recipeName: "Test", + ingredients: "not an array", + instructions: ["cook"], + }); + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(400); + expect(json.error).toBe("Ingredients array is required"); + expect(mockSend).not.toHaveBeenCalled(); + }); + + it("returns 400 when instructions is missing", async () => { + const request = createRequest({ + recipeName: "Test", + ingredients: ["egg"], + }); + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(400); + expect(json.error).toBe("Instructions array is required"); + expect(mockSend).not.toHaveBeenCalled(); + }); + + it("returns 400 when instructions is empty array", async () => { + const request = createRequest({ + recipeName: "Test", + ingredients: ["egg"], + instructions: [], + }); + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(400); + expect(json.error).toBe("Instructions array is required"); + expect(mockSend).not.toHaveBeenCalled(); + }); + + it("returns 400 when instructions is not an array", async () => { + const request = createRequest({ + recipeName: "Test", + ingredients: ["egg"], + instructions: "not an array", + }); + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(400); + expect(json.error).toBe("Instructions array is required"); + expect(mockSend).not.toHaveBeenCalled(); + }); + }); + + // ============================================================ + // Successful Response Tests + // ============================================================ + + describe("when request is valid", () => { + it("returns 200 with detailed instructions", async () => { + const request = createRequest(validRequest); + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.detailedInstructions).toHaveLength(4); + expect(json.detailedInstructions[0]).toContain("Step 1"); + }); + + it("handles response wrapped in markdown code blocks", async () => { + mockSend.mockResolvedValue({ + choices: [{ message: { content: "```json\n" + validResponse + "\n```" } }], + }); + + const request = createRequest(validRequest); + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.detailedInstructions).toHaveLength(4); + }); + + it("handles response with extra text around JSON", async () => { + mockSend.mockResolvedValue({ + choices: [{ message: { content: "Here are your instructions: " + validResponse + " Enjoy!" } }], + }); + + const request = createRequest(validRequest); + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.detailedInstructions).toHaveLength(4); + }); + + it("handles content as array of parts", async () => { + mockSend.mockResolvedValue({ + choices: [ + { + message: { + content: [ + { type: "text", text: '{"detailedInstructions": [' }, + { type: "text", text: '"Step 1: Do this", "Step 2: Do that"' }, + { type: "text", text: "]}" }, + ], + }, + }, + ], + }); + + const request = createRequest(validRequest); + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.detailedInstructions).toHaveLength(2); + }); + + it("filters out invalid instructions (empty strings)", async () => { + const mixedInstructions = { + detailedInstructions: [ + "Valid step 1", + "", + "Valid step 2", + " ", + "Valid step 3", + ], + }; + mockSend.mockResolvedValue({ + choices: [{ message: { content: JSON.stringify(mixedInstructions) } }], + }); + + const request = createRequest(validRequest); + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.detailedInstructions).toHaveLength(3); + expect(json.detailedInstructions).toEqual([ + "Valid step 1", + "Valid step 2", + "Valid step 3", + ]); + }); + + it("filters out non-string instructions", async () => { + const mixedInstructions = { + detailedInstructions: [ + "Valid step", + 123, + null, + { step: "object" }, + "Another valid step", + ], + }; + mockSend.mockResolvedValue({ + choices: [{ message: { content: JSON.stringify(mixedInstructions) } }], + }); + + const request = createRequest(validRequest); + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.detailedInstructions).toHaveLength(2); + }); + }); + + // ============================================================ + // AI Response Error Tests + // ============================================================ + + describe("when AI response is problematic", () => { + it("returns 500 when AI returns no content", async () => { + mockSend.mockResolvedValue({ + choices: [{ message: { content: null } }], + }); + + const request = createRequest(validRequest); + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(500); + expect(json.error).toBe("No response from AI"); + }); + + it("returns 500 when AI returns empty choices", async () => { + mockSend.mockResolvedValue({ choices: [] }); + + const request = createRequest(validRequest); + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(500); + expect(json.error).toBe("No response from AI"); + }); + + it("returns 502 when AI returns invalid JSON", async () => { + mockSend.mockResolvedValue({ + choices: [{ message: { content: "This is not JSON at all" } }], + }); + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const request = createRequest(validRequest); + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(502); + expect(json.error).toBe("Failed to parse detailed instructions. Please try again."); + consoleSpy.mockRestore(); + }); + + it("returns 502 when AI returns malformed JSON", async () => { + mockSend.mockResolvedValue({ + choices: [{ message: { content: '{"detailedInstructions": [' } }], + }); + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const request = createRequest(validRequest); + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(502); + expect(json.error).toBe("Failed to parse detailed instructions. Please try again."); + consoleSpy.mockRestore(); + }); + + it("returns 502 when response has no detailedInstructions array", async () => { + mockSend.mockResolvedValue({ + choices: [{ message: { content: '{"instructions": []}' } }], + }); + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const request = createRequest(validRequest); + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(502); + expect(json.error).toBe("Received invalid instructions format. Please try again."); + consoleSpy.mockRestore(); + }); + + it("returns 502 when detailedInstructions is not an array", async () => { + mockSend.mockResolvedValue({ + choices: [{ message: { content: '{"detailedInstructions": "not an array"}' } }], + }); + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const request = createRequest(validRequest); + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(502); + expect(json.error).toBe("Received invalid instructions format. Please try again."); + consoleSpy.mockRestore(); + }); + + it("returns 422 when detailedInstructions array is empty", async () => { + mockSend.mockResolvedValue({ + choices: [{ message: { content: '{"detailedInstructions": []}' } }], + }); + + const request = createRequest(validRequest); + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(422); + expect(json.error).toBe("No detailed instructions could be generated."); + }); + + it("returns 502 when all instructions fail validation", async () => { + const invalidInstructions = { + detailedInstructions: ["", " ", null, 123], + }; + mockSend.mockResolvedValue({ + choices: [{ message: { content: JSON.stringify(invalidInstructions) } }], + }); + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const request = createRequest(validRequest); + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(502); + expect(json.error).toBe("Generated instructions were malformed. Please try again."); + consoleSpy.mockRestore(); + }); + }); + + // ============================================================ + // API Call Failure Tests + // ============================================================ + + describe("when API call fails", () => { + it("returns 504 on timeout", async () => { + mockSend.mockRejectedValue(new Error("TIMEOUT")); + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const request = createRequest(validRequest); + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(504); + expect(json.error).toBe("Request timed out. Please try again."); + consoleSpy.mockRestore(); + }); + + it("returns 503 on network error (fetch)", async () => { + mockSend.mockRejectedValue(new Error("fetch failed")); + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const request = createRequest(validRequest); + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(503); + expect(json.error).toBe("Network error. Please check your connection."); + consoleSpy.mockRestore(); + }); + + it("returns 503 on network error (network)", async () => { + mockSend.mockRejectedValue(new Error("network unavailable")); + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const request = createRequest(validRequest); + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(503); + expect(json.error).toBe("Network error. Please check your connection."); + consoleSpy.mockRestore(); + }); + + it("returns 500 on generic error", async () => { + mockSend.mockRejectedValue(new Error("Something unexpected happened")); + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const request = createRequest(validRequest); + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(500); + expect(json.error).toBe("Failed to expand instructions. Please try again."); + consoleSpy.mockRestore(); + }); + + it("returns 500 on non-Error throw", async () => { + mockSend.mockRejectedValue("string error"); + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const request = createRequest(validRequest); + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(500); + expect(json.error).toBe("Failed to expand instructions. Please try again."); + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/src/app/api/expand-instructions/route.ts b/src/app/api/expand-instructions/route.ts new file mode 100644 index 0000000..35c7b69 --- /dev/null +++ b/src/app/api/expand-instructions/route.ts @@ -0,0 +1,164 @@ +import { NextResponse } from "next/server"; +import { OpenRouter } from "@openrouter/sdk"; +import { cleanJsonResponse, extractJson } from "../generate/route"; + +/** + * Creates a timeout promise that rejects after specified ms. + */ +function timeout(promise: Promise, ms: number): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => setTimeout(() => reject(new Error("TIMEOUT")), ms)), + ]); +} + +const REQUEST_TIMEOUT_MS = 30000; // 30 seconds (faster since text-only) + +export async function POST(request: Request) { + const openRouter = new OpenRouter({ + apiKey: process.env.OPENROUTER_API_KEY!, + }); + + try { + const { recipeName, ingredients, instructions } = await request.json(); + + // Validate required fields + if (!recipeName || typeof recipeName !== "string") { + return NextResponse.json({ error: "Recipe name is required" }, { status: 400 }); + } + + if (!ingredients || !Array.isArray(ingredients) || ingredients.length === 0) { + return NextResponse.json({ error: "Ingredients array is required" }, { status: 400 }); + } + + if (!instructions || !Array.isArray(instructions) || instructions.length === 0) { + return NextResponse.json({ error: "Instructions array is required" }, { status: 400 }); + } + + const response = await timeout( + openRouter.chat.send({ + model: "google/gemini-3-flash-preview", + messages: [ + { + role: "user", + content: `You are a helpful chef assistant. I have a recipe with basic instructions, and I need you to expand them into detailed, beginner-friendly steps. + +Recipe: ${recipeName} + +Ingredients: +${ingredients.map((ing: string) => `- ${ing}`).join("\n")} + +Basic Instructions: +${instructions.map((step: string, i: number) => `${i + 1}. ${step}`).join("\n")} + +Please expand these basic instructions into detailed, beginner-friendly steps. Include: +- Specific timing (e.g., "cook for 3-4 minutes until golden") +- Temperatures when relevant (e.g., "medium-high heat, about 375°F") +- Visual cues to know when each step is done +- Tips for beginners +- Safety reminders where appropriate + +Return ONLY valid JSON with no markdown, no explanation, no preamble. Start your response with { and end with }. + +Use this exact structure: +{ + "detailedInstructions": [ + "Step 1: Detailed instruction with timing, tips, and visual cues...", + "Step 2: Another detailed instruction..." + ] +} + +Expand the ${instructions.length} basic steps into approximately ${Math.max(instructions.length * 2, 8)} detailed steps.`, + }, + ], + }), + REQUEST_TIMEOUT_MS, + ); + + const rawContent = response.choices[0]?.message?.content; + + if (!rawContent) { + return NextResponse.json({ error: "No response from AI" }, { status: 500 }); + } + + // Extract text content (could be string or array of content parts) + const content = + typeof rawContent === "string" + ? rawContent + : rawContent + .filter((part) => part.type === "text") + .map((part) => (part as { type: "text"; text: string }).text) + .join(""); + + // Clean and parse the JSON response + let data; + try { + const cleaned = cleanJsonResponse(content); + const jsonString = extractJson(cleaned); + data = JSON.parse(jsonString); + } catch (parseError) { + console.error("JSON parse error:", parseError); + console.error("Raw content:", content.slice(0, 500)); + return NextResponse.json( + { error: "Failed to parse detailed instructions. Please try again." }, + { status: 502 }, + ); + } + + // Validate response structure + if (!data.detailedInstructions || !Array.isArray(data.detailedInstructions)) { + console.error("Invalid response structure:", data); + return NextResponse.json( + { error: "Received invalid instructions format. Please try again." }, + { status: 502 }, + ); + } + + if (data.detailedInstructions.length === 0) { + return NextResponse.json( + { error: "No detailed instructions could be generated." }, + { status: 422 }, + ); + } + + // Validate each instruction is a non-empty string + const validInstructions = data.detailedInstructions.filter( + (instruction: unknown) => typeof instruction === "string" && instruction.trim().length > 0, + ); + + if (validInstructions.length === 0) { + console.error("All instructions failed validation:", data.detailedInstructions); + return NextResponse.json( + { error: "Generated instructions were malformed. Please try again." }, + { status: 502 }, + ); + } + + return NextResponse.json({ detailedInstructions: validInstructions }); + } catch (error) { + console.error("API error:", error); + + // Handle specific error types + if (error instanceof Error) { + if (error.message === "TIMEOUT") { + return NextResponse.json( + { error: "Request timed out. Please try again." }, + { status: 504 }, + ); + } + + // Network or fetch errors + if (error.message.includes("fetch") || error.message.includes("network")) { + return NextResponse.json( + { error: "Network error. Please check your connection." }, + { status: 503 }, + ); + } + } + + return NextResponse.json( + { error: "Failed to expand instructions. Please try again." }, + { status: 500 }, + ); + } +} diff --git a/src/components/InstructionsSection.tsx b/src/components/InstructionsSection.tsx new file mode 100644 index 0000000..d2cbe69 --- /dev/null +++ b/src/components/InstructionsSection.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useState } from "react"; + +interface InstructionsSectionProps { + recipeName: string; + ingredients: string[]; + instructions: string[]; + detailedInstructions: string[] | null; + onDetailedInstructionsLoaded: (instructions: string[]) => void; +} + +export default function InstructionsSection({ + recipeName, + ingredients, + instructions, + detailedInstructions, + onDetailedInstructionsLoaded, +}: InstructionsSectionProps) { + const [isLoadingDetails, setIsLoadingDetails] = useState(false); + const [detailsError, setDetailsError] = useState(null); + + const handleExpandInstructions = async () => { + if (detailedInstructions || isLoadingDetails) return; // Already loaded or loading + + setIsLoadingDetails(true); + setDetailsError(null); + + try { + const res = await fetch("/api/expand-instructions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + recipeName, + ingredients, + instructions, + }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Failed to expand instructions"); + } + + const data = await res.json(); + onDetailedInstructionsLoaded(data.detailedInstructions); + } catch (err) { + console.error("Failed to expand instructions:", err); + setDetailsError(err instanceof Error ? err.message : "Failed to load detailed instructions"); + } finally { + setIsLoadingDetails(false); + } + }; + + return ( +
+

- Instructions -

+
    + {(detailedInstructions || instructions).map((step, index) => ( +
  1. {step.replace(/^step\s*\d+[:\.\-]\s*/i, "")}
  2. + ))} +
+ + {/* Detailed Instructions Button */} + {!detailedInstructions && ( + + )} + + {/* Show label when detailed instructions are displayed */} + {detailedInstructions && ( +

✓ Showing detailed instructions

+ )} + + {/* Error message */} + {detailsError &&

{detailsError}

} +
+ ); +} diff --git a/src/components/RecipeCard.tsx b/src/components/RecipeCard.tsx index 0dbf585..423f5f1 100644 --- a/src/components/RecipeCard.tsx +++ b/src/components/RecipeCard.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import InstructionsSection from "./InstructionsSection"; import { useClickSound } from "@/hooks/useClickSound"; import { usePageTurnSound } from "@/hooks/usePageTurnSound"; import { usePageBackSound } from "@/hooks/usePageBackSound"; @@ -26,6 +27,7 @@ interface RecipeCardProps { export default function RecipeCard({ recipe }: RecipeCardProps) { const [isOpen, setIsOpen] = useState(false); const [copied, setCopied] = useState(false); + const [detailedInstructions, setDetailedInstructions] = useState(null); const playClick = useClickSound(); const playPageTurn = usePageTurnSound(); const playPageBack = usePageBackSound(); @@ -114,14 +116,13 @@ ${recipe.instructions.map((step, i) => `${i + 1}. ${step}`).join("\n")} {/* Instructions */} -
-

- Instructions -

-
    - {recipe.instructions.map((step, index) => ( -
  1. {step.replace(/^step\s*\d+[:\.\-]\s*/i, "")}
  2. - ))} -
-
+ )}