test: cover custom store location flows

This commit is contained in:
Nico 2026-05-26 00:39:26 -07:00
parent f45473cbff
commit 6b3d267abb
6 changed files with 350 additions and 21 deletions

View File

@ -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,
})
);
});
});

View File

@ -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();

View File

@ -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();
});
});

View File

@ -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"]
);
});
});

View File

@ -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" },

View 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();
});
});