diff --git a/backend/tests/available-item.model.test.js b/backend/tests/available-item.model.test.js index 50d0923..3cbb865 100644 --- a/backend/tests/available-item.model.test.js +++ b/backend/tests/available-item.model.test.js @@ -2,12 +2,20 @@ jest.mock("../db/pool", () => ({ query: jest.fn(), })); +jest.mock("../models/list.model.v2", () => ({ + recordItemEvent: jest.fn(), + setCatalogItemImage: jest.fn(), +})); + const pool = require("../db/pool"); +const List = require("../models/list.model.v2"); const AvailableItems = require("../models/available-item.model"); describe("available-item.model", () => { beforeEach(() => { pool.query.mockReset(); + List.recordItemEvent.mockReset(); + List.setCatalogItemImage.mockReset(); }); test("lists household store items", async () => { @@ -58,6 +66,14 @@ describe("available-item.model", () => { expect.stringContaining("INSERT INTO household_store_items"), [1, 2, "granola", "granola"] ); + expect(List.recordItemEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: "ITEM_ADDED", + householdId: 1, + storeLocationId: 2, + householdStoreItemId: 77, + }) + ); }); test("updates household store item images and returns refreshed data", async () => { @@ -78,19 +94,37 @@ describe("available-item.model", () => { expect(pool.query).toHaveBeenNthCalledWith( 1, expect.stringContaining("UPDATE household_store_items"), - [1, 2, 55, imageBuffer, "image/jpeg"] + [1, 2, 55] + ); + expect(List.setCatalogItemImage).toHaveBeenCalledWith( + 1, + 2, + 55, + imageBuffer, + "image/jpeg", + null ); }); test("deletes the household store item", async () => { - pool.query.mockResolvedValueOnce({ rowCount: 1, rows: [] }); + pool.query + .mockResolvedValueOnce({ rowCount: 1, rows: [{ item_id: 55, item_name: "milk" }] }) + .mockResolvedValueOnce({ rowCount: 1, rows: [] }); const deleted = await AvailableItems.deleteAvailableItem(1, 2, 55); expect(deleted).toBe(true); - expect(pool.query).toHaveBeenCalledWith( + expect(pool.query).toHaveBeenLastCalledWith( expect.stringContaining("DELETE FROM household_store_items"), [1, 2, 55] ); + expect(List.recordItemEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: "ITEM_DELETED", + householdId: 1, + storeLocationId: 2, + householdStoreItemId: 55, + }) + ); }); }); diff --git a/backend/tests/available-items.controller.test.js b/backend/tests/available-items.controller.test.js index 7d7ce38..26e6c9e 100644 --- a/backend/tests/available-items.controller.test.js +++ b/backend/tests/available-items.controller.test.js @@ -9,6 +9,8 @@ jest.mock("../models/available-item.model", () => ({ jest.mock("../models/list.model.v2", () => ({ deleteClassification: jest.fn(), + getZoneByName: jest.fn(), + recordItemEvent: jest.fn(), upsertClassification: jest.fn(), })); @@ -42,7 +44,9 @@ describe("available-items.controller", () => { AvailableItems.deleteAvailableItem.mockResolvedValue(true); AvailableItems.importCurrentListItems.mockResolvedValue(2); AvailableItems.listAvailableItems.mockResolvedValue([]); - List.upsertClassification.mockResolvedValue(undefined); + List.getZoneByName.mockResolvedValue({ id: 5, name: "Dairy & Refrigerated" }); + List.recordItemEvent.mockResolvedValue(undefined); + List.upsertClassification.mockResolvedValue({ zone_id: 5 }); List.deleteClassification.mockResolvedValue(false); }); @@ -58,12 +62,13 @@ describe("available-items.controller", () => { }), }, processedImage: null, + user: { id: 7 }, }; const res = createResponse(); await controller.createAvailableItem(req, res); - expect(AvailableItems.createAvailableItem).toHaveBeenCalledWith("1", "2", "milk", null, null); + expect(AvailableItems.createAvailableItem).toHaveBeenCalledWith("1", "2", "milk", null, null, 7); expect(List.upsertClassification).toHaveBeenCalledWith( "1", "2", @@ -87,6 +92,7 @@ describe("available-items.controller", () => { item_group: "Bread", }), }, + user: { id: 7 }, }; const res = createResponse(); @@ -110,6 +116,7 @@ describe("available-items.controller", () => { classification: "null", }, processedImage: null, + user: { id: 7 }, }; const res = createResponse(); @@ -122,6 +129,7 @@ describe("available-items.controller", () => { test("imports current list items and reports the import count", async () => { const req = { params: { householdId: "1", storeId: "2" }, + user: { id: 7 }, }; const res = createResponse(); @@ -138,12 +146,13 @@ describe("available-items.controller", () => { test("deletes a store item", async () => { const req = { params: { householdId: "1", storeId: "2", itemId: "99" }, + user: { id: 7 }, }; const res = createResponse(); await controller.deleteAvailableItem(req, res); - expect(AvailableItems.deleteAvailableItem).toHaveBeenCalledWith("1", "2", 99); + expect(AvailableItems.deleteAvailableItem).toHaveBeenCalledWith("1", "2", 99, 7); expect(List.deleteClassification).not.toHaveBeenCalled(); expect(res.json).toHaveBeenCalledWith({ message: "Store item deleted" }); }); @@ -152,6 +161,7 @@ describe("available-items.controller", () => { const req = { params: { householdId: "1", storeId: "2" }, query: {}, + user: { id: 7 }, }; const res = createResponse(); @@ -177,6 +187,7 @@ describe("available-items.controller", () => { item_name: "milk", }, processedImage: null, + user: { id: 7 }, }; const res = createResponse(); diff --git a/backend/tests/available-items.routes.test.js b/backend/tests/available-items.routes.test.js index 05d5784..5a18381 100644 --- a/backend/tests/available-items.routes.test.js +++ b/backend/tests/available-items.routes.test.js @@ -11,6 +11,10 @@ jest.mock("../middleware/household", () => ({ }; next(); }, + locationAccess: (req, res, next) => { + req.storeLocation = { id: Number.parseInt(req.params.locationId, 10) }; + next(); + }, requireHouseholdAdmin: (req, res, next) => { if (["owner", "admin"].includes(req.household?.role)) { return next(); @@ -65,6 +69,21 @@ jest.mock("../controllers/available-items.controller", () => ({ updateAvailableItem: jest.fn((req, res) => res.json({ message: "updated" })), })); +jest.mock("../controllers/stores.controller", () => ({ + addLocationToStore: jest.fn((req, res) => res.status(201).json({ message: "location" })), + createHouseholdStore: jest.fn((req, res) => res.status(201).json({ message: "store" })), + createZone: jest.fn((req, res) => res.status(201).json({ message: "zone" })), + deleteHouseholdStore: jest.fn((req, res) => res.json({ message: "deleted store" })), + deleteLocation: jest.fn((req, res) => res.json({ message: "deleted location" })), + deleteZone: jest.fn((req, res) => res.json({ message: "deleted zone" })), + getHouseholdStores: jest.fn((req, res) => res.json([])), + getLocationZones: jest.fn((req, res) => res.json({ zones: [] })), + setDefaultLocation: jest.fn((req, res) => res.json({ message: "default" })), + updateHouseholdStore: jest.fn((req, res) => res.json({ message: "updated store" })), + updateLocation: jest.fn((req, res) => res.json({ message: "updated location" })), + updateZone: jest.fn((req, res) => res.json({ message: "updated zone" })), +})); + const express = require("express"); const request = require("supertest"); const router = require("../routes/households.routes"); @@ -106,4 +125,23 @@ describe("available-items routes", () => { expect(response.status).toBe(201); expect(availableItemsController.createAvailableItem).toHaveBeenCalled(); }); + + test("members can create available items on location-scoped routes", async () => { + const response = await request(app) + .post("/households/1/locations/2/available-items") + .set("x-household-role", "member") + .send({ item_name: "milk" }); + + expect(response.status).toBe(201); + expect(availableItemsController.createAvailableItem).toHaveBeenCalled(); + }); + + test("members cannot delete available items on location-scoped routes", async () => { + const response = await request(app) + .delete("/households/1/locations/2/available-items/3") + .set("x-household-role", "member"); + + expect(response.status).toBe(403); + expect(availableItemsController.deleteAvailableItem).not.toHaveBeenCalled(); + }); }); diff --git a/backend/tests/list.model.v2.test.js b/backend/tests/list.model.v2.test.js index 59e4469..a928d70 100644 --- a/backend/tests/list.model.v2.test.js +++ b/backend/tests/list.model.v2.test.js @@ -24,6 +24,10 @@ describe("list.model.v2 addOrUpdateItem", () => { itemId: 55, householdStoreItemId: 55, itemName: "milk", + quantity: 3, + previousQuantity: 0, + historyQuantity: 3, + wasBought: false, isNew: true, }); expect(pool.query).toHaveBeenNthCalledWith( @@ -41,7 +45,7 @@ describe("list.model.v2 addOrUpdateItem", () => { test("returns household store item metadata when updating an existing list item", async () => { pool.query .mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55, name: "milk" }] }) - .mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 88, bought: false }] }) + .mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 88, bought: false, quantity: 2 }] }) .mockResolvedValueOnce({ rowCount: 1, rows: [] }); const result = await List.addOrUpdateItem(1, 2, "Milk", 4, 7); @@ -51,14 +55,48 @@ describe("list.model.v2 addOrUpdateItem", () => { itemId: 55, householdStoreItemId: 55, itemName: "milk", + quantity: 4, + previousQuantity: 2, + historyQuantity: 2, + wasBought: false, isNew: false, }); expect(pool.query).toHaveBeenNthCalledWith( 3, expect.stringContaining("UPDATE household_lists"), - [4, 88] + [4, undefined, 88] ); }); + + test("uses the full requested quantity when reopening a bought list item", async () => { + pool.query + .mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55, name: "milk" }] }) + .mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 88, bought: true, quantity: 2 }] }) + .mockResolvedValueOnce({ rowCount: 1, rows: [] }); + + const result = await List.addOrUpdateItem(1, 2, "Milk", 4, 7); + + expect(result).toEqual( + expect.objectContaining({ + listId: 88, + quantity: 4, + previousQuantity: 2, + historyQuantity: 4, + wasBought: true, + isNew: false, + }) + ); + }); + + test("limits added_by_users to history entries that account for current quantity", async () => { + pool.query.mockResolvedValueOnce({ rowCount: 0, rows: [] }); + + await List.getHouseholdStoreList(1, 2); + + const sql = pool.query.mock.calls[0][0]; + expect(sql).toContain("ORDER BY hlh.added_on DESC, hlh.id DESC"); + expect(sql).toContain("active_history.newer_quantity < GREATEST(hl.quantity, 0)"); + }); }); describe("list.model.v2 classification helpers", () => { @@ -66,7 +104,7 @@ describe("list.model.v2 classification helpers", () => { pool.query.mockReset(); }); - test("gets classification using household, store, and household-store item ids", async () => { + test("gets classification using household, location, and household-store item ids", async () => { pool.query.mockResolvedValueOnce({ rowCount: 1, rows: [ @@ -95,17 +133,23 @@ describe("list.model.v2 classification helpers", () => { ); }); - test("upserts classification using household-store item conflict target", async () => { - pool.query.mockResolvedValueOnce({ + test("upserts classification using household-location item conflict target", async () => { + pool.query + .mockResolvedValueOnce({ + rowCount: 1, + rows: [{ id: 12, name: "Dairy & Refrigerated", sort_order: 60 }], + }) + .mockResolvedValueOnce({ rowCount: 1, rows: [ { household_id: 1, - store_id: 2, + store_location_id: 2, household_store_item_id: 55, item_type: "dairy", item_group: "Milk", zone: "Dairy & Refrigerated", + zone_id: 12, confidence: 1, source: "user", }, @@ -123,14 +167,14 @@ describe("list.model.v2 classification helpers", () => { expect(result).toEqual( expect.objectContaining({ household_id: 1, - store_id: 2, + store_location_id: 2, household_store_item_id: 55, item_type: "dairy", }) ); - expect(pool.query).toHaveBeenCalledWith( - expect.stringContaining("ON CONFLICT (household_id, store_id, household_store_item_id)"), - [1, 2, 55, "dairy", "Milk", "Dairy & Refrigerated", 1, "user"] + expect(pool.query).toHaveBeenLastCalledWith( + expect.stringContaining("ON CONFLICT (household_id, store_location_id, household_store_item_id)"), + [1, 2, 55, "dairy", "Milk", "Dairy & Refrigerated", 12, 1, "user"] ); }); }); diff --git a/backend/tests/lists.controller.v2.test.js b/backend/tests/lists.controller.v2.test.js index 0fcdb03..b84ee05 100644 --- a/backend/tests/lists.controller.v2.test.js +++ b/backend/tests/lists.controller.v2.test.js @@ -3,6 +3,8 @@ jest.mock("../models/list.model.v2", () => ({ addOrUpdateItem: jest.fn(), ensureHouseholdStoreItem: jest.fn(), getItemByName: jest.fn(), + getZoneByName: jest.fn(), + recordItemEvent: jest.fn(), upsertClassification: jest.fn(), })); @@ -37,7 +39,9 @@ describe("lists.controller.v2 addItem", () => { }); List.addHistoryRecord.mockResolvedValue(undefined); List.getItemByName.mockResolvedValue({ id: 42, item_id: 99, item_name: "milk" }); - List.upsertClassification.mockResolvedValue(undefined); + List.getZoneByName.mockResolvedValue({ id: 5, name: "Dairy & Refrigerated" }); + List.recordItemEvent.mockResolvedValue(undefined); + List.upsertClassification.mockResolvedValue({ zone_id: 5 }); householdModel.isHouseholdMember.mockResolvedValue(true); }); @@ -54,7 +58,15 @@ describe("lists.controller.v2 addItem", () => { expect(householdModel.isHouseholdMember).toHaveBeenCalledWith("1", 9); expect(List.addOrUpdateItem).toHaveBeenCalled(); - expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, "1", 9); + expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, "1", 9, "2"); + expect(List.recordItemEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: "ITEM_ADDED", + householdId: "1", + storeLocationId: "2", + householdStoreItemId: 99, + }) + ); expect(res.status).not.toHaveBeenCalledWith(400); }); @@ -71,10 +83,42 @@ describe("lists.controller.v2 addItem", () => { expect(householdModel.isHouseholdMember).not.toHaveBeenCalled(); expect(List.addOrUpdateItem).toHaveBeenCalled(); - expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, "1", 7); + expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, "1", 7, "2"); expect(res.status).not.toHaveBeenCalledWith(400); }); + test("records duplicate-add history with the added quantity instead of the new total", async () => { + List.addOrUpdateItem.mockResolvedValueOnce({ + listId: 42, + itemId: 99, + householdStoreItemId: 99, + itemName: "milk", + quantity: 3, + previousQuantity: 1, + historyQuantity: 2, + isNew: false, + }); + + const req = { + params: { householdId: "1", storeId: "2" }, + body: { item_name: "milk", quantity: "3" }, + user: { id: 7 }, + processedImage: null, + }; + const res = createResponse(); + + await controller.addItem(req, res); + + expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, 2, 7, "2"); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + item: expect.objectContaining({ + quantity: 3, + }), + }) + ); + }); + test("records history using request user when added_for_user_id is blank", async () => { const req = { params: { householdId: "1", storeId: "2" }, @@ -88,7 +132,7 @@ describe("lists.controller.v2 addItem", () => { expect(householdModel.isHouseholdMember).not.toHaveBeenCalled(); expect(List.addOrUpdateItem).toHaveBeenCalled(); - expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, "1", 7); + expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, "1", 7, "2"); expect(res.status).not.toHaveBeenCalledWith(400); }); @@ -169,7 +213,9 @@ describe("lists.controller.v2 setClassification", () => { beforeEach(() => { jest.clearAllMocks(); List.getItemByName.mockResolvedValue({ id: 42, item_id: 99, item_name: "milk" }); - List.upsertClassification.mockResolvedValue(undefined); + List.upsertClassification.mockResolvedValue({ zone_id: 5 }); + List.recordItemEvent.mockResolvedValue(undefined); + List.getZoneByName.mockResolvedValue({ id: 5, name: "Dairy & Refrigerated" }); List.ensureHouseholdStoreItem.mockResolvedValue({ id: 99, name: "milk" }); }); @@ -216,6 +262,7 @@ describe("lists.controller.v2 setClassification", () => { }); test("accepts zone-only classification updates", async () => { + List.getZoneByName.mockResolvedValueOnce({ id: 6, name: "Checkout Area" }); const req = { params: { householdId: "1", storeId: "2" }, body: { @@ -297,6 +344,7 @@ describe("lists.controller.v2 setClassification", () => { }); test("rejects invalid zone", async () => { + List.getZoneByName.mockResolvedValueOnce(null); const req = { params: { householdId: "1", storeId: "2" }, body: { @@ -350,6 +398,7 @@ describe("lists.controller.v2 setClassification", () => { test("creates a household store item when classification target is not yet on the list", async () => { List.getItemByName.mockResolvedValueOnce(null); + List.getZoneByName.mockResolvedValueOnce({ id: 7, name: "Snacks & Candy" }); const req = { params: { householdId: "1", storeId: "2" }, diff --git a/backend/tests/store-locations.routes.test.js b/backend/tests/store-locations.routes.test.js new file mode 100644 index 0000000..db958b2 --- /dev/null +++ b/backend/tests/store-locations.routes.test.js @@ -0,0 +1,153 @@ +jest.mock("../middleware/auth", () => (req, res, next) => { + req.user = { id: 42, role: "user" }; + next(); +}); + +jest.mock("../middleware/household", () => ({ + householdAccess: (req, res, next) => { + req.household = { + id: Number.parseInt(req.params.householdId, 10), + role: req.headers["x-household-role"] || "member", + }; + next(); + }, + locationAccess: (req, res, next) => { + req.storeLocation = { id: Number.parseInt(req.params.locationId, 10) }; + next(); + }, + requireHouseholdAdmin: (req, res, next) => { + if (["owner", "admin"].includes(req.household?.role)) { + return next(); + } + return res.status(403).json({ + error: { code: "FORBIDDEN", message: "Admin role required" }, + request_id: req.request_id, + }); + }, + storeAccess: (req, res, next) => next(), +})); + +jest.mock("../middleware/image", () => ({ + upload: { + single: () => (req, res, next) => next(), + }, + processImage: (req, res, next) => next(), +})); + +jest.mock("../controllers/households.controller", () => ({ + createHousehold: jest.fn(), + deleteHousehold: jest.fn(), + getHousehold: jest.fn(), + getMembers: jest.fn(), + getUserHouseholds: jest.fn(), + joinHousehold: jest.fn(), + refreshInviteCode: jest.fn(), + removeMember: jest.fn(), + updateHousehold: jest.fn(), + updateMemberRole: jest.fn(), +})); + +jest.mock("../controllers/lists.controller.v2", () => ({ + addItem: jest.fn(), + deleteItem: jest.fn(), + getClassification: jest.fn(), + getItemByName: jest.fn(), + getList: jest.fn(), + getRecentlyBought: jest.fn(), + getSuggestions: jest.fn(), + markBought: jest.fn(), + setClassification: jest.fn(), + updateItem: jest.fn(), + updateItemImage: jest.fn(), +})); + +jest.mock("../controllers/available-items.controller", () => ({ + createAvailableItem: jest.fn(), + deleteAvailableItem: jest.fn(), + getAvailableItems: jest.fn(), + importCurrentItems: jest.fn(), + updateAvailableItem: jest.fn(), +})); + +jest.mock("../controllers/stores.controller", () => ({ + addLocationToStore: jest.fn((req, res) => res.status(201).json({ message: "location" })), + createHouseholdStore: jest.fn((req, res) => res.status(201).json({ message: "store" })), + createZone: jest.fn((req, res) => res.status(201).json({ message: "zone" })), + deleteHouseholdStore: jest.fn((req, res) => res.json({ message: "deleted store" })), + deleteLocation: jest.fn((req, res) => res.json({ message: "deleted location" })), + deleteZone: jest.fn((req, res) => res.json({ message: "deleted zone" })), + getHouseholdStores: jest.fn((req, res) => res.json([{ id: 2, name: "Costco" }])), + getLocationZones: jest.fn((req, res) => res.json({ zones: [] })), + setDefaultLocation: jest.fn((req, res) => res.json({ message: "default" })), + updateHouseholdStore: jest.fn((req, res) => res.json({ message: "updated store" })), + updateLocation: jest.fn((req, res) => res.json({ message: "updated location" })), + updateZone: jest.fn((req, res) => res.json({ message: "updated zone" })), +})); + +const express = require("express"); +const request = require("supertest"); +const router = require("../routes/households.routes"); +const storesController = require("../controllers/stores.controller"); + +describe("store location routes", () => { + let app; + + beforeEach(() => { + app = express(); + app.use(express.json()); + app.use("/households", router); + jest.clearAllMocks(); + }); + + test("members can list household store locations", async () => { + const response = await request(app).get("/households/1/stores"); + + expect(response.status).toBe(200); + expect(storesController.getHouseholdStores).toHaveBeenCalled(); + }); + + test("members cannot create household stores", async () => { + const response = await request(app) + .post("/households/1/stores") + .set("x-household-role", "member") + .send({ name: "Costco" }); + + expect(response.status).toBe(403); + expect(storesController.createHouseholdStore).not.toHaveBeenCalled(); + }); + + test("admins can create household stores", async () => { + const response = await request(app) + .post("/households/1/stores") + .set("x-household-role", "admin") + .send({ name: "Costco", location_name: "Fontana" }); + + expect(response.status).toBe(201); + expect(storesController.createHouseholdStore).toHaveBeenCalled(); + }); + + test("members can list zones but cannot create zones", async () => { + const listResponse = await request(app) + .get("/households/1/locations/2/zones") + .set("x-household-role", "member"); + const createResponse = await request(app) + .post("/households/1/locations/2/zones") + .set("x-household-role", "member") + .send({ name: "Produce", sort_order: 10 }); + + expect(listResponse.status).toBe(200); + expect(createResponse.status).toBe(403); + expect(storesController.getLocationZones).toHaveBeenCalled(); + expect(storesController.createZone).not.toHaveBeenCalled(); + }); + + test("admins can update zone order", async () => { + const response = await request(app) + .patch("/households/1/locations/2/zones/9") + .set("x-household-role", "admin") + .send({ sort_order: 20 }); + + expect(response.status).toBe(200); + expect(storesController.updateZone).toHaveBeenCalled(); + }); +});