diff --git a/.mocharc.cjs b/.mocharc.cjs new file mode 100644 index 0000000..20b6900 --- /dev/null +++ b/.mocharc.cjs @@ -0,0 +1,7 @@ +module.exports = { + ignore: [ + 'test/contractTests/**', + // Jest-style unit test — run via `npx jest`, not Mocha + 'test/recipeDiscoveryAndFilter.test.js', + ], +}; diff --git a/controller/filterController.js b/controller/filterController.js index 1527fd4..7ed59c0 100644 --- a/controller/filterController.js +++ b/controller/filterController.js @@ -1,39 +1,96 @@ const supabase = require('../dbConnection'); /** - * Filter recipes based on dietary preferences and allergens - * @param {Request} req - Express request object - * @param {Response} res - Express response object + * GET /api/filter + * + * Canonical server-side recipe filter endpoint. This is intentionally the + * ONLY discovery filter endpoint on the backend — UI-only refinements + * (sorting client-loaded results, toggling favourites, etc.) stay in the + * frontend. See docs/RECIPES_SCOPE.md for the full scope contract. + * + * Supported query parameters (all optional): + * - allergies comma separated list or repeated param + * - dietary single dietary name (partial match) + * - cuisine_id numeric cuisine id + * - search partial match on recipe_name (ILIKE) + * - limit page size (default 50, max 200) + * - offset pagination offset (default 0) + * + * Response shape is preserved as a JSON array of recipes to avoid breaking + * existing frontend consumers. */ +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 200; + +function parsePaginationParam(value, fallback, max) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 0) return fallback; + if (max && parsed > max) return max; + return parsed; +} + +function parseAllergyList(allergies) { + if (!allergies) return []; + const raw = Array.isArray(allergies) ? allergies : String(allergies).split(','); + return raw + .map((allergy) => String(allergy || '').toLowerCase().trim()) + .filter(Boolean); +} + const filterRecipes = async (req, res) => { - const { allergies, dietary } = req.query; + const { allergies, dietary, cuisine_id, search } = req.query; + const limit = parsePaginationParam(req.query.limit, DEFAULT_LIMIT, MAX_LIMIT); + const offset = parsePaginationParam(req.query.offset, 0); try { - // Fetch the mapping of dietary names to IDs - const { data: dietaryMapping, error: dietaryError } = await supabase - .from('dietary_requirements') - .select('id, name'); + // Resolve dietary -> id list once. We keep partial-name matching for + // backwards compatibility with the existing frontend dropdown. + let dietaryFilterIds = []; + if (dietary) { + const { data: dietaryMapping, error: dietaryError } = await supabase + .from('dietary_requirements') + .select('id, name'); + + if (dietaryError) throw dietaryError; - if (dietaryError) throw dietaryError; + const matches = (dietaryMapping || []).filter((d) => + d.name.toLowerCase().includes(String(dietary).toLowerCase()) + ); - // Validate dietary input - if (dietary && !dietaryMapping.some(d => d.name.toLowerCase().includes(dietary.toLowerCase()))) { - return res.status(400).json({ error: "Invalid dietary requirement provided" }); + if (!matches.length) { + return res.status(400).json({ error: 'Invalid dietary requirement provided' }); + } + + dietaryFilterIds = matches.map((d) => d.id.toString()); } - // Find dietary IDs for partial matches - const dietaryFilterIds = dietary - ? dietaryMapping - .filter(d => d.name.toLowerCase().includes(dietary.toLowerCase())) - .map(d => d.id.toString()) - : []; + // Validate allergens against the canonical allergies table. + const allergyList = parseAllergyList(allergies); + if (allergyList.length) { + const { data: allergensMapping, error: allergensError } = await supabase + .from('allergies') + .select('id, name'); + + if (allergensError) throw allergensError; - // Fetch recipes with their dietary requirements and ingredients - const { data: recipes, error: recipeError } = await supabase + const allKnown = allergyList.every((allergy) => + (allergensMapping || []).some((a) => a.name.toLowerCase().includes(allergy)) + ); + + if (!allKnown) { + return res.status(400).json({ error: 'Invalid allergen provided' }); + } + } + + // Build the base query with server-side filters where Supabase supports + // them. Allergy filtering relies on joined ingredient data, so it still + // runs in JS, but the result set is pre-narrowed by cuisine/search. + let query = supabase .from('recipes') .select(` id, recipe_name, + cuisine_id, dietary, dietary_requirements ( id, @@ -49,48 +106,38 @@ const filterRecipes = async (req, res) => { ) `); - if (recipeError) throw recipeError; + if (cuisine_id) { + const cuisineIdNum = Number.parseInt(cuisine_id, 10); + if (!Number.isFinite(cuisineIdNum)) { + return res.status(400).json({ error: 'cuisine_id must be numeric' }); + } + query = query.eq('cuisine_id', cuisineIdNum); + } - // Validate allergies input - const allergyList = allergies - ? (Array.isArray(allergies) ? allergies : allergies.split(',')).map(allergy => - allergy.toLowerCase().trim() - ) - : []; - - const { data: allergensMapping, error: allergensError } = await supabase - .from('allergies') - .select('id, name'); - - if (allergensError) throw allergensError; - - if ( - allergyList.length && - !allergyList.every(allergy => - allergensMapping.some(a => a.name.toLowerCase().includes(allergy)) - ) - ) { - return res.status(400).json({ error: "Invalid allergen provided" }); + if (search) { + // Escape % and _ to keep ILIKE safe-ish; treat the rest as literal. + const safeSearch = String(search).replace(/[%_]/g, (c) => `\\${c}`); + query = query.ilike('recipe_name', `%${safeSearch}%`); } - // Filter recipes based on dietary requirements and allergens - const filteredRecipes = recipes.filter(recipe => { - // Check if any ingredient in the recipe has an allergen matching the allergyList (partial match) - const hasAllergy = recipe.ingredients.some(ingredient => { - return ( - ingredient.allergies_type && - allergyList.some(allergy => - ingredient.allergies_type.name - .toLowerCase() - .includes(allergy) // Check for partial match - ) - ); + // Pull a slightly larger window than `limit` because allergy filtering + // can shrink the page; we re-slice after filtering. + query = query.range(offset, offset + Math.max(limit * 2, limit + 25) - 1); + + const { data: recipes, error: recipeError } = await query; + if (recipeError) throw recipeError; + + const filteredRecipes = (recipes || []).filter((recipe) => { + const ingredients = Array.isArray(recipe.ingredients) ? recipe.ingredients : []; + + const hasAllergy = ingredients.some((ingredient) => { + if (!ingredient?.allergies_type?.name) return false; + const allergenName = ingredient.allergies_type.name.toLowerCase(); + return allergyList.some((allergy) => allergenName.includes(allergy)); }); - // Exclude recipes with ingredients containing allergens if (hasAllergy) return false; - // Check if recipe matches any of the dietary filter IDs const dietaryCheck = !dietaryFilterIds.length || (recipe.dietary && dietaryFilterIds.includes(recipe.dietary.toString())); @@ -98,10 +145,13 @@ const filterRecipes = async (req, res) => { return dietaryCheck; }); - res.status(200).json(filteredRecipes); + // Apply final pagination slice after allergy filtering. + const page = filteredRecipes.slice(0, limit); + + return res.status(200).json(page); } catch (error) { console.error('Error filtering recipes:', error.message); - res.status(400).json({ error: error.message }); + return res.status(400).json({ error: error.message }); } }; diff --git a/controller/recipeController.js b/controller/recipeController.js index 0bcb768..d5dba92 100644 --- a/controller/recipeController.js +++ b/controller/recipeController.js @@ -346,21 +346,79 @@ const listAdminRecipes = async (req, res) => { } }; +/** + * GET /api/recipe/community + * + * Refined to accept light discovery filters so the frontend does not need a + * parallel "community discovery" endpoint. Supported query parameters: + * - search partial match on recipe_name (ILIKE) + * - cuisine_id numeric cuisine filter + * - cooking_method_id numeric cooking method filter + * - sort "latest" (default) | "oldest" | "name" + * - limit page size (default 300, max 1000) + * - offset pagination offset (default 0) + * + * Anything beyond this (favourites, client-side reordering, etc.) stays in + * the frontend — see docs/RECIPES_SCOPE.md. + */ const listCommunityRecipes = async (req, res) => { try { const limit = Math.max(1, Math.min(Number(req.query.limit) || 300, 1000)); - const { data, error } = await supabase + const offset = Math.max(0, Number(req.query.offset) || 0); + const { search, cuisine_id, cooking_method_id, sort } = req.query; + + let query = supabase .from("recipes") .select("*") .eq("visibility", "community") - .eq("is_published", true) - .order("published_at", { ascending: false }) - .limit(limit); + .eq("is_published", true); + + if (search) { + const safeSearch = String(search).replace(/[%_]/g, (c) => `\\${c}`); + query = query.ilike("recipe_name", `%${safeSearch}%`); + } + + if (cuisine_id) { + const cuisineIdNum = Number.parseInt(cuisine_id, 10); + if (!Number.isFinite(cuisineIdNum)) { + return res.status(400).json({ error: "cuisine_id must be numeric", statusCode: 400 }); + } + query = query.eq("cuisine_id", cuisineIdNum); + } + + if (cooking_method_id) { + const cookingMethodIdNum = Number.parseInt(cooking_method_id, 10); + if (!Number.isFinite(cookingMethodIdNum)) { + return res.status(400).json({ error: "cooking_method_id must be numeric", statusCode: 400 }); + } + query = query.eq("cooking_method_id", cookingMethodIdNum); + } + + switch (String(sort || "").toLowerCase()) { + case "oldest": + query = query.order("published_at", { ascending: true }); + break; + case "name": + query = query.order("recipe_name", { ascending: true }); + break; + case "latest": + default: + query = query.order("published_at", { ascending: false }); + break; + } + + query = query.range(offset, offset + limit - 1); + const { data, error } = await query; if (error) throw error; const recipes = await decorateRecipes(data || []); - return res.status(200).json({ message: "success", statusCode: 200, recipes }); + return res.status(200).json({ + message: "success", + statusCode: 200, + recipes, + pagination: { limit, offset, count: recipes.length }, + }); } catch (error) { console.error("Error loading community recipes:", error); return res.status(500).json({ error: "Internal server error", statusCode: 500 }); @@ -369,11 +427,14 @@ const listCommunityRecipes = async (req, res) => { const shareRecipeToCommunity = async (req, res) => { const recipeId = Number(req.params.id); - const userId = Number(req.body.user_id || req.body.userId || req.user?.userId); + // Ownership is derived from the authenticated session; we deliberately + // ignore any user_id supplied in the request body so a caller cannot + // submit someone else's recipe for community review. + const userId = Number(req.user?.userId); try { if (!recipeId || !userId) { - return res.status(400).json({ error: "Recipe ID and User ID are required", statusCode: 400 }); + return res.status(400).json({ error: "Recipe ID and authenticated user are required", statusCode: 400 }); } const { data: recipe, error: recipeError } = await supabase @@ -418,11 +479,13 @@ const shareRecipeToCommunity = async (req, res) => { const unshareRecipeFromCommunity = async (req, res) => { const recipeId = Number(req.params.id); - const userId = Number(req.body.user_id || req.body.userId || req.user?.userId); + // Ownership is derived from the authenticated session — see + // shareRecipeToCommunity for rationale. + const userId = Number(req.user?.userId); try { if (!recipeId || !userId) { - return res.status(400).json({ error: "Recipe ID and User ID are required", statusCode: 400 }); + return res.status(400).json({ error: "Recipe ID and authenticated user are required", statusCode: 400 }); } const { data: recipe, error: recipeError } = await supabase diff --git a/docs/RECIPES_SCOPE.md b/docs/RECIPES_SCOPE.md new file mode 100644 index 0000000..3753b77 --- /dev/null +++ b/docs/RECIPES_SCOPE.md @@ -0,0 +1,111 @@ +# Recipe Backend Scope + +This document records what the backend is intentionally responsible for around +recipe discovery, filtering, and user-created recipes — and what is +intentionally left to the frontend. The goal is to keep the backend surface +small and predictable instead of growing one endpoint per UI affordance. + +If you are about to add a new recipe-related route, read this first. + +## TL;DR — what stays on the backend + +| Capability | Endpoint | +| --------------------------------------------------- | ------------------------------------------------- | +| User's own recipes (list) | `GET /api/recipe-library/my` | +| Public catalog (list) | `GET /api/recipe-library/public` | +| Community feed (list, with search/cuisine/sort) | `GET /api/recipe/community` | +| Add-meal picker | `GET /api/recipe-library/add-meal` | +| Recipe detail | `GET /api/recipe-library/:id` | +| Server-side filter (dietary + allergy + cuisine) | `GET /api/filter` | +| Create a user recipe | `POST /api/recipe/createRecipe` | +| Create a private library recipe | `POST /api/recipe-library` | +| Update own recipe | `PATCH /api/recipe-library/:id` | +| Delete own recipe | `DELETE /api/recipe` | +| Submit a recipe for community review | `POST /api/recipe/:id/share-community` | +| Stop sharing a recipe with the community | `POST /api/recipe/:id/unshare-community` | +| Admin community moderation | `PATCH /api/recipe/admin/:id/visibility` + `/api/recipe-library/admin/...` | +| Recipe scaling (servings) | `GET /api/recipe/scale/:recipe_id/:desired_servings` | + +`/api/recipe/community` and `/api/filter` are the two canonical discovery +surfaces. They accept `search`, `cuisine_id`, `cooking_method_id`, `sort`, +`limit`, and `offset` so the frontend can paginate and refine without +needing a separate "discovery" API. + +## What stays on the frontend + +The following are intentionally **not** backed by dedicated APIs. They are +either pure UI concerns or trivial transforms over data the frontend has +already fetched: + +* **Sorting an already-loaded page** (most-recent, alphabetical, etc.) once + the user has the result set in memory. The backend exposes `sort` for the + cases where the order affects which rows fall inside the page window; + re-ordering a single page in the client does not justify a new endpoint. +* **Client-side favourite/recently-viewed lists** that are persisted to + `localStorage` only. If/when these need to sync across devices a single + small endpoint can be added — until then there is no product need. +* **Ingredient unit conversion display** (e.g. grams ↔ ounces). The numbers + come from `/api/recipe/scale/...`; presentation conversion is UI logic. +* **Tag / chip rendering** for dietary tags, spice level, difficulty, etc. + These ride along on the existing recipe payload — no separate "tags" + endpoint is needed. +* **Search-as-you-type debouncing and request throttling.** This is a + frontend concern; the backend filter endpoint is the same regardless of + how often it is called. +* **"Print recipe" / "share to clipboard" actions.** Pure client-side. + +## Discovery endpoint contract + +Both `/api/filter` and `/api/recipe/community` accept the following query +parameters (all optional): + +| Param | Type | Notes | +| ------------------ | ------- | -------------------------------------------------------------- | +| `search` | string | Partial match on `recipe_name`, `%` and `_` are escaped. | +| `cuisine_id` | number | Filters server-side via `eq('cuisine_id', …)`. | +| `cooking_method_id`| number | Community list only. | +| `allergies` | csv | `/api/filter` only. Excludes recipes with matching allergens. | +| `dietary` | string | `/api/filter` only. Partial name match against dietary table. | +| `sort` | enum | Community list only: `latest` (default), `oldest`, `name`. | +| `limit` | number | Page size. Capped per-endpoint. | +| `offset` | number | Pagination offset. | + +Behaviours we intentionally do **not** support on these endpoints: + +* Free-form full-text search across instructions or tags — out of scope. +* Multi-cuisine OR / NOT filters — the UI only needs single-select today. +* "Recommended for me" personalisation — that lives behind + `/api/recommendations`, not the discovery filter. + +## User recipe create / update + +* `POST /api/recipe/createRecipe` is the legacy create endpoint. It hard-codes + `visibility = "user_private"` and `is_published = false` server-side so a + client cannot create something that goes straight to the community feed. +* `POST /api/recipe-library` is the canonical create endpoint for new + client work and should be preferred. +* `PATCH /api/recipe-library/:id` is the only user-recipe update endpoint. + We do **not** mirror it under `/api/recipe/:id` — owning a single update + surface keeps validation, audit logging, and authorization in one place. + +## Ownership & authorization + +* `POST /api/recipe/:id/share-community` and `…/unshare-community` derive + ownership from `req.user.userId`. Any `user_id` supplied in the request + body is ignored. This prevents a client from submitting another user's + recipe for community review. +* Admin moderation routes are gated by `authorizeRoles('admin')`. + +## Adding new recipe routes — checklist + +Before adding a new recipe route, confirm: + +1. The behaviour is not already supported by `/api/filter`, + `/api/recipe/community`, `/api/recipe-library/*`, or the scaling endpoint. +2. The behaviour cannot live in the frontend over data we already return. +3. There is a real product need — not just a refactor or convenience. +4. The new endpoint reuses existing services (`recipeLibraryService`, + `decorateRecipes`, etc.) instead of cloning their logic. + +If any of those fail, the right move is to refine an existing endpoint +rather than add a new one. diff --git a/package.json b/package.json index e2724e2..ac5589c 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "dev": "nodemon server.js", "dev:all": "concurrently --names \"API,AI\" --prefix-colors \"blue,green\" \"npm run dev\" \"cd ../NutriHelp-AI && python3 run.py\"", "start:all": "concurrently --names \"API,AI\" --prefix-colors \"blue,green\" \"npm run start\" \"cd ../NutriHelp-AI && python3 run.py\"", - "test": "mocha ./test/**/*.test.js --timeout 5000 --exit --ignore ./test/contractTests/**", + "test": "mocha 'test/**/*.test.js' --timeout 5000 --exit", "test:unit": "mocha ./test/unit/**/*.test.js --timeout 5000 --exit", "test:contract": "jest ./test/contractTests/ --testTimeout=10000 --forceExit", "lint": "eslint . --ext .js --max-warnings=0", diff --git a/test/recipeDiscoveryAndFilter.test.js b/test/recipeDiscoveryAndFilter.test.js new file mode 100644 index 0000000..0d984b2 --- /dev/null +++ b/test/recipeDiscoveryAndFilter.test.js @@ -0,0 +1,370 @@ +/** + * Tests for the refined recipe discovery, filter, and ownership flows. + * + * Behaviour under test: + * - GET /api/filter pushes cuisine_id / search to Supabase server-side + * - GET /api/recipe/community paginates and sorts server-side + * - share/unshare community endpoints ignore body user_id and derive + * ownership from req.user.userId + * + * We use Jest's built-in `expect` and module mocking (rather than chai/sinon) + * because the chai v6 dependency in this repo is ESM-only and not loadable + * from CommonJS Jest. + */ + +function buildFromMock(handler) { + return (table) => handler(table); +} + +function makeRes() { + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + return res; +} + +afterEach(() => { + jest.resetModules(); +}); + +describe('GET /api/filter (filterController.filterRecipes)', () => { + test('applies cuisine_id and search server-side and respects limit', async () => { + const calls = { + cuisineEqArgs: null, + ilikeArgs: null, + rangeArgs: null, + }; + + const recipesQuery = { + select() { return this; }, + eq(col, val) { + if (col === 'cuisine_id') calls.cuisineEqArgs = [col, val]; + return this; + }, + ilike(col, pattern) { + calls.ilikeArgs = [col, pattern]; + return this; + }, + range(from, to) { + calls.rangeArgs = [from, to]; + return Promise.resolve({ + data: [ + { id: 1, recipe_name: 'Curry Bowl', cuisine_id: 7, ingredients: [] }, + { id: 2, recipe_name: 'Curry Wrap', cuisine_id: 7, ingredients: [] }, + { id: 3, recipe_name: 'Curry Soup', cuisine_id: 7, ingredients: [] }, + ], + error: null, + }); + }, + }; + + jest.doMock('../dbConnection', () => ({ + from: buildFromMock(() => recipesQuery), + }), { virtual: false }); + + const controller = require('../controller/filterController'); + + const req = { query: { cuisine_id: '7', search: 'curry', limit: '2', offset: '0' } }; + const res = makeRes(); + + await controller.filterRecipes(req, res); + + expect(calls.cuisineEqArgs).toEqual(['cuisine_id', 7]); + expect(calls.ilikeArgs[0]).toBe('recipe_name'); + expect(calls.ilikeArgs[1]).toBe('%curry%'); + expect(calls.rangeArgs[0]).toBe(0); + expect(res.status).toHaveBeenCalledWith(200); + const payload = res.json.mock.calls[0][0]; + expect(Array.isArray(payload)).toBe(true); + expect(payload).toHaveLength(2); // limit honoured after filtering + }); + + test('rejects a non-numeric cuisine_id with 400', async () => { + jest.doMock('../dbConnection', () => ({ + from: buildFromMock(() => ({ + select() { return this; }, + eq() { return this; }, + ilike() { return this; }, + range() { return Promise.resolve({ data: [], error: null }); }, + })), + })); + + const controller = require('../controller/filterController'); + + const req = { query: { cuisine_id: 'not-a-number' } }; + const res = makeRes(); + + await controller.filterRecipes(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json.mock.calls[0][0]).toEqual({ error: 'cuisine_id must be numeric' }); + }); + + test('escapes % and _ in the search term', async () => { + let capturedPattern = null; + const recipesQuery = { + select() { return this; }, + eq() { return this; }, + ilike(_col, pattern) { + capturedPattern = pattern; + return this; + }, + range() { + return Promise.resolve({ data: [], error: null }); + }, + }; + jest.doMock('../dbConnection', () => ({ + from: buildFromMock(() => recipesQuery), + })); + const controller = require('../controller/filterController'); + + const req = { query: { search: '50%_off' } }; + const res = makeRes(); + + await controller.filterRecipes(req, res); + + expect(capturedPattern).toBe('%50\\%\\_off%'); + }); +}); + +describe('GET /api/recipe/community (recipeController.listCommunityRecipes)', () => { + test('pushes search, cuisine_id, sort, and pagination to Supabase', async () => { + const calls = { + eqs: [], + ilike: null, + order: null, + range: null, + }; + + const recipesQuery = { + select() { return this; }, + eq(col, val) { + calls.eqs.push([col, val]); + return this; + }, + ilike(col, pattern) { + calls.ilike = [col, pattern]; + return this; + }, + order(col, opts) { + calls.order = [col, opts]; + return this; + }, + range(from, to) { + calls.range = [from, to]; + return Promise.resolve({ data: [], error: null }); + }, + }; + + jest.doMock('../dbConnection.js', () => ({ + from: buildFromMock(() => recipesQuery), + storage: { from: () => ({ getPublicUrl: () => ({ data: { publicUrl: '' } }) }) }, + })); + jest.doMock('../model/createRecipe.js', () => ({})); + jest.doMock('../model/getUserRecipes.js', () => ({})); + jest.doMock('../model/deleteUserRecipes.js', () => ({})); + + const controller = require('../controller/recipeController'); + + const req = { + query: { + search: 'tofu', + cuisine_id: '3', + sort: 'name', + limit: '25', + offset: '50', + }, + }; + const res = makeRes(); + + await controller.listCommunityRecipes(req, res); + + const eqMap = new Map(calls.eqs); + expect(eqMap.get('visibility')).toBe('community'); + expect(eqMap.get('is_published')).toBe(true); + expect(eqMap.get('cuisine_id')).toBe(3); + expect(calls.ilike).toEqual(['recipe_name', '%tofu%']); + expect(calls.order).toEqual(['recipe_name', { ascending: true }]); + expect(calls.range).toEqual([50, 74]); // offset .. offset+limit-1 + expect(res.status).toHaveBeenCalledWith(200); + const payload = res.json.mock.calls[0][0]; + expect(payload).toHaveProperty('pagination'); + expect(payload.pagination).toMatchObject({ limit: 25, offset: 50 }); + }); + + test('defaults to latest ordering when sort is omitted', async () => { + let capturedOrder = null; + const recipesQuery = { + select() { return this; }, + eq() { return this; }, + order(col, opts) { + capturedOrder = [col, opts]; + return this; + }, + range() { + return Promise.resolve({ data: [], error: null }); + }, + }; + jest.doMock('../dbConnection.js', () => ({ + from: buildFromMock(() => recipesQuery), + storage: { from: () => ({ getPublicUrl: () => ({ data: { publicUrl: '' } }) }) }, + })); + jest.doMock('../model/createRecipe.js', () => ({})); + jest.doMock('../model/getUserRecipes.js', () => ({})); + jest.doMock('../model/deleteUserRecipes.js', () => ({})); + + const controller = require('../controller/recipeController'); + + const req = { query: {} }; + const res = makeRes(); + + await controller.listCommunityRecipes(req, res); + + expect(capturedOrder).toEqual(['published_at', { ascending: false }]); + }); +}); + +describe('share/unshare community endpoints — ownership', () => { + test('share ignores body user_id and uses req.user.userId', async () => { + const recipeRow = { id: 99, user_id: 42, recipe_name: 'Test' }; + let capturedUserIdEq = null; + let updatePayload = null; + let notificationPayload = null; + + const recipesQuery = { + select() { return this; }, + eq(col, val) { + if (col === 'user_id') capturedUserIdEq = val; + return this; + }, + single() { + return Promise.resolve({ data: recipeRow, error: null }); + }, + update(payload) { + updatePayload = payload; + return { + eq() { return Promise.resolve({ error: null }); }, + }; + }, + }; + + const notificationsQuery = { + insert(payload) { + notificationPayload = payload; + return Promise.resolve({ error: null }); + }, + }; + + jest.doMock('../dbConnection.js', () => ({ + from: buildFromMock((table) => { + if (table === 'recipes') return recipesQuery; + if (table === 'notifications') return notificationsQuery; + throw new Error(`Unexpected table: ${table}`); + }), + storage: { from: () => ({ getPublicUrl: () => ({ data: { publicUrl: '' } }) }) }, + })); + jest.doMock('../model/createRecipe.js', () => ({})); + jest.doMock('../model/getUserRecipes.js', () => ({})); + jest.doMock('../model/deleteUserRecipes.js', () => ({})); + + const controller = require('../controller/recipeController'); + + // The caller tries to spoof a different user via body — must be ignored. + const req = { + params: { id: '99' }, + body: { user_id: 999, userId: 999 }, + user: { userId: 42 }, + }; + const res = makeRes(); + + await controller.shareRecipeToCommunity(req, res); + + expect(capturedUserIdEq).toBe(42); + expect(updatePayload).toMatchObject({ + visibility: 'community_pending', + is_published: false, + }); + expect(notificationPayload.user_id).toBe(42); + expect(res.status).toHaveBeenCalledWith(200); + }); + + test('share returns 400 when there is no authenticated user', async () => { + jest.doMock('../dbConnection.js', () => ({ + from: buildFromMock(() => ({ + select() { return this; }, + eq() { return this; }, + single() { return Promise.resolve({ data: null, error: null }); }, + })), + storage: { from: () => ({ getPublicUrl: () => ({ data: { publicUrl: '' } }) }) }, + })); + jest.doMock('../model/createRecipe.js', () => ({})); + jest.doMock('../model/getUserRecipes.js', () => ({})); + jest.doMock('../model/deleteUserRecipes.js', () => ({})); + + const controller = require('../controller/recipeController'); + + const req = { params: { id: '99' }, body: { user_id: 1 }, user: undefined }; + const res = makeRes(); + + await controller.shareRecipeToCommunity(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + test('unshare ignores body user_id and uses req.user.userId', async () => { + const recipeRow = { + id: 88, + user_id: 7, + recipe_name: 'Test 2', + visibility: 'community_pending', + }; + let capturedUserIdEq = null; + + const recipesQuery = { + select() { return this; }, + eq(col, val) { + if (col === 'user_id') capturedUserIdEq = val; + return this; + }, + single() { return Promise.resolve({ data: recipeRow, error: null }); }, + update() { + return { + eq() { + return { + eq() { return Promise.resolve({ error: null }); }, + }; + }, + }; + }, + }; + const notificationsQuery = { + insert() { return Promise.resolve({ error: null }); }, + }; + jest.doMock('../dbConnection.js', () => ({ + from: buildFromMock((table) => { + if (table === 'recipes') return recipesQuery; + if (table === 'notifications') return notificationsQuery; + throw new Error(`Unexpected table: ${table}`); + }), + storage: { from: () => ({ getPublicUrl: () => ({ data: { publicUrl: '' } }) }) }, + })); + jest.doMock('../model/createRecipe.js', () => ({})); + jest.doMock('../model/getUserRecipes.js', () => ({})); + jest.doMock('../model/deleteUserRecipes.js', () => ({})); + + const controller = require('../controller/recipeController'); + + const req = { + params: { id: '88' }, + body: { user_id: 12345 }, + user: { userId: 7 }, + }; + const res = makeRes(); + + await controller.unshareRecipeFromCommunity(req, res); + + expect(capturedUserIdEq).toBe(7); + expect(res.status).toHaveBeenCalledWith(200); + }); +});