Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions apps/backend/src/controllers/recipe.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ApiResponse>) {
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<ApiResponse>) {
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<ApiResponse>) {
const rawLimit = Number(req.query.limit ?? 10);
const limit = Number.isFinite(rawLimit)
Expand Down
12 changes: 12 additions & 0 deletions apps/backend/src/routes/recipe.routes.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
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";
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) =>
Expand Down
13 changes: 13 additions & 0 deletions apps/backend/tests/health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,24 @@ 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();

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");

Expand All @@ -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");

Expand All @@ -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");

Expand All @@ -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");

Expand All @@ -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)
Expand Down
94 changes: 94 additions & 0 deletions apps/backend/tests/recipe.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
4 changes: 2 additions & 2 deletions apps/frontend/src/app/(dashboard)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
50 changes: 50 additions & 0 deletions apps/frontend/src/lib/api/meal-logs.api.test.ts
Original file line number Diff line number Diff line change
@@ -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" },
});
});
});
17 changes: 14 additions & 3 deletions apps/frontend/src/lib/api/meal-logs.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<MealLogsResponse>("/meal-logs", {
params,
});
return response.data.data;
return response.data.data.items;
},

getMealLog: async (id: string) => {
Expand Down
46 changes: 46 additions & 0 deletions apps/frontend/src/lib/api/recipes.api.test.ts
Original file line number Diff line number Diff line change
@@ -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 },
});
});
});
Loading
Loading