diff --git a/app/api/meal-plan-generator/services.ts b/app/api/meal-plan-generator/services.ts index 54f126c..0130db5 100644 --- a/app/api/meal-plan-generator/services.ts +++ b/app/api/meal-plan-generator/services.ts @@ -66,7 +66,7 @@ function validateBody(body: PlanGeneratorFormInput) { async function mealsByMealTypeAndTagIds(mealType: MealType, tagIds: number[]) { return await prisma.meal.findMany({ - where: { + where: { tags: { some: { id: { in: tagIds } } }, suggestedMealType: { has: mealType } }, @@ -74,10 +74,10 @@ async function mealsByMealTypeAndTagIds(mealType: MealType, tagIds: number[]) { }); } -async function mealsByTagIds(tagIds: number[]) { +async function mealsByMealType(mealType: MealType) { return await prisma.meal.findMany({ - where: { - tags: { some: { id: { in: tagIds } } } + where: { + suggestedMealType: { has: mealType } }, include: { tags: true } }); @@ -129,9 +129,9 @@ export async function generateMealPlan(body: PlanGeneratorFormInput, userId: str const failedToCreateObject = await buildFailedToCreateObject(tagIds, parsedDate, mealType) failedToCreate.push(failedToCreateObject) - const mealsByTags = await mealsByTagIds(tagIds) + const mealsByType = await mealsByMealType(mealType) - return findBestMeal(mealsByTags, tagIds) + return findBestMeal(mealsByType, tagIds) } else { return findBestMeal(mealsWithMealType, tagIds) @@ -139,12 +139,12 @@ export async function generateMealPlan(body: PlanGeneratorFormInput, userId: str })() if (meal === undefined) { - continue + continue } const mealPlan = { date: parsedDate, - type: mealType, + type: mealType, mealId: meal.id, userId }; diff --git a/app/api/meals/route.ts b/app/api/meals/route.ts index e0b2dbd..400050b 100644 --- a/app/api/meals/route.ts +++ b/app/api/meals/route.ts @@ -33,7 +33,7 @@ export async function POST(request: Request) { name: body.name, description: body.description || null, ingredients: body.ingredients, - recipe: body.recipe || null, + recipe: body.recipe || [], suggestedMealType: body.suggestedMealType, userId: session.user.id, tags: { diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..b971c59 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -13,6 +13,12 @@ const eslintConfig = defineConfig([ "build/**", "next-env.d.ts", ]), + { + files: ["test/**/*.ts", "test/**/*.tsx"], + rules: { + "@typescript-eslint/no-explicit-any": "off", + }, + }, ]); export default eslintConfig; diff --git a/package-lock.json b/package-lock.json index 431d208..d9761b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "dotenv-cli": "^11.0.0", "eslint": "^9", "eslint-config-next": "16.0.1", + "fishery": "^2.4.0", "jsdom": "^26.0.0", "prisma": "^6.19.0", "tsx": "^4.20.6", @@ -5367,6 +5368,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/fishery": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/fishery/-/fishery-2.4.0.tgz", + "integrity": "sha512-QgeTlvgNhVGuMztrfAhlSIBs3rD3l9RMjl9I15yb/lnrx3njrOhvegr2L3LWdqvXwYfQjdQGpglyAfHH2J8DRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash.mergewith": "^4.6.2" + } + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -6784,6 +6795,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "dev": true, + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", diff --git a/package.json b/package.json index 99131f8..df47aee 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "dotenv-cli": "^11.0.0", "eslint": "^9", "eslint-config-next": "16.0.1", + "fishery": "^2.4.0", "jsdom": "^26.0.0", "prisma": "^6.19.0", "tsx": "^4.20.6", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 290f27c..7f38bc6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -17,7 +17,7 @@ model Meal { mealPlans MealPlan[] suggestedMealType MealType[] tags Tag[] @relation("MealTags") - userId String? + userId String? user User? @relation(fields: [userId], references: [id]) } @@ -52,7 +52,7 @@ model User { createdAt DateTime @default(now()) mealPlans MealPlan[] //one to many preferences UserPreference[] - meals Meal[] // user-created meals + meals Meal[] // user-created meals } enum MealType { diff --git a/test/api/meal-plan-generator/services/find_best_meal.test.ts b/test/api/meal-plan-generator/services/find_best_meal.test.ts new file mode 100644 index 0000000..3ddf910 --- /dev/null +++ b/test/api/meal-plan-generator/services/find_best_meal.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest" +import { findBestMeal } from "@/app/api/meal-plan-generator/services" +import tagFactory from "@/test/factories/tag_factory" +import mealFactory from "@/test/factories/meal_factory" + +describe("findBestMeal", () => { + it('finds the meal with the most matched tags', async () => { + const vegetarianTag = await tagFactory.vegetarian().create() + const noGlutenTag = await tagFactory.noGluten().create() + const fruitsTag = await tagFactory.fruits().create() + const meatTag = await tagFactory.meat().create() + const veganTag = await tagFactory.vegan().create() + const quickTag = await tagFactory.quick().create() + + const meal1 = await mealFactory.create({ + name: "Owsianka z Owocami", + tags: [vegetarianTag, fruitsTag, quickTag, noGlutenTag] + }) + const meal2 = await mealFactory.create({ + name: "Kotlet schabowy", + tags: [meatTag] + }) + const meal3 = await mealFactory.create({ + name: "Makaron bez niczego", + tags: [veganTag] + }) + + const meals = [meal1, meal2, meal3] + const searchedTags = [vegetarianTag.id, quickTag.id, veganTag.id] + const bestMeal = findBestMeal(meals, searchedTags) + + expect(bestMeal.name).toBe("Owsianka z Owocami") + }) +}) diff --git a/test/api/meal-plan-generator/services/generate_meal_plan.test.ts b/test/api/meal-plan-generator/services/generate_meal_plan.test.ts new file mode 100644 index 0000000..369f643 --- /dev/null +++ b/test/api/meal-plan-generator/services/generate_meal_plan.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, beforeEach } from "vitest" +import prisma from "@/lib/prisma" +import { generateMealPlan } from "@/app/api/meal-plan-generator/services" +import tagFactory from "@/test/factories/tag_factory" +import mealFactory, { MealWithTags } from "@/test/factories/meal_factory" +import { MealType } from "@/app/generated/prisma/enums" +import userFactory from "@/test/factories/user_factory" +import { type Tag, type User, type MealPlan } from "@/app/generated/prisma/client" + +describe("generateMealPlan", () => { + let user: User + let noGlutenTag: Tag + let fruitsTag: Tag + let meatTag: Tag + let quickTag: Tag + let dessert: MealWithTags + let lunch: MealWithTags + + beforeEach(async() => { + user = await userFactory.create() + noGlutenTag = await tagFactory.noGluten().create() + fruitsTag = await tagFactory.fruits().create() + meatTag = await tagFactory.meat().create() + quickTag = await tagFactory.quick().create() + dessert = await mealFactory.create({ + name: "Deser owocowy", + suggestedMealType: [MealType.DESSERT], + tags: [noGlutenTag, fruitsTag] + }) + lunch = await mealFactory.create({ + name: "Schabowy", + suggestedMealType: [MealType.LUNCH], + tags: [meatTag, quickTag] + }) + }) + + it("Generates meal plan with meal with provided meal type and tags", async () => { + const requestBody = { + "2025-12-06": { + "BREAKFAST": [], + "LUNCH": [meatTag, quickTag].map((tag) => tag.id), + "DESSERT": [], + "DINNER": [] + }, + } + + const { count, failedToCreate } = await generateMealPlan(requestBody, user.id) + + expect(count).toEqual(1) + expect(failedToCreate.length).toEqual(0) + + const mealPlan = await prisma.mealPlan.findFirst() as MealPlan + expect(mealPlan.type).toEqual(MealType.LUNCH) + expect(mealPlan.mealId).toEqual(lunch.id) + expect(mealPlan.date).toEqual(new Date("2025-12-06")) + }) + + it("Generates meal plan with random meal with provided meal type if no meal with type and tags is found", async () => { + const requestBody = { + "2025-12-06": { + "BREAKFAST": [], + "LUNCH": [], + "DESSERT": [meatTag, quickTag].map((tag) => tag.id), + "DINNER": [] + }, + } + + const { count, failedToCreate } = await generateMealPlan(requestBody, user.id) + + expect(count).toEqual(1) + expect(failedToCreate.length).toEqual(1) + + const mealPlan = await prisma.mealPlan.findFirst() as MealPlan + expect(mealPlan.type).toEqual(MealType.DESSERT) + expect(mealPlan.mealId).toEqual(dessert.id) + expect(mealPlan.date).toEqual(new Date("2025-12-06")) + }) +}) diff --git a/test/api/meals.test.ts b/test/api/meals.test.ts index 810689d..6799a75 100644 --- a/test/api/meals.test.ts +++ b/test/api/meals.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import prisma from "@/lib/prisma"; -import { createTag } from "../helpers/records_creator"; +import tagFactory from "@/test/factories/tag_factory" +import userFactory from "@/test/factories/user_factory" vi.mock("@/lib/auth", () => ({ getSession: vi.fn(), @@ -15,22 +16,12 @@ describe("POST /api/meals", () => { }); it("creates a meal owned by the current user and connects tags", async () => { - const userId = "user_1"; - - await prisma.user.create({ - data: { - id: userId, - email: "user_1@example.com", - password: "hashed-password", - name: "User 1", - }, - }); - - const tag1 = await createTag("Wegetariańskie"); - const tag2 = await createTag("Szybkie"); + const user = await userFactory.create() + const tag1 = await tagFactory.vegetarian().create() + const tag2 = await tagFactory.quick().create() vi.mocked(getSession).mockResolvedValue({ - user: { id: userId }, + user: { id: user.id }, } as any); const { POST } = await import("@/app/api/meals/route"); @@ -54,13 +45,13 @@ describe("POST /api/meals", () => { const payload = await res.json(); expect(payload.ok).toBe(true); expect(payload.meal.name).toBe("Owsianka"); - expect(payload.meal.userId).toBe(userId); + expect(payload.meal.userId).toBe(user.id); expect(payload.meal.tags.map((t: any) => t.id).sort()).toEqual( [tag1.id, tag2.id].sort() ); const created = await prisma.meal.findFirst({ - where: { name: "Owsianka", userId }, + where: { name: "Owsianka", userId: user.id }, include: { tags: true }, }); diff --git a/test/api/services.test.ts b/test/api/services.test.ts deleted file mode 100644 index ad4afb1..0000000 --- a/test/api/services.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, it, expect } from "vitest" -import prisma from "@/lib/prisma" -import { createTag, toIdsArray } from "../helpers/records_creator" -import { findBestMeal } from "@/app/api/meal-plan-generator/services" - -describe("findBestMeal", () => { - it('finds the meal with the most matched tags', async () => { - const vegetarianTag = await createTag("Wegetariańskie") - const noGlutenTag = await createTag("Bezglutenowe") - const fruitsTag = await createTag("Owoce") - const meatTag = await createTag("Mięso") - const veganTag = await createTag("Wegańskie") - const fastTag = await createTag("Szybkie") - - const meal1 = await prisma.meal.create({ - data: { - name: "Owsianka z Owocami", - ingredients: {}, - tags: { - connect: toIdsArray([vegetarianTag, fruitsTag, fastTag, noGlutenTag]) - } - }, - include: { tags: true } - }) - const meal2 = await prisma.meal.create({ - data: { - name: "Kotlet schabowy", - ingredients: {}, - tags: { - connect: toIdsArray([meatTag]) - } - }, - include: { tags: true } - }) - const meal3 = await prisma.meal.create({ - data: { - name: "Makaron bez niczego", - ingredients: {}, - tags: { - connect: toIdsArray([veganTag]) - } - }, - include: { tags: true } - }) - - const meals = [meal1, meal2, meal3] - const searchedTags = [vegetarianTag.id, fastTag.id, veganTag.id] - const bestMeal = findBestMeal(meals, searchedTags) - - expect(bestMeal.name).toBe("Owsianka z Owocami") - }) -}) diff --git a/test/factories/meal_factory.ts b/test/factories/meal_factory.ts new file mode 100644 index 0000000..b79ab9b --- /dev/null +++ b/test/factories/meal_factory.ts @@ -0,0 +1,42 @@ +import prisma from "@/lib/prisma" +import { Factory } from "fishery" +import { Prisma, type Meal, type Tag} from "@/app/generated/prisma/client" +import { JsonValue } from "@prisma/client/runtime/library" + +export interface MealWithTags extends Meal { + tags: Tag[]; +} + +class MealFactory extends Factory { } + +const mealFactory = MealFactory.define(({ sequence, params, onCreate }) => { + onCreate((meal) => { + const tags = meal.tags || [] + + return prisma.meal.create({ + data: { + ...meal, + id: undefined, + ingredients: (meal.ingredients ?? {}) as Prisma.InputJsonValue, + recipe: (meal.recipe ?? Prisma.JsonNull) as Prisma.InputJsonValue, + tags: { + connect: tags.map(tag => ({ id: tag.id })) + } + }, + include: { tags: true } + }) + }) + + return { + id: sequence, + name: params.name!, + ingredients: (params.ingredients || {}) as JsonValue, + description: params.description ?? null, + recipe: (params.recipe || []) as JsonValue, + suggestedMealType: params.suggestedMealType || [], + userId: params.userId ?? null, + tags: params.tags || [] + } +}) + +export default mealFactory diff --git a/test/factories/tag_factory.ts b/test/factories/tag_factory.ts new file mode 100644 index 0000000..bde4149 --- /dev/null +++ b/test/factories/tag_factory.ts @@ -0,0 +1,33 @@ +import prisma from "@/lib/prisma" +import { Factory } from "fishery" +import { Tag, TagSection } from "@/app/generated/prisma/client" + +class TagFactory extends Factory { + vegetarian() { return this.params({ name: "Wegetariańskie" }) } + noGluten() { return this.params({ name: "Bezglutenowe" }) } + fruits() { return this.params({ name: "Owoce" }) } + meat() { return this.params({ name: "Mięso" }) } + vegan() { return this.params({ name: "Wegańskie" }) } + quick() { return this.params({ name: "Szybkie" }) } +} + +const tagFactory = TagFactory.define(({ sequence, params, onCreate }) => { + onCreate((tag) => { + return prisma.tag.create( + { + data: { + ...tag, + id: undefined + } + } + ) + }) + + return { + id: sequence, + name: params.name!, + section: params.section || TagSection.UNIVERSAL, + } +}) + +export default tagFactory diff --git a/test/factories/user_factory.ts b/test/factories/user_factory.ts new file mode 100644 index 0000000..0e1856f --- /dev/null +++ b/test/factories/user_factory.ts @@ -0,0 +1,21 @@ +import prisma from "@/lib/prisma" +import { Factory } from "fishery" +import { User } from "@/app/generated/prisma/client" + +class UserFactory extends Factory {} + +const userFactory = UserFactory.define(({ sequence, params, onCreate }) => { + onCreate((user) => { + return prisma.user.create({ data: user }); + }) + + return { + id: `user_${sequence}`, + name: params.name || `User ${sequence}`, + email: params.email || `user_${sequence}@example.com`, + password: params.password || "hashed-password", + createdAt: new Date() + } +}) + +export default userFactory diff --git a/test/helpers/reset-db.ts b/test/helpers/reset-db.ts index 8093fe4..68d5ee5 100644 --- a/test/helpers/reset-db.ts +++ b/test/helpers/reset-db.ts @@ -1,5 +1,3 @@ -// src/tests/helpers/reset-db.ts - import prisma from "@/lib/prisma" export default async function resetDB() { diff --git a/tsconfig.json b/tsconfig.json index 3a13f90..88497ba 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,7 +19,8 @@ } ], "paths": { - "@/*": ["./*"] + "@/*": ["./*"], + "@test/*": ["./test/*"] } }, "include": [ diff --git a/vitest.config.mts b/vitest.config.mts index 6903606..1c161ab 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -1,10 +1,11 @@ import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' import tsconfigPaths from 'vite-tsconfig-paths' - + export default defineConfig({ plugins: [tsconfigPaths(), react()], test: { + maxWorkers: 1, environment: 'jsdom', setupFiles: ['test/helpers/setup.ts'] },