'use strict'; const request = require('supertest'); const app = require('../../src/main/app'); const { setDb } = require('../../src/db/knex'); const { createTestDb } = require('../helpers/testDb'); const { issueToken } = require('../../src/utils/jwt'); const { v4: uuidv4 } = require('uuid'); function makeUser(overrides = {}) { return { id: uuidv4(), email: 'jane@example.com', name: 'Jane Doe', profile_picture_url: null, email_verified: true, deleted_at: null, deletion_scheduled_at: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), ...overrides, }; } const RECIPE_ID = '00000000-0000-0000-0000-000000000001'; describe('Shopping List Routes', () => { let db; let user; let token; beforeEach(() => { user = makeUser(); db = createTestDb({ users: [user], shopping_lists: [], shopping_list_items: [], deleted_records: [], recipes: [ { id: RECIPE_ID, name: 'Pancakes', instructions: '1. Mix. 2. Cook.', servings: 4, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }, ], recipe_ingredients: [ { id: uuidv4(), recipe_id: RECIPE_ID, item_name: 'flour', item_name_lower: 'flour', quantity: '2.0000', unit: 'cups', }, { id: uuidv4(), recipe_id: RECIPE_ID, item_name: 'milk', item_name_lower: 'milk', quantity: '1.5000', unit: 'cups', }, ], }); setDb(db); token = issueToken(user).token; }); // ── GET /v1/shopping-lists ────────────────────────────────────────────────── describe('GET /v1/shopping-lists', () => { it('returns empty array for new user', async () => { const res = await request(app) .get('/v1/shopping-lists') .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(200); expect(res.body.shopping_lists).toEqual([]); expect(res.body.synced_at).toBeDefined(); }); it('returns 401 without token', async () => { const res = await request(app).get('/v1/shopping-lists'); expect(res.status).toBe(401); }); }); // ── POST /v1/shopping-lists ───────────────────────────────────────────────── describe('POST /v1/shopping-lists', () => { it('creates a new shopping list', async () => { const res = await request(app) .post('/v1/shopping-lists') .set('Authorization', `Bearer ${token}`) .send({ list_name: 'Weekly Groceries' }); expect(res.status).toBe(201); expect(res.body.shopping_list.list_name).toBe('Weekly Groceries'); expect(res.body.shopping_list.item_count).toBe(0); expect(res.body.shopping_list.id).toBeDefined(); }); it('returns 400 for missing list_name', async () => { const res = await request(app) .post('/v1/shopping-lists') .set('Authorization', `Bearer ${token}`) .send({}); expect(res.status).toBe(400); }); it('returns 400 for empty list_name', async () => { const res = await request(app) .post('/v1/shopping-lists') .set('Authorization', `Bearer ${token}`) .send({ list_name: '' }); expect(res.status).toBe(400); }); it('returns 401 without token', async () => { const res = await request(app) .post('/v1/shopping-lists') .send({ list_name: 'Test' }); expect(res.status).toBe(401); }); }); // ── GET /v1/shopping-lists/:list_id ──────────────────────────────────────── describe('GET /v1/shopping-lists/:list_id', () => { let listId; beforeEach(async () => { const res = await request(app) .post('/v1/shopping-lists') .set('Authorization', `Bearer ${token}`) .send({ list_name: 'My List' }); listId = res.body.shopping_list.id; }); it('returns list with items', async () => { const res = await request(app) .get(`/v1/shopping-lists/${listId}`) .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(200); expect(res.body.shopping_list.id).toBe(listId); expect(res.body.shopping_list.items).toEqual([]); }); it('returns 404 for non-existent list', async () => { const res = await request(app) .get('/v1/shopping-lists/00000000-0000-0000-0000-000000000000') .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(404); }); it('returns 404 for list belonging to another user', async () => { const otherUser = makeUser({ id: uuidv4(), email: 'other@example.com' }); const otherToken = issueToken(otherUser).token; const res = await request(app) .get(`/v1/shopping-lists/${listId}`) .set('Authorization', `Bearer ${otherToken}`); expect(res.status).toBe(404); }); }); // ── DELETE /v1/shopping-lists/:list_id ───────────────────────────────────── describe('DELETE /v1/shopping-lists/:list_id', () => { let listId; beforeEach(async () => { const res = await request(app) .post('/v1/shopping-lists') .set('Authorization', `Bearer ${token}`) .send({ list_name: 'Delete Me' }); listId = res.body.shopping_list.id; }); it('deletes the shopping list', async () => { const res = await request(app) .delete(`/v1/shopping-lists/${listId}`) .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(204); const getRes = await request(app) .get(`/v1/shopping-lists/${listId}`) .set('Authorization', `Bearer ${token}`); expect(getRes.status).toBe(404); }); it('records tombstone for sync', async () => { await request(app) .delete(`/v1/shopping-lists/${listId}`) .set('Authorization', `Bearer ${token}`); const tombstones = db.getTable('deleted_records'); expect(tombstones.some((t) => t.record_id === listId)).toBe(true); }); it('returns 404 for non-existent list', async () => { const res = await request(app) .delete('/v1/shopping-lists/00000000-0000-0000-0000-000000000000') .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(404); }); }); // ── POST /v1/shopping-lists/:list_id/items ────────────────────────────────── describe('POST /v1/shopping-lists/:list_id/items', () => { let listId; beforeEach(async () => { const res = await request(app) .post('/v1/shopping-lists') .set('Authorization', `Bearer ${token}`) .send({ list_name: 'Groceries' }); listId = res.body.shopping_list.id; }); it('adds an item to the list', async () => { const res = await request(app) .post(`/v1/shopping-lists/${listId}/items`) .set('Authorization', `Bearer ${token}`) .send({ item_name: 'flour', quantity: 2.0, unit: 'cups' }); expect(res.status).toBe(201); expect(res.body.item.item_name).toBe('flour'); expect(res.body.item.quantity).toBe(2.0); expect(res.body.merged).toBe(false); }); it('merges quantities for same name and unit', async () => { await request(app) .post(`/v1/shopping-lists/${listId}/items`) .set('Authorization', `Bearer ${token}`) .send({ item_name: 'flour', quantity: 2.0, unit: 'cups' }); const res = await request(app) .post(`/v1/shopping-lists/${listId}/items`) .set('Authorization', `Bearer ${token}`) .send({ item_name: 'flour', quantity: 1.5, unit: 'cups' }); expect(res.status).toBe(201); expect(res.body.merged).toBe(true); expect(res.body.item.quantity).toBe(3.5); expect(res.body.previous_quantity).toBe(2.0); }); it('merges case-insensitively', async () => { await request(app) .post(`/v1/shopping-lists/${listId}/items`) .set('Authorization', `Bearer ${token}`) .send({ item_name: 'Flour', quantity: 2.0, unit: 'cups' }); const res = await request(app) .post(`/v1/shopping-lists/${listId}/items`) .set('Authorization', `Bearer ${token}`) .send({ item_name: 'FLOUR', quantity: 1.0, unit: 'cups' }); expect(res.body.merged).toBe(true); expect(res.body.item.quantity).toBe(3.0); }); it('creates separate items for same name but different unit', async () => { await request(app) .post(`/v1/shopping-lists/${listId}/items`) .set('Authorization', `Bearer ${token}`) .send({ item_name: 'flour', quantity: 2.0, unit: 'cups' }); const res = await request(app) .post(`/v1/shopping-lists/${listId}/items`) .set('Authorization', `Bearer ${token}`) .send({ item_name: 'flour', quantity: 100.0, unit: 'g' }); expect(res.body.merged).toBe(false); const listRes = await request(app) .get(`/v1/shopping-lists/${listId}`) .set('Authorization', `Bearer ${token}`); expect(listRes.body.shopping_list.items).toHaveLength(2); }); it('returns 400 for missing unit', async () => { const res = await request(app) .post(`/v1/shopping-lists/${listId}/items`) .set('Authorization', `Bearer ${token}`) .send({ item_name: 'flour', quantity: 2.0 }); expect(res.status).toBe(400); }); it('returns 400 for zero quantity', async () => { const res = await request(app) .post(`/v1/shopping-lists/${listId}/items`) .set('Authorization', `Bearer ${token}`) .send({ item_name: 'flour', quantity: 0, unit: 'cups' }); expect(res.status).toBe(400); }); it('returns 404 for non-existent list', async () => { const res = await request(app) .post('/v1/shopping-lists/00000000-0000-0000-0000-000000000000/items') .set('Authorization', `Bearer ${token}`) .send({ item_name: 'flour', quantity: 2.0, unit: 'cups' }); expect(res.status).toBe(404); }); }); // ── PUT /v1/shopping-lists/:list_id/items/:item_id ────────────────────────── describe('PUT /v1/shopping-lists/:list_id/items/:item_id', () => { let listId; let itemId; beforeEach(async () => { const listRes = await request(app) .post('/v1/shopping-lists') .set('Authorization', `Bearer ${token}`) .send({ list_name: 'Update Test' }); listId = listRes.body.shopping_list.id; const itemRes = await request(app) .post(`/v1/shopping-lists/${listId}/items`) .set('Authorization', `Bearer ${token}`) .send({ item_name: 'butter', quantity: 1.0, unit: 'cups' }); itemId = itemRes.body.item.id; }); it('updates checked_off status', async () => { const res = await request(app) .put(`/v1/shopping-lists/${listId}/items/${itemId}`) .set('Authorization', `Bearer ${token}`) .send({ checked_off: true }); expect(res.status).toBe(200); expect(res.body.item.checked_off).toBe(true); }); it('updates quantity', async () => { const res = await request(app) .put(`/v1/shopping-lists/${listId}/items/${itemId}`) .set('Authorization', `Bearer ${token}`) .send({ quantity: 3.5 }); expect(res.status).toBe(200); expect(res.body.item.quantity).toBe(3.5); }); it('returns 400 when no fields provided', async () => { const res = await request(app) .put(`/v1/shopping-lists/${listId}/items/${itemId}`) .set('Authorization', `Bearer ${token}`) .send({}); expect(res.status).toBe(400); }); it('returns 404 for non-existent item', async () => { const res = await request(app) .put(`/v1/shopping-lists/${listId}/items/00000000-0000-0000-0000-000000000000`) .set('Authorization', `Bearer ${token}`) .send({ checked_off: true }); expect(res.status).toBe(404); }); }); // ── DELETE /v1/shopping-lists/:list_id/items/:item_id ─────────────────────── describe('DELETE /v1/shopping-lists/:list_id/items/:item_id', () => { let listId; let itemId; beforeEach(async () => { const listRes = await request(app) .post('/v1/shopping-lists') .set('Authorization', `Bearer ${token}`) .send({ list_name: 'Delete Item Test' }); listId = listRes.body.shopping_list.id; const itemRes = await request(app) .post(`/v1/shopping-lists/${listId}/items`) .set('Authorization', `Bearer ${token}`) .send({ item_name: 'sugar', quantity: 1.0, unit: 'cups' }); itemId = itemRes.body.item.id; }); it('deletes the item', async () => { const res = await request(app) .delete(`/v1/shopping-lists/${listId}/items/${itemId}`) .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(204); const listRes = await request(app) .get(`/v1/shopping-lists/${listId}`) .set('Authorization', `Bearer ${token}`); expect(listRes.body.shopping_list.items).toHaveLength(0); }); it('records tombstone for sync', async () => { await request(app) .delete(`/v1/shopping-lists/${listId}/items/${itemId}`) .set('Authorization', `Bearer ${token}`); const tombstones = db.getTable('deleted_records'); expect(tombstones.some((t) => t.record_id === itemId)).toBe(true); }); it('returns 404 for non-existent item', async () => { const res = await request(app) .delete(`/v1/shopping-lists/${listId}/items/00000000-0000-0000-0000-000000000000`) .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(404); }); }); // ── POST /v1/shopping-lists/:list_id/add-recipes ──────────────────────────── describe('POST /v1/shopping-lists/:list_id/add-recipes', () => { let listId; beforeEach(async () => { const res = await request(app) .post('/v1/shopping-lists') .set('Authorization', `Bearer ${token}`) .send({ list_name: 'Recipe List' }); listId = res.body.shopping_list.id; }); it('adds recipe ingredients to the list', async () => { const res = await request(app) .post(`/v1/shopping-lists/${listId}/add-recipes`) .set('Authorization', `Bearer ${token}`) .send({ recipe_ids: [RECIPE_ID], scale: 1 }); expect(res.status).toBe(201); expect(res.body.recipes_added).toBe(1); expect(res.body.items_created).toBe(2); // flour + milk expect(res.body.shopping_list.items).toHaveLength(2); }); it('merges quantities when adding same recipe twice', async () => { await request(app) .post(`/v1/shopping-lists/${listId}/add-recipes`) .set('Authorization', `Bearer ${token}`) .send({ recipe_ids: [RECIPE_ID], scale: 1 }); const res = await request(app) .post(`/v1/shopping-lists/${listId}/add-recipes`) .set('Authorization', `Bearer ${token}`) .send({ recipe_ids: [RECIPE_ID], scale: 1 }); expect(res.status).toBe(201); expect(res.body.items_merged).toBe(2); expect(res.body.items_created).toBe(0); const flour = res.body.shopping_list.items.find((i) => i.item_name === 'flour'); expect(flour.quantity).toBe(4.0); // 2 + 2 }); it('scales ingredient quantities', async () => { const res = await request(app) .post(`/v1/shopping-lists/${listId}/add-recipes`) .set('Authorization', `Bearer ${token}`) .send({ recipe_ids: [RECIPE_ID], scale: 2 }); expect(res.status).toBe(201); const flour = res.body.shopping_list.items.find((i) => i.item_name === 'flour'); expect(flour.quantity).toBe(4.0); // 2 * 2 }); it('returns 400 for empty recipe_ids', async () => { const res = await request(app) .post(`/v1/shopping-lists/${listId}/add-recipes`) .set('Authorization', `Bearer ${token}`) .send({ recipe_ids: [] }); expect(res.status).toBe(400); }); it('returns 400 for invalid scale', async () => { const res = await request(app) .post(`/v1/shopping-lists/${listId}/add-recipes`) .set('Authorization', `Bearer ${token}`) .send({ recipe_ids: [RECIPE_ID], scale: 5 }); expect(res.status).toBe(400); }); it('returns 404 for non-existent recipe', async () => { const res = await request(app) .post(`/v1/shopping-lists/${listId}/add-recipes`) .set('Authorization', `Bearer ${token}`) .send({ recipe_ids: ['00000000-0000-0000-0000-999999999999'] }); expect(res.status).toBe(404); }); it('returns 404 for non-existent list', async () => { const res = await request(app) .post('/v1/shopping-lists/00000000-0000-0000-0000-000000000000/add-recipes') .set('Authorization', `Bearer ${token}`) .send({ recipe_ids: [RECIPE_ID] }); expect(res.status).toBe(404); }); }); });