chore: harden reliability checks #2
279
backend/controllers/available-items.controller.js
Normal file
279
backend/controllers/available-items.controller.js
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
const AvailableItems = require("../models/available-item.model");
|
||||||
|
const List = require("../models/list.model.v2");
|
||||||
|
const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications");
|
||||||
|
const { sendError } = require("../utils/http");
|
||||||
|
const { logError } = require("../utils/logger");
|
||||||
|
|
||||||
|
const LEGACY_ITEM_TYPE_MAP = {
|
||||||
|
beverages: "beverage",
|
||||||
|
snacks: "snack",
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseBoolean(value) {
|
||||||
|
return value === true || value === "true" || value === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseClassificationInput(value) {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed === "null") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith("{")) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed);
|
||||||
|
} catch (error) {
|
||||||
|
return Symbol.for("invalid-classification-json");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeClassificationPayload(classification) {
|
||||||
|
if (typeof classification === "string") {
|
||||||
|
const normalizedItemType = LEGACY_ITEM_TYPE_MAP[classification] || classification;
|
||||||
|
return {
|
||||||
|
item_type: normalizedItemType,
|
||||||
|
item_group: null,
|
||||||
|
zone: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!classification || typeof classification !== "object" || Array.isArray(classification)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item_type =
|
||||||
|
typeof classification.item_type === "string" && classification.item_type.trim() !== ""
|
||||||
|
? classification.item_type.trim()
|
||||||
|
: null;
|
||||||
|
const item_group =
|
||||||
|
typeof classification.item_group === "string" && classification.item_group.trim() !== ""
|
||||||
|
? classification.item_group.trim()
|
||||||
|
: null;
|
||||||
|
const zone =
|
||||||
|
typeof classification.zone === "string" && classification.zone.trim() !== ""
|
||||||
|
? classification.zone.trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!item_type && !item_group && !zone) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { item_type, item_group, zone };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateClassification(res, classification) {
|
||||||
|
if (!classification) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { item_type, item_group, zone } = classification;
|
||||||
|
|
||||||
|
if (item_type && !isValidItemType(item_type)) {
|
||||||
|
sendError(res, 400, "Invalid item_type");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item_group && !item_type) {
|
||||||
|
sendError(res, 400, "Item type is required when item group is provided");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item_group && !isValidItemGroup(item_type, item_group)) {
|
||||||
|
sendError(res, 400, "Invalid item_group for selected item_type");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zone && !isValidZone(zone)) {
|
||||||
|
sendError(res, 400, "Invalid zone");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseItemId(value) {
|
||||||
|
const parsed = Number.parseInt(String(value), 10);
|
||||||
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.getAvailableItems = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { householdId, storeId } = req.params;
|
||||||
|
const items = await AvailableItems.listAvailableItems(householdId, storeId, req.query.query || "");
|
||||||
|
res.json({ items });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "availableItems.getAvailableItems", error);
|
||||||
|
sendError(res, 500, "Failed to load available items");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.createAvailableItem = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { householdId, storeId } = req.params;
|
||||||
|
const { item_name } = req.body;
|
||||||
|
|
||||||
|
if (!item_name || item_name.trim() === "") {
|
||||||
|
return sendError(res, 400, "Item name is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedClassification = parseClassificationInput(req.body.classification);
|
||||||
|
if (parsedClassification === Symbol.for("invalid-classification-json")) {
|
||||||
|
return sendError(res, 400, "Classification payload must be valid JSON");
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedClassification = normalizeClassificationPayload(parsedClassification);
|
||||||
|
if (validateClassification(res, normalizedClassification)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageBuffer = req.processedImage?.buffer || null;
|
||||||
|
const mimeType = req.processedImage?.mimeType || null;
|
||||||
|
|
||||||
|
const item = await AvailableItems.createAvailableItem(
|
||||||
|
householdId,
|
||||||
|
storeId,
|
||||||
|
item_name,
|
||||||
|
imageBuffer,
|
||||||
|
mimeType
|
||||||
|
);
|
||||||
|
|
||||||
|
if (normalizedClassification) {
|
||||||
|
await List.upsertClassification(householdId, storeId, item.item_id, {
|
||||||
|
...normalizedClassification,
|
||||||
|
confidence: 1.0,
|
||||||
|
source: "user",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshedItem = await AvailableItems.getAvailableItemById(householdId, storeId, item.item_id);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
message: "Available item added",
|
||||||
|
item: refreshedItem,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "availableItems.createAvailableItem", error);
|
||||||
|
if (error.code === "23505") {
|
||||||
|
return sendError(res, 400, "Available item already exists for this store");
|
||||||
|
}
|
||||||
|
sendError(res, 500, "Failed to add available item");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateAvailableItem = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { householdId, storeId, itemId: rawItemId } = req.params;
|
||||||
|
const itemId = parseItemId(rawItemId);
|
||||||
|
|
||||||
|
if (!itemId) {
|
||||||
|
return sendError(res, 400, "Item ID must be a positive integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasClassificationField = Object.prototype.hasOwnProperty.call(req.body, "classification");
|
||||||
|
const parsedClassification = parseClassificationInput(req.body.classification);
|
||||||
|
|
||||||
|
if (parsedClassification === Symbol.for("invalid-classification-json")) {
|
||||||
|
return sendError(res, 400, "Classification payload must be valid JSON");
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedClassification = normalizeClassificationPayload(parsedClassification);
|
||||||
|
if (normalizedClassification && validateClassification(res, normalizedClassification)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedItem = await AvailableItems.updateAvailableItem(householdId, storeId, itemId, {
|
||||||
|
itemName: req.body.item_name,
|
||||||
|
imageBuffer: req.processedImage?.buffer || null,
|
||||||
|
mimeType: req.processedImage?.mimeType || null,
|
||||||
|
removeImage: parseBoolean(req.body.remove_image),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updatedItem) {
|
||||||
|
return sendError(res, 404, "Available item not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasClassificationField) {
|
||||||
|
if (normalizedClassification) {
|
||||||
|
await List.upsertClassification(householdId, storeId, updatedItem.item_id, {
|
||||||
|
...normalizedClassification,
|
||||||
|
confidence: 1.0,
|
||||||
|
source: "user",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await List.deleteClassification(householdId, storeId, updatedItem.item_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshedItem = await AvailableItems.getAvailableItemById(
|
||||||
|
householdId,
|
||||||
|
storeId,
|
||||||
|
updatedItem.item_id
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: "Available item updated",
|
||||||
|
item: refreshedItem,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "availableItems.updateAvailableItem", error);
|
||||||
|
if (error.code === "23505") {
|
||||||
|
return sendError(res, 400, "Available item already exists for this store");
|
||||||
|
}
|
||||||
|
sendError(res, 500, "Failed to update available item");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.deleteAvailableItem = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { householdId, storeId, itemId: rawItemId } = req.params;
|
||||||
|
const itemId = parseItemId(rawItemId);
|
||||||
|
|
||||||
|
if (!itemId) {
|
||||||
|
return sendError(res, 400, "Item ID must be a positive integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await AvailableItems.deleteAvailableItem(householdId, storeId, itemId);
|
||||||
|
if (!deleted) {
|
||||||
|
return sendError(res, 404, "Available item not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: "Available item removed" });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "availableItems.deleteAvailableItem", error);
|
||||||
|
sendError(res, 500, "Failed to remove available item");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.importCurrentItems = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { householdId, storeId } = req.params;
|
||||||
|
const importedCount = await AvailableItems.importCurrentListItems(householdId, storeId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: importedCount > 0 ? "Imported current list items" : "No current list items to import",
|
||||||
|
imported_count: importedCount,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "availableItems.importCurrentItems", error);
|
||||||
|
sendError(res, 500, "Failed to import current list items");
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -1,9 +1,49 @@
|
|||||||
const List = require("../models/list.model.v2");
|
const List = require("../models/list.model.v2");
|
||||||
|
const AvailableItems = require("../models/available-item.model");
|
||||||
const householdModel = require("../models/household.model");
|
const householdModel = require("../models/household.model");
|
||||||
const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications");
|
const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications");
|
||||||
const { sendError } = require("../utils/http");
|
const { sendError } = require("../utils/http");
|
||||||
const { logError } = require("../utils/logger");
|
const { logError } = require("../utils/logger");
|
||||||
|
|
||||||
|
const LEGACY_ITEM_TYPE_MAP = {
|
||||||
|
beverages: "beverage",
|
||||||
|
snacks: "snack",
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeClassificationPayload(classification) {
|
||||||
|
if (typeof classification === "string") {
|
||||||
|
const normalizedItemType = LEGACY_ITEM_TYPE_MAP[classification] || classification;
|
||||||
|
return {
|
||||||
|
item_type: normalizedItemType,
|
||||||
|
item_group: null,
|
||||||
|
zone: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!classification || typeof classification !== "object" || Array.isArray(classification)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item_type =
|
||||||
|
typeof classification.item_type === "string" && classification.item_type.trim() !== ""
|
||||||
|
? classification.item_type.trim()
|
||||||
|
: null;
|
||||||
|
const item_group =
|
||||||
|
typeof classification.item_group === "string" && classification.item_group.trim() !== ""
|
||||||
|
? classification.item_group.trim()
|
||||||
|
: null;
|
||||||
|
const zone =
|
||||||
|
typeof classification.zone === "string" && classification.zone.trim() !== ""
|
||||||
|
? classification.zone.trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!item_type && !item_group && !zone) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { item_type, item_group, zone };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get list items for household and store
|
* Get list items for household and store
|
||||||
* GET /households/:householdId/stores/:storeId/list
|
* GET /households/:householdId/stores/:storeId/list
|
||||||
@ -80,8 +120,18 @@ exports.addItem = async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get processed image if uploaded
|
// Get processed image if uploaded
|
||||||
const imageBuffer = req.processedImage?.buffer || null;
|
let imageBuffer = req.processedImage?.buffer || null;
|
||||||
const mimeType = req.processedImage?.mimeType || null;
|
let mimeType = req.processedImage?.mimeType || null;
|
||||||
|
|
||||||
|
if (!imageBuffer) {
|
||||||
|
const catalogItem = await AvailableItems.getAvailableItemImageByName(
|
||||||
|
householdId,
|
||||||
|
storeId,
|
||||||
|
item_name
|
||||||
|
);
|
||||||
|
imageBuffer = catalogItem?.custom_image || null;
|
||||||
|
mimeType = catalogItem?.custom_image_mime_type || null;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await List.addOrUpdateItem(
|
const result = await List.addOrUpdateItem(
|
||||||
householdId,
|
householdId,
|
||||||
@ -253,7 +303,7 @@ exports.getClassification = async (req, res) => {
|
|||||||
return res.json({ classification: null });
|
return res.json({ classification: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
const classification = await List.getClassification(householdId, item.item_id);
|
const classification = await List.getClassification(householdId, storeId, item.item_id);
|
||||||
res.json({ classification });
|
res.json({ classification });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(req, "listsV2.getClassification", error);
|
logError(req, "listsV2.getClassification", error);
|
||||||
@ -274,14 +324,27 @@ exports.setClassification = async (req, res) => {
|
|||||||
return sendError(res, 400, "Item name is required");
|
return sendError(res, 400, "Item name is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!classification) {
|
const normalizedClassification = normalizeClassificationPayload(classification);
|
||||||
|
if (!normalizedClassification) {
|
||||||
return sendError(res, 400, "Classification is required");
|
return sendError(res, 400, "Classification is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate classification
|
const { item_type, item_group, zone } = normalizedClassification;
|
||||||
const validClassifications = ['produce', 'dairy', 'meat', 'bakery', 'frozen', 'pantry', 'snacks', 'beverages', 'household', 'other'];
|
|
||||||
if (!validClassifications.includes(classification)) {
|
if (item_type && !isValidItemType(item_type)) {
|
||||||
return sendError(res, 400, "Invalid classification value");
|
return sendError(res, 400, "Invalid item_type");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item_group && !item_type) {
|
||||||
|
return sendError(res, 400, "Item type is required when item group is provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item_group && !isValidItemGroup(item_type, item_group)) {
|
||||||
|
return sendError(res, 400, "Invalid item_group for selected item_type");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zone && !isValidZone(zone)) {
|
||||||
|
return sendError(res, 400, "Invalid zone");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get item - add to master items if not exists
|
// Get item - add to master items if not exists
|
||||||
@ -304,14 +367,15 @@ exports.setClassification = async (req, res) => {
|
|||||||
itemId = item.item_id;
|
itemId = item.item_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set classification (using item_type field for simplicity)
|
await List.upsertClassification(householdId, storeId, itemId, {
|
||||||
await List.upsertClassification(householdId, itemId, {
|
item_type,
|
||||||
item_type: classification,
|
item_group,
|
||||||
item_group: null,
|
zone,
|
||||||
zone: null
|
confidence: 1.0,
|
||||||
|
source: "user",
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ message: "Classification set", classification });
|
res.json({ message: "Classification set", classification: normalizedClassification });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(req, "listsV2.setClassification", error);
|
logError(req, "listsV2.setClassification", error);
|
||||||
sendError(res, 500, "Failed to set classification");
|
sendError(res, 500, "Failed to set classification");
|
||||||
|
|||||||
231
backend/models/available-item.model.js
Normal file
231
backend/models/available-item.model.js
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
const pool = require("../db/pool");
|
||||||
|
|
||||||
|
function normalizeItemName(itemName) {
|
||||||
|
return String(itemName || "").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findOrCreateItem(itemName) {
|
||||||
|
const normalizedName = normalizeItemName(itemName);
|
||||||
|
const existing = await pool.query(
|
||||||
|
"SELECT id, name FROM items WHERE name ILIKE $1",
|
||||||
|
[normalizedName]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.rowCount > 0) {
|
||||||
|
return {
|
||||||
|
itemId: existing.rows[0].id,
|
||||||
|
itemName: existing.rows[0].name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await pool.query(
|
||||||
|
"INSERT INTO items (name) VALUES ($1) RETURNING id, name",
|
||||||
|
[normalizedName]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
itemId: created.rows[0].id,
|
||||||
|
itemName: created.rows[0].name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAvailableItemRecord(householdId, storeId, itemId) {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
hsai.item_id,
|
||||||
|
i.name AS item_name,
|
||||||
|
ENCODE(hsai.custom_image, 'base64') AS item_image,
|
||||||
|
hsai.custom_image_mime_type AS image_mime_type,
|
||||||
|
hic.item_type,
|
||||||
|
hic.item_group,
|
||||||
|
hic.zone,
|
||||||
|
hsai.created_at,
|
||||||
|
hsai.updated_at
|
||||||
|
FROM household_store_available_items hsai
|
||||||
|
JOIN items i ON i.id = hsai.item_id
|
||||||
|
LEFT JOIN household_item_classifications hic
|
||||||
|
ON hic.household_id = hsai.household_id
|
||||||
|
AND hic.store_id = hsai.store_id
|
||||||
|
AND hic.item_id = hsai.item_id
|
||||||
|
WHERE hsai.household_id = $1
|
||||||
|
AND hsai.store_id = $2
|
||||||
|
AND hsai.item_id = $3`,
|
||||||
|
[householdId, storeId, itemId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.listAvailableItems = async (householdId, storeId, query = "") => {
|
||||||
|
const trimmedQuery = String(query || "").trim();
|
||||||
|
const values = [householdId, storeId];
|
||||||
|
let filterClause = "";
|
||||||
|
|
||||||
|
if (trimmedQuery) {
|
||||||
|
values.push(`%${trimmedQuery}%`);
|
||||||
|
filterClause = "AND i.name ILIKE $3";
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
hsai.item_id,
|
||||||
|
i.name AS item_name,
|
||||||
|
ENCODE(hsai.custom_image, 'base64') AS item_image,
|
||||||
|
hsai.custom_image_mime_type AS image_mime_type,
|
||||||
|
hic.item_type,
|
||||||
|
hic.item_group,
|
||||||
|
hic.zone,
|
||||||
|
hsai.created_at,
|
||||||
|
hsai.updated_at
|
||||||
|
FROM household_store_available_items hsai
|
||||||
|
JOIN items i ON i.id = hsai.item_id
|
||||||
|
LEFT JOIN household_item_classifications hic
|
||||||
|
ON hic.household_id = hsai.household_id
|
||||||
|
AND hic.store_id = hsai.store_id
|
||||||
|
AND hic.item_id = hsai.item_id
|
||||||
|
WHERE hsai.household_id = $1
|
||||||
|
AND hsai.store_id = $2
|
||||||
|
${filterClause}
|
||||||
|
ORDER BY i.name ASC
|
||||||
|
LIMIT 100`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getAvailableItemById = async (householdId, storeId, itemId) =>
|
||||||
|
getAvailableItemRecord(householdId, storeId, itemId);
|
||||||
|
|
||||||
|
exports.getAvailableItemImageByName = async (householdId, storeId, itemName) => {
|
||||||
|
const normalizedName = normalizeItemName(itemName);
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
hsai.item_id,
|
||||||
|
i.name AS item_name,
|
||||||
|
hsai.custom_image,
|
||||||
|
hsai.custom_image_mime_type
|
||||||
|
FROM household_store_available_items hsai
|
||||||
|
JOIN items i ON i.id = hsai.item_id
|
||||||
|
WHERE hsai.household_id = $1
|
||||||
|
AND hsai.store_id = $2
|
||||||
|
AND i.name ILIKE $3`,
|
||||||
|
[householdId, storeId, normalizedName]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.createAvailableItem = async (
|
||||||
|
householdId,
|
||||||
|
storeId,
|
||||||
|
itemName,
|
||||||
|
imageBuffer = null,
|
||||||
|
mimeType = null
|
||||||
|
) => {
|
||||||
|
const { itemId } = await findOrCreateItem(itemName);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO household_store_available_items
|
||||||
|
(household_id, store_id, item_id, custom_image, custom_image_mime_type, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, NOW())`,
|
||||||
|
[householdId, storeId, itemId, imageBuffer, mimeType]
|
||||||
|
);
|
||||||
|
|
||||||
|
return getAvailableItemRecord(householdId, storeId, itemId);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateAvailableItem = async (householdId, storeId, itemId, updates = {}) => {
|
||||||
|
const {
|
||||||
|
itemName,
|
||||||
|
imageBuffer,
|
||||||
|
mimeType,
|
||||||
|
removeImage = false,
|
||||||
|
} = updates;
|
||||||
|
|
||||||
|
const assignments = ["updated_at = NOW()"];
|
||||||
|
const values = [householdId, storeId, itemId];
|
||||||
|
let parameterIndex = values.length;
|
||||||
|
|
||||||
|
if (itemName !== undefined && String(itemName).trim() !== "") {
|
||||||
|
const { itemId: nextItemId } = await findOrCreateItem(itemName);
|
||||||
|
parameterIndex += 1;
|
||||||
|
assignments.push(`item_id = $${parameterIndex}`);
|
||||||
|
values.push(nextItemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removeImage) {
|
||||||
|
assignments.push("custom_image = NULL", "custom_image_mime_type = NULL");
|
||||||
|
} else if (imageBuffer && mimeType) {
|
||||||
|
parameterIndex += 1;
|
||||||
|
assignments.push(`custom_image = $${parameterIndex}`);
|
||||||
|
values.push(imageBuffer);
|
||||||
|
|
||||||
|
parameterIndex += 1;
|
||||||
|
assignments.push(`custom_image_mime_type = $${parameterIndex}`);
|
||||||
|
values.push(mimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE household_store_available_items
|
||||||
|
SET ${assignments.join(", ")}
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND store_id = $2
|
||||||
|
AND item_id = $3
|
||||||
|
RETURNING item_id`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getAvailableItemRecord(householdId, storeId, result.rows[0].item_id);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.deleteAvailableItem = async (householdId, storeId, itemId) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`DELETE FROM household_store_available_items
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND store_id = $2
|
||||||
|
AND item_id = $3`,
|
||||||
|
[householdId, storeId, itemId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rowCount > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.importCurrentListItems = async (householdId, storeId) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO household_store_available_items
|
||||||
|
(household_id, store_id, item_id, custom_image, custom_image_mime_type, updated_at)
|
||||||
|
SELECT
|
||||||
|
hl.household_id,
|
||||||
|
hl.store_id,
|
||||||
|
hl.item_id,
|
||||||
|
hl.custom_image,
|
||||||
|
hl.custom_image_mime_type,
|
||||||
|
NOW()
|
||||||
|
FROM household_lists hl
|
||||||
|
WHERE hl.household_id = $1
|
||||||
|
AND hl.store_id = $2
|
||||||
|
ON CONFLICT (household_id, store_id, item_id) DO NOTHING
|
||||||
|
RETURNING item_id`,
|
||||||
|
[householdId, storeId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rowCount;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.hasAvailableItems = async (householdId, storeId) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT 1
|
||||||
|
FROM household_store_available_items
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND store_id = $2
|
||||||
|
LIMIT 1`,
|
||||||
|
[householdId, storeId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rowCount > 0;
|
||||||
|
};
|
||||||
@ -11,6 +11,7 @@ exports.getHouseholdStoreList = async (householdId, storeId, includeHistory = tr
|
|||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT
|
`SELECT
|
||||||
hl.id,
|
hl.id,
|
||||||
|
hl.item_id,
|
||||||
i.name AS item_name,
|
i.name AS item_name,
|
||||||
hl.quantity,
|
hl.quantity,
|
||||||
hl.bought,
|
hl.bought,
|
||||||
@ -36,6 +37,7 @@ exports.getHouseholdStoreList = async (householdId, storeId, includeHistory = tr
|
|||||||
JOIN items i ON hl.item_id = i.id
|
JOIN items i ON hl.item_id = i.id
|
||||||
LEFT JOIN household_item_classifications hic
|
LEFT JOIN household_item_classifications hic
|
||||||
ON hl.household_id = hic.household_id
|
ON hl.household_id = hic.household_id
|
||||||
|
AND hl.store_id = hic.store_id
|
||||||
AND hl.item_id = hic.item_id
|
AND hl.item_id = hic.item_id
|
||||||
WHERE hl.household_id = $1
|
WHERE hl.household_id = $1
|
||||||
AND hl.store_id = $2
|
AND hl.store_id = $2
|
||||||
@ -68,6 +70,7 @@ exports.getItemByName = async (householdId, storeId, itemName) => {
|
|||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT
|
`SELECT
|
||||||
hl.id,
|
hl.id,
|
||||||
|
hl.item_id,
|
||||||
i.name AS item_name,
|
i.name AS item_name,
|
||||||
hl.quantity,
|
hl.quantity,
|
||||||
hl.bought,
|
hl.bought,
|
||||||
@ -91,6 +94,7 @@ exports.getItemByName = async (householdId, storeId, itemName) => {
|
|||||||
JOIN items i ON hl.item_id = i.id
|
JOIN items i ON hl.item_id = i.id
|
||||||
LEFT JOIN household_item_classifications hic
|
LEFT JOIN household_item_classifications hic
|
||||||
ON hl.household_id = hic.household_id
|
ON hl.household_id = hic.household_id
|
||||||
|
AND hl.store_id = hic.store_id
|
||||||
AND hl.item_id = hic.item_id
|
AND hl.item_id = hic.item_id
|
||||||
WHERE hl.household_id = $1
|
WHERE hl.household_id = $1
|
||||||
AND hl.store_id = $2
|
AND hl.store_id = $2
|
||||||
@ -109,7 +113,7 @@ exports.getItemByName = async (householdId, storeId, itemName) => {
|
|||||||
* @param {number} userId - User adding the item
|
* @param {number} userId - User adding the item
|
||||||
* @param {Buffer|null} imageBuffer - Image buffer
|
* @param {Buffer|null} imageBuffer - Image buffer
|
||||||
* @param {string|null} mimeType - MIME type
|
* @param {string|null} mimeType - MIME type
|
||||||
* @returns {Promise<number>} List item ID
|
* @returns {Promise<{listId: number, itemId: number, itemName: string, isNew: boolean}>} Item metadata
|
||||||
*/
|
*/
|
||||||
exports.addOrUpdateItem = async (
|
exports.addOrUpdateItem = async (
|
||||||
householdId,
|
householdId,
|
||||||
@ -169,7 +173,12 @@ exports.addOrUpdateItem = async (
|
|||||||
[quantity, listId]
|
[quantity, listId]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return listId;
|
return {
|
||||||
|
listId,
|
||||||
|
itemId,
|
||||||
|
itemName: lowerItemName,
|
||||||
|
isNew: false,
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
const insert = await pool.query(
|
const insert = await pool.query(
|
||||||
`INSERT INTO household_lists
|
`INSERT INTO household_lists
|
||||||
@ -178,7 +187,12 @@ exports.addOrUpdateItem = async (
|
|||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[householdId, storeId, itemId, quantity, imageBuffer, mimeType]
|
[householdId, storeId, itemId, quantity, imageBuffer, mimeType]
|
||||||
);
|
);
|
||||||
return insert.rows[0].id;
|
return {
|
||||||
|
listId: insert.rows[0].id,
|
||||||
|
itemId,
|
||||||
|
itemName: lowerItemName,
|
||||||
|
isNew: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -255,6 +269,32 @@ exports.addHistoryRecord = async (listId, quantity, userId) => {
|
|||||||
* @returns {Promise<Array>} Suggestions
|
* @returns {Promise<Array>} Suggestions
|
||||||
*/
|
*/
|
||||||
exports.getSuggestions = async (query, householdId, storeId) => {
|
exports.getSuggestions = async (query, householdId, storeId) => {
|
||||||
|
const hasCatalogResult = await pool.query(
|
||||||
|
`SELECT 1
|
||||||
|
FROM household_store_available_items
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND store_id = $2
|
||||||
|
LIMIT 1`,
|
||||||
|
[householdId, storeId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasCatalogResult.rowCount > 0) {
|
||||||
|
const catalogSuggestions = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
i.name as item_name,
|
||||||
|
0 as sort_order
|
||||||
|
FROM household_store_available_items hsai
|
||||||
|
JOIN items i ON i.id = hsai.item_id
|
||||||
|
WHERE hsai.household_id = $2
|
||||||
|
AND hsai.store_id = $3
|
||||||
|
AND i.name ILIKE $1
|
||||||
|
ORDER BY i.name
|
||||||
|
LIMIT 10`,
|
||||||
|
[`%${query}%`, householdId, storeId]
|
||||||
|
);
|
||||||
|
return catalogSuggestions.rows;
|
||||||
|
}
|
||||||
|
|
||||||
// Get items from both master catalog and household history
|
// Get items from both master catalog and household history
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT DISTINCT
|
`SELECT DISTINCT
|
||||||
@ -314,15 +354,16 @@ exports.getRecentlyBoughtItems = async (householdId, storeId) => {
|
|||||||
/**
|
/**
|
||||||
* Get classification for household item
|
* Get classification for household item
|
||||||
* @param {number} householdId - Household ID
|
* @param {number} householdId - Household ID
|
||||||
|
* @param {number} storeId - Store ID
|
||||||
* @param {number} itemId - Item ID
|
* @param {number} itemId - Item ID
|
||||||
* @returns {Promise<Object|null>} Classification or null
|
* @returns {Promise<Object|null>} Classification or null
|
||||||
*/
|
*/
|
||||||
exports.getClassification = async (householdId, itemId) => {
|
exports.getClassification = async (householdId, storeId, itemId) => {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT item_type, item_group, zone, confidence, source
|
`SELECT item_type, item_group, zone, confidence, source
|
||||||
FROM household_item_classifications
|
FROM household_item_classifications
|
||||||
WHERE household_id = $1 AND item_id = $2`,
|
WHERE household_id = $1 AND store_id = $2 AND item_id = $3`,
|
||||||
[householdId, itemId]
|
[householdId, storeId, itemId]
|
||||||
);
|
);
|
||||||
return result.rows[0] || null;
|
return result.rows[0] || null;
|
||||||
};
|
};
|
||||||
@ -330,18 +371,19 @@ exports.getClassification = async (householdId, itemId) => {
|
|||||||
/**
|
/**
|
||||||
* Upsert classification for household item
|
* Upsert classification for household item
|
||||||
* @param {number} householdId - Household ID
|
* @param {number} householdId - Household ID
|
||||||
|
* @param {number} storeId - Store ID
|
||||||
* @param {number} itemId - Item ID
|
* @param {number} itemId - Item ID
|
||||||
* @param {Object} classification - Classification data
|
* @param {Object} classification - Classification data
|
||||||
* @returns {Promise<Object>} Updated classification
|
* @returns {Promise<Object>} Updated classification
|
||||||
*/
|
*/
|
||||||
exports.upsertClassification = async (householdId, itemId, classification) => {
|
exports.upsertClassification = async (householdId, storeId, itemId, classification) => {
|
||||||
const { item_type, item_group, zone, confidence, source } = classification;
|
const { item_type, item_group, zone, confidence, source } = classification;
|
||||||
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`INSERT INTO household_item_classifications
|
`INSERT INTO household_item_classifications
|
||||||
(household_id, item_id, item_type, item_group, zone, confidence, source)
|
(household_id, store_id, item_id, item_type, item_group, zone, confidence, source)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
ON CONFLICT (household_id, item_id)
|
ON CONFLICT (household_id, store_id, item_id)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
item_type = EXCLUDED.item_type,
|
item_type = EXCLUDED.item_type,
|
||||||
item_group = EXCLUDED.item_group,
|
item_group = EXCLUDED.item_group,
|
||||||
@ -349,11 +391,27 @@ exports.upsertClassification = async (householdId, itemId, classification) => {
|
|||||||
confidence = EXCLUDED.confidence,
|
confidence = EXCLUDED.confidence,
|
||||||
source = EXCLUDED.source
|
source = EXCLUDED.source
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[householdId, itemId, item_type, item_group, zone, confidence, source]
|
[householdId, storeId, itemId, item_type, item_group, zone, confidence, source]
|
||||||
);
|
);
|
||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove classification for household/store item
|
||||||
|
* @param {number} householdId - Household ID
|
||||||
|
* @param {number} storeId - Store ID
|
||||||
|
* @param {number} itemId - Item ID
|
||||||
|
*/
|
||||||
|
exports.deleteClassification = async (householdId, storeId, itemId) => {
|
||||||
|
await pool.query(
|
||||||
|
`DELETE FROM household_item_classifications
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND store_id = $2
|
||||||
|
AND item_id = $3`,
|
||||||
|
[householdId, storeId, itemId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update list item details
|
* Update list item details
|
||||||
* @param {number} listId - List item ID
|
* @param {number} listId - List item ID
|
||||||
|
|||||||
@ -2,6 +2,7 @@ const express = require("express");
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const controller = require("../controllers/households.controller");
|
const controller = require("../controllers/households.controller");
|
||||||
const listsController = require("../controllers/lists.controller.v2");
|
const listsController = require("../controllers/lists.controller.v2");
|
||||||
|
const availableItemsController = require("../controllers/available-items.controller");
|
||||||
const auth = require("../middleware/auth");
|
const auth = require("../middleware/auth");
|
||||||
const {
|
const {
|
||||||
householdAccess,
|
householdAccess,
|
||||||
@ -39,6 +40,50 @@ router.post(
|
|||||||
controller.refreshInviteCode
|
controller.refreshInviteCode
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/:householdId/stores/:storeId/available-items",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
storeAccess,
|
||||||
|
availableItemsController.getAvailableItems
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/:householdId/stores/:storeId/available-items",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
storeAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
upload.single("image"),
|
||||||
|
processImage,
|
||||||
|
availableItemsController.createAvailableItem
|
||||||
|
);
|
||||||
|
router.patch(
|
||||||
|
"/:householdId/stores/:storeId/available-items/:itemId",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
storeAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
upload.single("image"),
|
||||||
|
processImage,
|
||||||
|
availableItemsController.updateAvailableItem
|
||||||
|
);
|
||||||
|
router.delete(
|
||||||
|
"/:householdId/stores/:storeId/available-items/:itemId",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
storeAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
availableItemsController.deleteAvailableItem
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/:householdId/stores/:storeId/available-items/import-current",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
storeAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
availableItemsController.importCurrentItems
|
||||||
|
);
|
||||||
|
|
||||||
// Member management routes
|
// Member management routes
|
||||||
router.get(
|
router.get(
|
||||||
"/:householdId/members",
|
"/:householdId/members",
|
||||||
|
|||||||
120
backend/tests/available-item.model.test.js
Normal file
120
backend/tests/available-item.model.test.js
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
jest.mock("../db/pool", () => ({
|
||||||
|
query: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pool = require("../db/pool");
|
||||||
|
const AvailableItems = require("../models/available-item.model");
|
||||||
|
|
||||||
|
describe("available-item.model", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
pool.query.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates an available item using an existing catalog item", async () => {
|
||||||
|
pool.query
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55, name: "milk" }] })
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1, rows: [] })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
item_id: 55,
|
||||||
|
item_name: "milk",
|
||||||
|
item_image: null,
|
||||||
|
image_mime_type: null,
|
||||||
|
item_type: "dairy",
|
||||||
|
item_group: "Milk",
|
||||||
|
zone: "Dairy & Refrigerated",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await AvailableItems.createAvailableItem(1, 2, "Milk");
|
||||||
|
|
||||||
|
expect(result).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
item_id: 55,
|
||||||
|
item_name: "milk",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(pool.query).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"SELECT id, name FROM items WHERE name ILIKE $1",
|
||||||
|
["milk"]
|
||||||
|
);
|
||||||
|
expect(pool.query).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
expect.stringContaining("INSERT INTO household_store_available_items"),
|
||||||
|
[1, 2, 55, null, null]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates an available item and inserts a new master item when needed", async () => {
|
||||||
|
pool.query
|
||||||
|
.mockResolvedValueOnce({ rowCount: 0, rows: [] })
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 77, name: "granola" }] })
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1, rows: [] })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [{ item_id: 77, item_name: "granola" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await AvailableItems.createAvailableItem(1, 2, "Granola");
|
||||||
|
|
||||||
|
expect(result).toEqual(expect.objectContaining({ item_id: 77, item_name: "granola" }));
|
||||||
|
expect(pool.query).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"INSERT INTO items (name) VALUES ($1) RETURNING id, name",
|
||||||
|
["granola"]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updates available item images and returns refreshed data", async () => {
|
||||||
|
const imageBuffer = Buffer.from("abc");
|
||||||
|
pool.query
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1, rows: [{ item_id: 55 }] })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [{ item_id: 55, item_name: "milk", item_image: "YWJj", image_mime_type: "image/jpeg" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await AvailableItems.updateAvailableItem(1, 2, 55, {
|
||||||
|
imageBuffer,
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual(expect.objectContaining({ item_id: 55, image_mime_type: "image/jpeg" }));
|
||||||
|
expect(pool.query).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
expect.stringContaining("UPDATE household_store_available_items"),
|
||||||
|
[1, 2, 55, imageBuffer, "image/jpeg"]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("imports current household list items idempotently", async () => {
|
||||||
|
pool.query.mockResolvedValueOnce({
|
||||||
|
rowCount: 2,
|
||||||
|
rows: [{ item_id: 10 }, { item_id: 11 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await AvailableItems.importCurrentListItems(1, 2);
|
||||||
|
|
||||||
|
expect(result).toBe(2);
|
||||||
|
expect(pool.query).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("INSERT INTO household_store_available_items"),
|
||||||
|
[1, 2]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deletes only the catalog entry", async () => {
|
||||||
|
pool.query.mockResolvedValueOnce({ rowCount: 1, rows: [] });
|
||||||
|
|
||||||
|
const deleted = await AvailableItems.deleteAvailableItem(1, 2, 55);
|
||||||
|
|
||||||
|
expect(deleted).toBe(true);
|
||||||
|
expect(pool.query).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("DELETE FROM household_store_available_items"),
|
||||||
|
[1, 2, 55]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
137
backend/tests/available-items.controller.test.js
Normal file
137
backend/tests/available-items.controller.test.js
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
jest.mock("../models/available-item.model", () => ({
|
||||||
|
createAvailableItem: jest.fn(),
|
||||||
|
deleteAvailableItem: jest.fn(),
|
||||||
|
getAvailableItemById: jest.fn(),
|
||||||
|
importCurrentListItems: jest.fn(),
|
||||||
|
listAvailableItems: jest.fn(),
|
||||||
|
updateAvailableItem: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../models/list.model.v2", () => ({
|
||||||
|
deleteClassification: jest.fn(),
|
||||||
|
upsertClassification: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../utils/logger", () => ({
|
||||||
|
logError: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const AvailableItems = require("../models/available-item.model");
|
||||||
|
const List = require("../models/list.model.v2");
|
||||||
|
const controller = require("../controllers/available-items.controller");
|
||||||
|
|
||||||
|
function createResponse() {
|
||||||
|
const res = {};
|
||||||
|
res.status = jest.fn().mockReturnValue(res);
|
||||||
|
res.json = jest.fn().mockReturnValue(res);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("available-items.controller", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
AvailableItems.createAvailableItem.mockResolvedValue({ item_id: 99, item_name: "milk" });
|
||||||
|
AvailableItems.getAvailableItemById.mockResolvedValue({
|
||||||
|
item_id: 99,
|
||||||
|
item_name: "milk",
|
||||||
|
item_type: "dairy",
|
||||||
|
item_group: "Milk",
|
||||||
|
zone: "Dairy & Refrigerated",
|
||||||
|
});
|
||||||
|
AvailableItems.updateAvailableItem.mockResolvedValue({ item_id: 99, item_name: "milk" });
|
||||||
|
AvailableItems.deleteAvailableItem.mockResolvedValue(true);
|
||||||
|
AvailableItems.importCurrentListItems.mockResolvedValue(2);
|
||||||
|
AvailableItems.listAvailableItems.mockResolvedValue([]);
|
||||||
|
List.upsertClassification.mockResolvedValue(undefined);
|
||||||
|
List.deleteClassification.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates an available item and persists classification metadata", async () => {
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
body: {
|
||||||
|
item_name: "milk",
|
||||||
|
classification: JSON.stringify({
|
||||||
|
item_type: "dairy",
|
||||||
|
item_group: "Milk",
|
||||||
|
zone: "Dairy & Refrigerated",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
processedImage: null,
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.createAvailableItem(req, res);
|
||||||
|
|
||||||
|
expect(AvailableItems.createAvailableItem).toHaveBeenCalledWith("1", "2", "milk", null, null);
|
||||||
|
expect(List.upsertClassification).toHaveBeenCalledWith(
|
||||||
|
"1",
|
||||||
|
"2",
|
||||||
|
99,
|
||||||
|
expect.objectContaining({
|
||||||
|
item_type: "dairy",
|
||||||
|
item_group: "Milk",
|
||||||
|
zone: "Dairy & Refrigerated",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(res.status).toHaveBeenCalledWith(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid item_group values", async () => {
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
body: {
|
||||||
|
item_name: "milk",
|
||||||
|
classification: JSON.stringify({
|
||||||
|
item_type: "dairy",
|
||||||
|
item_group: "Bread",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.createAvailableItem(req, res);
|
||||||
|
|
||||||
|
expect(AvailableItems.createAvailableItem).not.toHaveBeenCalled();
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({
|
||||||
|
message: "Invalid item_group for selected item_type",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clears classification on update when classification is explicitly empty", async () => {
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2", itemId: "99" },
|
||||||
|
body: {
|
||||||
|
classification: "null",
|
||||||
|
},
|
||||||
|
processedImage: null,
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.updateAvailableItem(req, res);
|
||||||
|
|
||||||
|
expect(List.deleteClassification).toHaveBeenCalledWith("1", "2", 99);
|
||||||
|
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("imports current list items and reports the import count", async () => {
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.importCurrentItems(req, res);
|
||||||
|
|
||||||
|
expect(AvailableItems.importCurrentListItems).toHaveBeenCalledWith("1", "2");
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
imported_count: 2,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
109
backend/tests/available-items.routes.test.js
Normal file
109
backend/tests/available-items.routes.test.js
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
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"] || "user",
|
||||||
|
};
|
||||||
|
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((req, res) => res.status(201).json({ message: "created" })),
|
||||||
|
deleteAvailableItem: jest.fn((req, res) => res.json({ message: "deleted" })),
|
||||||
|
getAvailableItems: jest.fn((req, res) => res.json({ items: [] })),
|
||||||
|
importCurrentItems: jest.fn((req, res) => res.json({ imported_count: 1 })),
|
||||||
|
updateAvailableItem: jest.fn((req, res) => res.json({ message: "updated" })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const express = require("express");
|
||||||
|
const request = require("supertest");
|
||||||
|
const router = require("../routes/households.routes");
|
||||||
|
const availableItemsController = require("../controllers/available-items.controller");
|
||||||
|
|
||||||
|
describe("available-items routes", () => {
|
||||||
|
let app;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/households", router);
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("members can read available items", async () => {
|
||||||
|
const response = await request(app).get("/households/1/stores/2/available-items");
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(availableItemsController.getAvailableItems).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("members cannot mutate available items", async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post("/households/1/stores/2/available-items")
|
||||||
|
.set("x-household-role", "user")
|
||||||
|
.send({ item_name: "milk" });
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(availableItemsController.createAvailableItem).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("admins can create available items", async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post("/households/1/stores/2/available-items")
|
||||||
|
.set("x-household-role", "admin")
|
||||||
|
.send({ item_name: "milk" });
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(availableItemsController.createAvailableItem).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
176
backend/tests/list.model.v2.test.js
Normal file
176
backend/tests/list.model.v2.test.js
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
jest.mock("../db/pool", () => ({
|
||||||
|
query: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pool = require("../db/pool");
|
||||||
|
const List = require("../models/list.model.v2");
|
||||||
|
|
||||||
|
describe("list.model.v2 addOrUpdateItem", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
pool.query.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns item metadata when creating a new household list item", async () => {
|
||||||
|
pool.query
|
||||||
|
.mockResolvedValueOnce({ rowCount: 0, rows: [] })
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55 }] })
|
||||||
|
.mockResolvedValueOnce({ rowCount: 0, rows: [] })
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 88 }] });
|
||||||
|
|
||||||
|
const result = await List.addOrUpdateItem(1, 2, "Milk", 3, 7);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
listId: 88,
|
||||||
|
itemId: 55,
|
||||||
|
itemName: "milk",
|
||||||
|
isNew: true,
|
||||||
|
});
|
||||||
|
expect(pool.query).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"SELECT id FROM items WHERE name ILIKE $1",
|
||||||
|
["milk"]
|
||||||
|
);
|
||||||
|
expect(pool.query).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"INSERT INTO items (name) VALUES ($1) RETURNING id",
|
||||||
|
["milk"]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns item metadata when updating an existing household list item", async () => {
|
||||||
|
pool.query
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55 }] })
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 88, bought: false }] })
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1, rows: [] });
|
||||||
|
|
||||||
|
const result = await List.addOrUpdateItem(1, 2, "Milk", 4, 7);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
listId: 88,
|
||||||
|
itemId: 55,
|
||||||
|
itemName: "milk",
|
||||||
|
isNew: false,
|
||||||
|
});
|
||||||
|
expect(pool.query).toHaveBeenNthCalledWith(
|
||||||
|
3,
|
||||||
|
expect.stringContaining("UPDATE household_lists"),
|
||||||
|
[4, 88]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("list.model.v2 classification helpers", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
pool.query.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gets classification using household, store, and item ids", async () => {
|
||||||
|
pool.query.mockResolvedValueOnce({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
item_type: "dairy",
|
||||||
|
item_group: "Milk",
|
||||||
|
zone: "Dairy & Refrigerated",
|
||||||
|
confidence: 1,
|
||||||
|
source: "user",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await List.getClassification(1, 2, 55);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
item_type: "dairy",
|
||||||
|
item_group: "Milk",
|
||||||
|
zone: "Dairy & Refrigerated",
|
||||||
|
confidence: 1,
|
||||||
|
source: "user",
|
||||||
|
});
|
||||||
|
expect(pool.query).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("WHERE household_id = $1 AND store_id = $2 AND item_id = $3"),
|
||||||
|
[1, 2, 55]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("upserts classification using store-scoped conflict target", async () => {
|
||||||
|
pool.query.mockResolvedValueOnce({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
household_id: 1,
|
||||||
|
store_id: 2,
|
||||||
|
item_id: 55,
|
||||||
|
item_type: "dairy",
|
||||||
|
item_group: "Milk",
|
||||||
|
zone: "Dairy & Refrigerated",
|
||||||
|
confidence: 1,
|
||||||
|
source: "user",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await List.upsertClassification(1, 2, 55, {
|
||||||
|
item_type: "dairy",
|
||||||
|
item_group: "Milk",
|
||||||
|
zone: "Dairy & Refrigerated",
|
||||||
|
confidence: 1,
|
||||||
|
source: "user",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
household_id: 1,
|
||||||
|
store_id: 2,
|
||||||
|
item_id: 55,
|
||||||
|
item_type: "dairy",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(pool.query).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("ON CONFLICT (household_id, store_id, item_id)"),
|
||||||
|
[1, 2, 55, "dairy", "Milk", "Dairy & Refrigerated", 1, "user"]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("list.model.v2 suggestions", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
pool.query.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns catalog suggestions when a household-store catalog exists", async () => {
|
||||||
|
pool.query
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1, rows: [{ "?column?": 1 }] })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [{ item_name: "milk", sort_order: 0 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await List.getSuggestions("mi", 1, 2);
|
||||||
|
|
||||||
|
expect(result).toEqual([{ item_name: "milk", sort_order: 0 }]);
|
||||||
|
expect(pool.query).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
expect.stringContaining("FROM household_store_available_items"),
|
||||||
|
[1, 2]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to legacy suggestions when catalog is empty", async () => {
|
||||||
|
pool.query
|
||||||
|
.mockResolvedValueOnce({ rowCount: 0, rows: [] })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [{ item_name: "milk", sort_order: 1 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await List.getSuggestions("mi", 1, 2);
|
||||||
|
|
||||||
|
expect(result).toEqual([{ item_name: "milk", sort_order: 1 }]);
|
||||||
|
expect(pool.query).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
expect.stringContaining("LEFT JOIN household_lists"),
|
||||||
|
["%mi%", 1, 2]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,17 +1,24 @@
|
|||||||
jest.mock("../models/list.model.v2", () => ({
|
jest.mock("../models/list.model.v2", () => ({
|
||||||
addHistoryRecord: jest.fn(),
|
addHistoryRecord: jest.fn(),
|
||||||
addOrUpdateItem: jest.fn(),
|
addOrUpdateItem: jest.fn(),
|
||||||
|
getItemByName: jest.fn(),
|
||||||
|
upsertClassification: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("../models/household.model", () => ({
|
jest.mock("../models/household.model", () => ({
|
||||||
isHouseholdMember: jest.fn(),
|
isHouseholdMember: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
jest.mock("../models/available-item.model", () => ({
|
||||||
|
getAvailableItemImageByName: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
jest.mock("../utils/logger", () => ({
|
jest.mock("../utils/logger", () => ({
|
||||||
logError: jest.fn(),
|
logError: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const List = require("../models/list.model.v2");
|
const List = require("../models/list.model.v2");
|
||||||
|
const AvailableItems = require("../models/available-item.model");
|
||||||
const householdModel = require("../models/household.model");
|
const householdModel = require("../models/household.model");
|
||||||
const controller = require("../controllers/lists.controller.v2");
|
const controller = require("../controllers/lists.controller.v2");
|
||||||
|
|
||||||
@ -24,12 +31,17 @@ function createResponse() {
|
|||||||
|
|
||||||
describe("lists.controller.v2 addItem", () => {
|
describe("lists.controller.v2 addItem", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
List.addOrUpdateItem.mockResolvedValue({
|
List.addOrUpdateItem.mockResolvedValue({
|
||||||
listId: 42,
|
listId: 42,
|
||||||
|
itemId: 99,
|
||||||
itemName: "milk",
|
itemName: "milk",
|
||||||
isNew: true,
|
isNew: true,
|
||||||
});
|
});
|
||||||
List.addHistoryRecord.mockResolvedValue(undefined);
|
List.addHistoryRecord.mockResolvedValue(undefined);
|
||||||
|
List.getItemByName.mockResolvedValue({ id: 42, item_id: 99, item_name: "milk" });
|
||||||
|
List.upsertClassification.mockResolvedValue(undefined);
|
||||||
|
AvailableItems.getAvailableItemImageByName.mockResolvedValue(null);
|
||||||
householdModel.isHouseholdMember.mockResolvedValue(true);
|
householdModel.isHouseholdMember.mockResolvedValue(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -156,3 +168,193 @@ describe("lists.controller.v2 addItem", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("lists.controller.v2 setClassification", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
List.getItemByName.mockResolvedValue({ id: 42, item_id: 99, item_name: "milk" });
|
||||||
|
List.upsertClassification.mockResolvedValue(undefined);
|
||||||
|
List.addOrUpdateItem.mockResolvedValue({
|
||||||
|
listId: 42,
|
||||||
|
itemId: 99,
|
||||||
|
itemName: "milk",
|
||||||
|
isNew: true,
|
||||||
|
});
|
||||||
|
AvailableItems.getAvailableItemImageByName.mockResolvedValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts object classification with type, group, and zone", async () => {
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
body: {
|
||||||
|
item_name: "milk",
|
||||||
|
classification: {
|
||||||
|
item_type: "dairy",
|
||||||
|
item_group: "Milk",
|
||||||
|
zone: "Dairy & Refrigerated",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: { id: 7 },
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.setClassification(req, res);
|
||||||
|
|
||||||
|
expect(List.upsertClassification).toHaveBeenCalledWith(
|
||||||
|
"1",
|
||||||
|
"2",
|
||||||
|
99,
|
||||||
|
expect.objectContaining({
|
||||||
|
item_type: "dairy",
|
||||||
|
item_group: "Milk",
|
||||||
|
zone: "Dairy & Refrigerated",
|
||||||
|
confidence: 1.0,
|
||||||
|
source: "user",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
message: "Classification set",
|
||||||
|
classification: {
|
||||||
|
item_type: "dairy",
|
||||||
|
item_group: "Milk",
|
||||||
|
zone: "Dairy & Refrigerated",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts zone-only classification updates", async () => {
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
body: {
|
||||||
|
item_name: "milk",
|
||||||
|
classification: {
|
||||||
|
zone: "Checkout Area",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: { id: 7 },
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.setClassification(req, res);
|
||||||
|
|
||||||
|
expect(List.upsertClassification).toHaveBeenCalledWith(
|
||||||
|
"1",
|
||||||
|
"2",
|
||||||
|
99,
|
||||||
|
expect.objectContaining({
|
||||||
|
item_type: null,
|
||||||
|
item_group: null,
|
||||||
|
zone: "Checkout Area",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid item_type", async () => {
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
body: {
|
||||||
|
item_name: "milk",
|
||||||
|
classification: {
|
||||||
|
item_type: "invalid-type",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: { id: 7 },
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.setClassification(req, res);
|
||||||
|
|
||||||
|
expect(List.upsertClassification).not.toHaveBeenCalled();
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({
|
||||||
|
message: "Invalid item_type",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid item_group for selected item_type", async () => {
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
body: {
|
||||||
|
item_name: "milk",
|
||||||
|
classification: {
|
||||||
|
item_type: "dairy",
|
||||||
|
item_group: "Bread",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: { id: 7 },
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.setClassification(req, res);
|
||||||
|
|
||||||
|
expect(List.upsertClassification).not.toHaveBeenCalled();
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({
|
||||||
|
message: "Invalid item_group for selected item_type",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid zone", async () => {
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
body: {
|
||||||
|
item_name: "milk",
|
||||||
|
classification: {
|
||||||
|
zone: "Space Aisle",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: { id: 7 },
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.setClassification(req, res);
|
||||||
|
|
||||||
|
expect(List.upsertClassification).not.toHaveBeenCalled();
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({
|
||||||
|
message: "Invalid zone",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts legacy string classification values", async () => {
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
body: {
|
||||||
|
item_name: "milk",
|
||||||
|
classification: "beverages",
|
||||||
|
},
|
||||||
|
user: { id: 7 },
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.setClassification(req, res);
|
||||||
|
|
||||||
|
expect(List.upsertClassification).toHaveBeenCalledWith(
|
||||||
|
"1",
|
||||||
|
"2",
|
||||||
|
99,
|
||||||
|
expect.objectContaining({
|
||||||
|
item_type: "beverage",
|
||||||
|
item_group: null,
|
||||||
|
zone: null,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -0,0 +1,24 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS household_store_available_items (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,
|
||||||
|
store_id INTEGER NOT NULL REFERENCES stores(id) ON DELETE CASCADE,
|
||||||
|
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
custom_image BYTEA,
|
||||||
|
custom_image_mime_type VARCHAR(50),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
UNIQUE(household_id, store_id, item_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_available_items_household_store
|
||||||
|
ON household_store_available_items(household_id, store_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_available_items_item
|
||||||
|
ON household_store_available_items(item_id);
|
||||||
|
|
||||||
|
COMMENT ON TABLE household_store_available_items IS 'Curated household-store item catalogs';
|
||||||
|
COMMENT ON COLUMN household_store_available_items.custom_image IS 'Optional store-specific image override';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
Loading…
Reference in New Issue
Block a user