diff --git a/apps/backend/src/controllers/recipe.controller.ts b/apps/backend/src/controllers/recipe.controller.ts index 5ecc00a..68bf008 100644 --- a/apps/backend/src/controllers/recipe.controller.ts +++ b/apps/backend/src/controllers/recipe.controller.ts @@ -3,6 +3,49 @@ import type { ApiResponse } from "@snacktrack/shared-types"; import { recipeService } from "../services/recipe.service.js"; export class RecipeController { + async list(req: Request, res: Response) { + const rawLimit = Number(req.query.limit ?? 20); + const limit = Number.isFinite(rawLimit) + ? Math.max(1, Math.min(50, Math.floor(rawLimit))) + : 20; + const rawMaxReady = Number(req.query.maxReadyInMinutes ?? req.query.maxReadyTime); + const maxReadyInMinutes = Number.isFinite(rawMaxReady) ? rawMaxReady : undefined; + const diet = typeof req.query.diet === "string" ? req.query.diet : undefined; + + const recipes = await recipeService.getCachedRecipes({ + diet, + maxReadyInMinutes, + limit, + }); + + res.json({ + status: "success", + data: { + recipes, + total: recipes.length, + page: 1, + limit, + }, + error: null, + }); + } + + async search(req: Request, res: Response) { + const { q, limit } = req.query as unknown as { q: string; limit: number }; + const recipes = await recipeService.searchRecipes(q, limit, req.user?.id); + + res.json({ + status: "success", + data: { + recipes, + total: recipes.length, + page: 1, + limit, + }, + error: null, + }); + } + async getRecommendations(req: Request, res: Response) { const rawLimit = Number(req.query.limit ?? 10); const limit = Number.isFinite(rawLimit) diff --git a/apps/backend/src/routes/recipe.routes.ts b/apps/backend/src/routes/recipe.routes.ts index b32b311..e8015de 100644 --- a/apps/backend/src/routes/recipe.routes.ts +++ b/apps/backend/src/routes/recipe.routes.ts @@ -1,4 +1,5 @@ import { Router, type Router as RouterType } from "express"; +import { foodSearchSchema } from "@snacktrack/shared-types"; import { optionalAuth, requireAuth } from "../middleware/auth.js"; import { validate } from "../middleware/validate.js"; import { recipeController } from "../controllers/recipe.controller.js"; @@ -6,6 +7,17 @@ import { uuidParamSchema } from "../schemas/meal-plan.schema.js"; const router: RouterType = Router(); +// GET /api/v1/recipes?limit=20 +router.get("/", optionalAuth, (req, res) => recipeController.list(req, res)); + +// GET /api/v1/recipes/search?q=...&limit=... +router.get( + "/search", + optionalAuth, + validate({ query: foodSearchSchema }), + (req, res) => recipeController.search(req, res), +); + // GET /api/v1/recipes/recommendations?limit=10 // Requires auth because recommendations are personalized per user. router.get("/recommendations", requireAuth, (req, res) => diff --git a/apps/backend/tests/health.test.ts b/apps/backend/tests/health.test.ts index b72d924..2485f25 100644 --- a/apps/backend/tests/health.test.ts +++ b/apps/backend/tests/health.test.ts @@ -12,9 +12,16 @@ vi.mock("../src/config/redis.js", () => ({ redis: { on: vi.fn() }, })); +vi.mock("../src/services/ml.service.js", () => ({ + mlService: { + isHealthy: vi.fn().mockResolvedValue(true), + }, +})); + import { createApp } from "../src/app.js"; import { isDatabaseHealthy } from "../src/config/database.js"; import { isRedisHealthy } from "../src/config/redis.js"; +import { mlService } from "../src/services/ml.service.js"; const app = createApp(); @@ -22,6 +29,7 @@ describe("Health endpoint", () => { it("GET /api/v1/health returns 200 when all services healthy", async () => { vi.mocked(isDatabaseHealthy).mockResolvedValue(true); vi.mocked(isRedisHealthy).mockResolvedValue(true); + vi.mocked(mlService.isHealthy).mockResolvedValue(true); const res = await request(app).get("/api/v1/health"); @@ -32,12 +40,14 @@ describe("Health endpoint", () => { expect(res.body.data).toHaveProperty("version"); expect(res.body.data.services.database).toBe(true); expect(res.body.data.services.redis).toBe(true); + expect(res.body.data.services.ml).toBe(true); expect(res.body.error).toBeNull(); }); it("GET /api/v1/health returns 503 when database is unhealthy", async () => { vi.mocked(isDatabaseHealthy).mockResolvedValue(false); vi.mocked(isRedisHealthy).mockResolvedValue(true); + vi.mocked(mlService.isHealthy).mockResolvedValue(true); const res = await request(app).get("/api/v1/health"); @@ -51,6 +61,7 @@ describe("Health endpoint", () => { it("GET /api/v1/health returns 503 when redis is unhealthy", async () => { vi.mocked(isDatabaseHealthy).mockResolvedValue(true); vi.mocked(isRedisHealthy).mockResolvedValue(false); + vi.mocked(mlService.isHealthy).mockResolvedValue(true); const res = await request(app).get("/api/v1/health"); @@ -63,6 +74,7 @@ describe("Health endpoint", () => { it("GET /api/v1/health includes X-Request-Id header", async () => { vi.mocked(isDatabaseHealthy).mockResolvedValue(true); vi.mocked(isRedisHealthy).mockResolvedValue(true); + vi.mocked(mlService.isHealthy).mockResolvedValue(true); const res = await request(app).get("/api/v1/health"); @@ -72,6 +84,7 @@ describe("Health endpoint", () => { it("respects provided X-Request-Id header", async () => { vi.mocked(isDatabaseHealthy).mockResolvedValue(true); vi.mocked(isRedisHealthy).mockResolvedValue(true); + vi.mocked(mlService.isHealthy).mockResolvedValue(true); const customId = "test-request-123"; const res = await request(app) diff --git a/apps/backend/tests/recipe.test.ts b/apps/backend/tests/recipe.test.ts new file mode 100644 index 0000000..6560c6b --- /dev/null +++ b/apps/backend/tests/recipe.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import request from "supertest"; + +vi.mock("../src/config/database.js", () => ({ + isDatabaseHealthy: vi.fn().mockResolvedValue(true), + prisma: {}, +})); + +vi.mock("../src/config/redis.js", () => ({ + isRedisHealthy: vi.fn().mockResolvedValue(true), + redis: { on: vi.fn() }, +})); + +vi.mock("../src/config/supabase.js", () => ({ + supabaseAdmin: { + auth: { + getUser: vi.fn(), + admin: { deleteUser: vi.fn() }, + }, + }, + createUserClient: vi.fn(), +})); + +vi.mock("../src/services/recipe.service.js", () => ({ + recipeService: { + getCachedRecipes: vi.fn(), + searchRecipes: vi.fn(), + getRecommendationsForUser: vi.fn(), + getRecipeById: vi.fn(), + }, +})); + +vi.mock("../src/utils/logger.js", () => ({ + logger: { debug: vi.fn(), info: vi.fn(), error: vi.fn(), warn: vi.fn() }, +})); + +import { createApp } from "../src/app.js"; +import { recipeService } from "../src/services/recipe.service.js"; + +const app = createApp(); + +const mockRecipe = { + id: "550e8400-e29b-41d4-a716-446655440001", + spoonacularId: 123, + title: "Chickpea Salad", + imageUrl: null, + cloudinaryUrl: null, + readyInMinutes: 15, + servings: 2, + calories: 320, + proteinG: 12, + carbsG: 40, + fatG: 8, + sodiumMg: null, + fiberG: null, + sugarG: null, + ingredients: null, + allergens: [], + dietLabels: ["vegetarian"], + cuisineTypes: [], + instructions: null, +}; + +describe("Recipe endpoints", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("lists cached recipes from GET /api/v1/recipes", async () => { + vi.mocked(recipeService.getCachedRecipes).mockResolvedValue([mockRecipe]); + + const res = await request(app).get("/api/v1/recipes?limit=12"); + + expect(res.status).toBe(200); + expect(res.body.data.recipes).toHaveLength(1); + expect(res.body.data.recipes[0].title).toBe("Chickpea Salad"); + expect(recipeService.getCachedRecipes).toHaveBeenCalledWith({ + diet: undefined, + maxReadyInMinutes: undefined, + limit: 12, + }); + }); + + it("routes recipe search before the UUID detail route", async () => { + vi.mocked(recipeService.searchRecipes).mockResolvedValue([mockRecipe]); + + const res = await request(app).get("/api/v1/recipes/search?q=salad&limit=5"); + + expect(res.status).toBe(200); + expect(res.body.data.recipes).toHaveLength(1); + expect(recipeService.searchRecipes).toHaveBeenCalledWith("salad", 5, undefined); + expect(recipeService.getRecipeById).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/frontend/src/app/(dashboard)/page.tsx b/apps/frontend/src/app/(dashboard)/page.tsx index 5318b58..7c9f00c 100644 --- a/apps/frontend/src/app/(dashboard)/page.tsx +++ b/apps/frontend/src/app/(dashboard)/page.tsx @@ -12,8 +12,8 @@ import { format } from "date-fns"; export default function DashboardPage() { const today = format(new Date(), "yyyy-MM-dd"); const { data: logs, isLoading: logsLoading } = useMealLogs({ - startDate: today, - endDate: today, + date: today, + range: "day", }); const { data: recommendations, isLoading: recsLoading } = useRecommendations(4); diff --git a/apps/frontend/src/lib/api/meal-logs.api.test.ts b/apps/frontend/src/lib/api/meal-logs.api.test.ts new file mode 100644 index 0000000..d22635a --- /dev/null +++ b/apps/frontend/src/lib/api/meal-logs.api.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mealLogsApi } from "./meal-logs.api"; + +const { getMock } = vi.hoisted(() => ({ + getMock: vi.fn(), +})); + +vi.mock("./client", () => ({ + apiClient: { + get: getMock, + }, +})); + +describe("mealLogsApi", () => { + beforeEach(() => { + getMock.mockReset(); + }); + + it("unwraps paginated meal log responses to the item array", async () => { + const mealLog = { + id: "log-1", + userId: "user-1", + mealType: "breakfast", + foodName: "Oatmeal", + servings: 1, + loggedAt: "2026-05-14T08:00:00.000Z", + source: "manual", + }; + + getMock.mockResolvedValue({ + data: { + status: "success", + data: { + items: [mealLog], + nextCursor: null, + hasMore: false, + }, + error: null, + }, + }); + + await expect( + mealLogsApi.getMealLogs({ date: "2026-05-14", range: "day" }) + ).resolves.toEqual([mealLog]); + + expect(getMock).toHaveBeenCalledWith("/meal-logs", { + params: { date: "2026-05-14", range: "day" }, + }); + }); +}); diff --git a/apps/frontend/src/lib/api/meal-logs.api.ts b/apps/frontend/src/lib/api/meal-logs.api.ts index 69e04dc..5bdb4e1 100644 --- a/apps/frontend/src/lib/api/meal-logs.api.ts +++ b/apps/frontend/src/lib/api/meal-logs.api.ts @@ -9,10 +9,21 @@ interface MealLogResponse { interface MealLogsResponse { status: string; - data: MealLog[]; + data: { + items: MealLog[]; + nextCursor: string | null; + hasMore: boolean; + }; error: null; } +interface MealLogQueryParams { + date?: string; + range?: "day" | "week" | "month"; + cursor?: string; + limit?: number; +} + interface CreateMealLogInput { recipeId?: string; mealType: string; @@ -26,11 +37,11 @@ interface CreateMealLogInput { } export const mealLogsApi = { - getMealLogs: async (params?: { startDate?: string; endDate?: string }) => { + getMealLogs: async (params?: MealLogQueryParams) => { const response = await apiClient.get("/meal-logs", { params, }); - return response.data.data; + return response.data.data.items; }, getMealLog: async (id: string) => { diff --git a/apps/frontend/src/lib/api/recipes.api.test.ts b/apps/frontend/src/lib/api/recipes.api.test.ts new file mode 100644 index 0000000..0fa9850 --- /dev/null +++ b/apps/frontend/src/lib/api/recipes.api.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { recipesApi } from "./recipes.api"; + +const { getMock } = vi.hoisted(() => ({ + getMock: vi.fn(), +})); + +vi.mock("./client", () => ({ + apiClient: { + get: getMock, + }, +})); + +describe("recipesApi", () => { + beforeEach(() => { + getMock.mockReset(); + getMock.mockResolvedValue({ + data: { + status: "success", + data: { + recipes: [], + total: 0, + page: 1, + limit: 12, + }, + error: null, + }, + }); + }); + + it("loads cached recipes from the list endpoint when no search is present", async () => { + await recipesApi.getRecipes({ page: 1, limit: 12 }); + + expect(getMock).toHaveBeenCalledWith("/recipes", { + params: { page: 1, limit: 12 }, + }); + }); + + it("routes searched recipe browsing through the search endpoint", async () => { + await recipesApi.getRecipes({ search: "salad", page: 1, limit: 12 }); + + expect(getMock).toHaveBeenCalledWith("/recipes/search", { + params: { q: "salad", page: 1, limit: 12 }, + }); + }); +}); diff --git a/apps/frontend/src/lib/api/recipes.api.ts b/apps/frontend/src/lib/api/recipes.api.ts index 2868396..83fb52d 100644 --- a/apps/frontend/src/lib/api/recipes.api.ts +++ b/apps/frontend/src/lib/api/recipes.api.ts @@ -20,14 +20,13 @@ interface RecipeResponse { export const recipesApi = { getRecipes: async (filters?: RecipeFilters) => { - const response = await apiClient.get("/recipes", { - params: filters, - }); - return response.data.data; - }, - - getRecipe: async (id: string) => { - const response = await apiClient.get(`/recipes/${id}`); + const { search, ...restFilters } = filters ?? {}; + const response = await apiClient.get( + search ? "/recipes/search" : "/recipes", + { + params: search ? { q: search, ...restFilters } : restFilters, + } + ); return response.data.data; }, @@ -38,6 +37,11 @@ export const recipesApi = { return response.data.data; }, + getRecipe: async (id: string) => { + const response = await apiClient.get(`/recipes/${id}`); + return response.data.data; + }, + getRecommendations: async (limit = 10) => { const response = await apiClient.get( "/recipes/recommendations", diff --git a/apps/frontend/src/lib/hooks/use-meal-logs.ts b/apps/frontend/src/lib/hooks/use-meal-logs.ts index 2080f7c..816f49f 100644 --- a/apps/frontend/src/lib/hooks/use-meal-logs.ts +++ b/apps/frontend/src/lib/hooks/use-meal-logs.ts @@ -3,7 +3,14 @@ import { mealLogsApi } from "../api/meal-logs.api"; import { toast } from "sonner"; import type { MealLog } from "@/types"; -export function useMealLogs(params?: { startDate?: string; endDate?: string }) { +type MealLogQueryParams = { + date?: string; + range?: "day" | "week" | "month"; + cursor?: string; + limit?: number; +}; + +export function useMealLogs(params?: MealLogQueryParams) { return useQuery({ queryKey: ["meal-logs", params], queryFn: () => mealLogsApi.getMealLogs(params),