feature-custom-store-locations #4
@ -2,12 +2,20 @@ jest.mock("../db/pool", () => ({
|
|||||||
query: jest.fn(),
|
query: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
jest.mock("../models/list.model.v2", () => ({
|
||||||
|
recordItemEvent: jest.fn(),
|
||||||
|
setCatalogItemImage: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
const pool = require("../db/pool");
|
const pool = require("../db/pool");
|
||||||
|
const List = require("../models/list.model.v2");
|
||||||
const AvailableItems = require("../models/available-item.model");
|
const AvailableItems = require("../models/available-item.model");
|
||||||
|
|
||||||
describe("available-item.model", () => {
|
describe("available-item.model", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
pool.query.mockReset();
|
pool.query.mockReset();
|
||||||
|
List.recordItemEvent.mockReset();
|
||||||
|
List.setCatalogItemImage.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("lists household store items", async () => {
|
test("lists household store items", async () => {
|
||||||
@ -58,6 +66,14 @@ describe("available-item.model", () => {
|
|||||||
expect.stringContaining("INSERT INTO household_store_items"),
|
expect.stringContaining("INSERT INTO household_store_items"),
|
||||||
[1, 2, "granola", "granola"]
|
[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 () => {
|
test("updates household store item images and returns refreshed data", async () => {
|
||||||
@ -78,19 +94,37 @@ describe("available-item.model", () => {
|
|||||||
expect(pool.query).toHaveBeenNthCalledWith(
|
expect(pool.query).toHaveBeenNthCalledWith(
|
||||||
1,
|
1,
|
||||||
expect.stringContaining("UPDATE household_store_items"),
|
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 () => {
|
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);
|
const deleted = await AvailableItems.deleteAvailableItem(1, 2, 55);
|
||||||
|
|
||||||
expect(deleted).toBe(true);
|
expect(deleted).toBe(true);
|
||||||
expect(pool.query).toHaveBeenCalledWith(
|
expect(pool.query).toHaveBeenLastCalledWith(
|
||||||
expect.stringContaining("DELETE FROM household_store_items"),
|
expect.stringContaining("DELETE FROM household_store_items"),
|
||||||
[1, 2, 55]
|
[1, 2, 55]
|
||||||
);
|
);
|
||||||
|
expect(List.recordItemEvent).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
eventType: "ITEM_DELETED",
|
||||||
|
householdId: 1,
|
||||||
|
storeLocationId: 2,
|
||||||
|
householdStoreItemId: 55,
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -9,6 +9,8 @@ jest.mock("../models/available-item.model", () => ({
|
|||||||
|
|
||||||
jest.mock("../models/list.model.v2", () => ({
|
jest.mock("../models/list.model.v2", () => ({
|
||||||
deleteClassification: jest.fn(),
|
deleteClassification: jest.fn(),
|
||||||
|
getZoneByName: jest.fn(),
|
||||||
|
recordItemEvent: jest.fn(),
|
||||||
upsertClassification: jest.fn(),
|
upsertClassification: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -42,7 +44,9 @@ describe("available-items.controller", () => {
|
|||||||
AvailableItems.deleteAvailableItem.mockResolvedValue(true);
|
AvailableItems.deleteAvailableItem.mockResolvedValue(true);
|
||||||
AvailableItems.importCurrentListItems.mockResolvedValue(2);
|
AvailableItems.importCurrentListItems.mockResolvedValue(2);
|
||||||
AvailableItems.listAvailableItems.mockResolvedValue([]);
|
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);
|
List.deleteClassification.mockResolvedValue(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -58,12 +62,13 @@ describe("available-items.controller", () => {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
processedImage: null,
|
processedImage: null,
|
||||||
|
user: { id: 7 },
|
||||||
};
|
};
|
||||||
const res = createResponse();
|
const res = createResponse();
|
||||||
|
|
||||||
await controller.createAvailableItem(req, res);
|
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(
|
expect(List.upsertClassification).toHaveBeenCalledWith(
|
||||||
"1",
|
"1",
|
||||||
"2",
|
"2",
|
||||||
@ -87,6 +92,7 @@ describe("available-items.controller", () => {
|
|||||||
item_group: "Bread",
|
item_group: "Bread",
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
user: { id: 7 },
|
||||||
};
|
};
|
||||||
const res = createResponse();
|
const res = createResponse();
|
||||||
|
|
||||||
@ -110,6 +116,7 @@ describe("available-items.controller", () => {
|
|||||||
classification: "null",
|
classification: "null",
|
||||||
},
|
},
|
||||||
processedImage: null,
|
processedImage: null,
|
||||||
|
user: { id: 7 },
|
||||||
};
|
};
|
||||||
const res = createResponse();
|
const res = createResponse();
|
||||||
|
|
||||||
@ -122,6 +129,7 @@ describe("available-items.controller", () => {
|
|||||||
test("imports current list items and reports the import count", async () => {
|
test("imports current list items and reports the import count", async () => {
|
||||||
const req = {
|
const req = {
|
||||||
params: { householdId: "1", storeId: "2" },
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
user: { id: 7 },
|
||||||
};
|
};
|
||||||
const res = createResponse();
|
const res = createResponse();
|
||||||
|
|
||||||
@ -138,12 +146,13 @@ describe("available-items.controller", () => {
|
|||||||
test("deletes a store item", async () => {
|
test("deletes a store item", async () => {
|
||||||
const req = {
|
const req = {
|
||||||
params: { householdId: "1", storeId: "2", itemId: "99" },
|
params: { householdId: "1", storeId: "2", itemId: "99" },
|
||||||
|
user: { id: 7 },
|
||||||
};
|
};
|
||||||
const res = createResponse();
|
const res = createResponse();
|
||||||
|
|
||||||
await controller.deleteAvailableItem(req, res);
|
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(List.deleteClassification).not.toHaveBeenCalled();
|
||||||
expect(res.json).toHaveBeenCalledWith({ message: "Store item deleted" });
|
expect(res.json).toHaveBeenCalledWith({ message: "Store item deleted" });
|
||||||
});
|
});
|
||||||
@ -152,6 +161,7 @@ describe("available-items.controller", () => {
|
|||||||
const req = {
|
const req = {
|
||||||
params: { householdId: "1", storeId: "2" },
|
params: { householdId: "1", storeId: "2" },
|
||||||
query: {},
|
query: {},
|
||||||
|
user: { id: 7 },
|
||||||
};
|
};
|
||||||
const res = createResponse();
|
const res = createResponse();
|
||||||
|
|
||||||
@ -177,6 +187,7 @@ describe("available-items.controller", () => {
|
|||||||
item_name: "milk",
|
item_name: "milk",
|
||||||
},
|
},
|
||||||
processedImage: null,
|
processedImage: null,
|
||||||
|
user: { id: 7 },
|
||||||
};
|
};
|
||||||
const res = createResponse();
|
const res = createResponse();
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,10 @@ jest.mock("../middleware/household", () => ({
|
|||||||
};
|
};
|
||||||
next();
|
next();
|
||||||
},
|
},
|
||||||
|
locationAccess: (req, res, next) => {
|
||||||
|
req.storeLocation = { id: Number.parseInt(req.params.locationId, 10) };
|
||||||
|
next();
|
||||||
|
},
|
||||||
requireHouseholdAdmin: (req, res, next) => {
|
requireHouseholdAdmin: (req, res, next) => {
|
||||||
if (["owner", "admin"].includes(req.household?.role)) {
|
if (["owner", "admin"].includes(req.household?.role)) {
|
||||||
return next();
|
return next();
|
||||||
@ -65,6 +69,21 @@ jest.mock("../controllers/available-items.controller", () => ({
|
|||||||
updateAvailableItem: jest.fn((req, res) => res.json({ message: "updated" })),
|
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 express = require("express");
|
||||||
const request = require("supertest");
|
const request = require("supertest");
|
||||||
const router = require("../routes/households.routes");
|
const router = require("../routes/households.routes");
|
||||||
@ -106,4 +125,23 @@ describe("available-items routes", () => {
|
|||||||
expect(response.status).toBe(201);
|
expect(response.status).toBe(201);
|
||||||
expect(availableItemsController.createAvailableItem).toHaveBeenCalled();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -24,6 +24,10 @@ describe("list.model.v2 addOrUpdateItem", () => {
|
|||||||
itemId: 55,
|
itemId: 55,
|
||||||
householdStoreItemId: 55,
|
householdStoreItemId: 55,
|
||||||
itemName: "milk",
|
itemName: "milk",
|
||||||
|
quantity: 3,
|
||||||
|
previousQuantity: 0,
|
||||||
|
historyQuantity: 3,
|
||||||
|
wasBought: false,
|
||||||
isNew: true,
|
isNew: true,
|
||||||
});
|
});
|
||||||
expect(pool.query).toHaveBeenNthCalledWith(
|
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 () => {
|
test("returns household store item metadata when updating an existing list item", async () => {
|
||||||
pool.query
|
pool.query
|
||||||
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55, name: "milk" }] })
|
.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: [] });
|
.mockResolvedValueOnce({ rowCount: 1, rows: [] });
|
||||||
|
|
||||||
const result = await List.addOrUpdateItem(1, 2, "Milk", 4, 7);
|
const result = await List.addOrUpdateItem(1, 2, "Milk", 4, 7);
|
||||||
@ -51,14 +55,48 @@ describe("list.model.v2 addOrUpdateItem", () => {
|
|||||||
itemId: 55,
|
itemId: 55,
|
||||||
householdStoreItemId: 55,
|
householdStoreItemId: 55,
|
||||||
itemName: "milk",
|
itemName: "milk",
|
||||||
|
quantity: 4,
|
||||||
|
previousQuantity: 2,
|
||||||
|
historyQuantity: 2,
|
||||||
|
wasBought: false,
|
||||||
isNew: false,
|
isNew: false,
|
||||||
});
|
});
|
||||||
expect(pool.query).toHaveBeenNthCalledWith(
|
expect(pool.query).toHaveBeenNthCalledWith(
|
||||||
3,
|
3,
|
||||||
expect.stringContaining("UPDATE household_lists"),
|
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", () => {
|
describe("list.model.v2 classification helpers", () => {
|
||||||
@ -66,7 +104,7 @@ describe("list.model.v2 classification helpers", () => {
|
|||||||
pool.query.mockReset();
|
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({
|
pool.query.mockResolvedValueOnce({
|
||||||
rowCount: 1,
|
rowCount: 1,
|
||||||
rows: [
|
rows: [
|
||||||
@ -95,17 +133,23 @@ describe("list.model.v2 classification helpers", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("upserts classification using household-store item conflict target", async () => {
|
test("upserts classification using household-location item conflict target", async () => {
|
||||||
pool.query.mockResolvedValueOnce({
|
pool.query
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [{ id: 12, name: "Dairy & Refrigerated", sort_order: 60 }],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
rowCount: 1,
|
rowCount: 1,
|
||||||
rows: [
|
rows: [
|
||||||
{
|
{
|
||||||
household_id: 1,
|
household_id: 1,
|
||||||
store_id: 2,
|
store_location_id: 2,
|
||||||
household_store_item_id: 55,
|
household_store_item_id: 55,
|
||||||
item_type: "dairy",
|
item_type: "dairy",
|
||||||
item_group: "Milk",
|
item_group: "Milk",
|
||||||
zone: "Dairy & Refrigerated",
|
zone: "Dairy & Refrigerated",
|
||||||
|
zone_id: 12,
|
||||||
confidence: 1,
|
confidence: 1,
|
||||||
source: "user",
|
source: "user",
|
||||||
},
|
},
|
||||||
@ -123,14 +167,14 @@ describe("list.model.v2 classification helpers", () => {
|
|||||||
expect(result).toEqual(
|
expect(result).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
household_id: 1,
|
household_id: 1,
|
||||||
store_id: 2,
|
store_location_id: 2,
|
||||||
household_store_item_id: 55,
|
household_store_item_id: 55,
|
||||||
item_type: "dairy",
|
item_type: "dairy",
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(pool.query).toHaveBeenCalledWith(
|
expect(pool.query).toHaveBeenLastCalledWith(
|
||||||
expect.stringContaining("ON CONFLICT (household_id, store_id, household_store_item_id)"),
|
expect.stringContaining("ON CONFLICT (household_id, store_location_id, household_store_item_id)"),
|
||||||
[1, 2, 55, "dairy", "Milk", "Dairy & Refrigerated", 1, "user"]
|
[1, 2, 55, "dairy", "Milk", "Dairy & Refrigerated", 12, 1, "user"]
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,6 +3,8 @@ jest.mock("../models/list.model.v2", () => ({
|
|||||||
addOrUpdateItem: jest.fn(),
|
addOrUpdateItem: jest.fn(),
|
||||||
ensureHouseholdStoreItem: jest.fn(),
|
ensureHouseholdStoreItem: jest.fn(),
|
||||||
getItemByName: jest.fn(),
|
getItemByName: jest.fn(),
|
||||||
|
getZoneByName: jest.fn(),
|
||||||
|
recordItemEvent: jest.fn(),
|
||||||
upsertClassification: jest.fn(),
|
upsertClassification: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -37,7 +39,9 @@ describe("lists.controller.v2 addItem", () => {
|
|||||||
});
|
});
|
||||||
List.addHistoryRecord.mockResolvedValue(undefined);
|
List.addHistoryRecord.mockResolvedValue(undefined);
|
||||||
List.getItemByName.mockResolvedValue({ id: 42, item_id: 99, item_name: "milk" });
|
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);
|
householdModel.isHouseholdMember.mockResolvedValue(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -54,7 +58,15 @@ describe("lists.controller.v2 addItem", () => {
|
|||||||
|
|
||||||
expect(householdModel.isHouseholdMember).toHaveBeenCalledWith("1", 9);
|
expect(householdModel.isHouseholdMember).toHaveBeenCalledWith("1", 9);
|
||||||
expect(List.addOrUpdateItem).toHaveBeenCalled();
|
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);
|
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -71,10 +83,42 @@ describe("lists.controller.v2 addItem", () => {
|
|||||||
|
|
||||||
expect(householdModel.isHouseholdMember).not.toHaveBeenCalled();
|
expect(householdModel.isHouseholdMember).not.toHaveBeenCalled();
|
||||||
expect(List.addOrUpdateItem).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);
|
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 () => {
|
test("records history using request user when added_for_user_id is blank", async () => {
|
||||||
const req = {
|
const req = {
|
||||||
params: { householdId: "1", storeId: "2" },
|
params: { householdId: "1", storeId: "2" },
|
||||||
@ -88,7 +132,7 @@ describe("lists.controller.v2 addItem", () => {
|
|||||||
|
|
||||||
expect(householdModel.isHouseholdMember).not.toHaveBeenCalled();
|
expect(householdModel.isHouseholdMember).not.toHaveBeenCalled();
|
||||||
expect(List.addOrUpdateItem).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);
|
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -169,7 +213,9 @@ describe("lists.controller.v2 setClassification", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
List.getItemByName.mockResolvedValue({ id: 42, item_id: 99, item_name: "milk" });
|
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" });
|
List.ensureHouseholdStoreItem.mockResolvedValue({ id: 99, name: "milk" });
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -216,6 +262,7 @@ describe("lists.controller.v2 setClassification", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("accepts zone-only classification updates", async () => {
|
test("accepts zone-only classification updates", async () => {
|
||||||
|
List.getZoneByName.mockResolvedValueOnce({ id: 6, name: "Checkout Area" });
|
||||||
const req = {
|
const req = {
|
||||||
params: { householdId: "1", storeId: "2" },
|
params: { householdId: "1", storeId: "2" },
|
||||||
body: {
|
body: {
|
||||||
@ -297,6 +344,7 @@ describe("lists.controller.v2 setClassification", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("rejects invalid zone", async () => {
|
test("rejects invalid zone", async () => {
|
||||||
|
List.getZoneByName.mockResolvedValueOnce(null);
|
||||||
const req = {
|
const req = {
|
||||||
params: { householdId: "1", storeId: "2" },
|
params: { householdId: "1", storeId: "2" },
|
||||||
body: {
|
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 () => {
|
test("creates a household store item when classification target is not yet on the list", async () => {
|
||||||
List.getItemByName.mockResolvedValueOnce(null);
|
List.getItemByName.mockResolvedValueOnce(null);
|
||||||
|
List.getZoneByName.mockResolvedValueOnce({ id: 7, name: "Snacks & Candy" });
|
||||||
|
|
||||||
const req = {
|
const req = {
|
||||||
params: { householdId: "1", storeId: "2" },
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
|||||||
153
backend/tests/store-locations.routes.test.js
Normal file
153
backend/tests/store-locations.routes.test.js
Normal file
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user