chore: harden reliability checks #2

Merged
nalalangan merged 67 commits from main-new into main 2026-05-25 14:28:32 -09:00
9 changed files with 281 additions and 195 deletions
Showing only changes of commit 36277a9e67 - Show all commits

View File

@ -276,12 +276,16 @@ exports.deleteAvailableItem = async (req, res) => {
return sendError(res, 400, "Item ID must be a positive integer"); return sendError(res, 400, "Item ID must be a positive integer");
} }
const deleted = await AvailableItems.deleteAvailableItem(householdId, storeId, itemId); const [deletedCatalogEntry, deletedClassification] = await Promise.all([
if (!deleted) { AvailableItems.deleteAvailableItem(householdId, storeId, itemId),
return sendError(res, 404, "Available item not found"); List.deleteClassification(householdId, storeId, itemId),
]);
if (!deletedCatalogEntry && !deletedClassification) {
return sendError(res, 404, "Managed item settings not found");
} }
res.json({ message: "Available item removed" }); res.json({ message: "Store item settings cleared" });
} catch (error) { } catch (error) {
if (isCatalogTableMissing(error)) { if (isCatalogTableMissing(error)) {
return sendError( return sendError(

View File

@ -31,25 +31,52 @@ async function findOrCreateItem(itemName) {
async function getAvailableItemRecord(householdId, storeId, itemId) { async function getAvailableItemRecord(householdId, storeId, itemId) {
const result = await pool.query( const result = await pool.query(
`SELECT `WITH manageable_items AS (
hsai.item_id, SELECT DISTINCT hl.item_id
i.name AS item_name, FROM household_lists hl
ENCODE(hsai.custom_image, 'base64') AS item_image, WHERE hl.household_id = $1
hsai.custom_image_mime_type AS image_mime_type, AND hl.store_id = $2
hic.item_type, UNION
hic.item_group, SELECT hsai.item_id
hic.zone, FROM household_store_available_items hsai
hsai.created_at, WHERE hsai.household_id = $1
hsai.updated_at AND hsai.store_id = $2
FROM household_store_available_items hsai ),
JOIN items i ON i.id = hsai.item_id latest_list_items AS (
LEFT JOIN household_item_classifications hic SELECT DISTINCT ON (hl.item_id)
ON hic.household_id = hsai.household_id hl.item_id,
AND hic.store_id = hsai.store_id hl.custom_image,
AND hic.item_id = hsai.item_id hl.custom_image_mime_type,
WHERE hsai.household_id = $1 hl.modified_on,
AND hsai.store_id = $2 hl.id
AND hsai.item_id = $3`, FROM household_lists hl
WHERE hl.household_id = $1
AND hl.store_id = $2
ORDER BY hl.item_id, hl.modified_on DESC NULLS LAST, hl.id DESC
)
SELECT
mi.item_id,
i.name AS item_name,
ENCODE(COALESCE(hsai.custom_image, lli.custom_image), 'base64') AS item_image,
COALESCE(hsai.custom_image_mime_type, lli.custom_image_mime_type) AS image_mime_type,
hic.item_type,
hic.item_group,
hic.zone,
hsai.created_at,
hsai.updated_at,
(hsai.item_id IS NOT NULL OR hic.item_id IS NOT NULL) AS has_managed_settings
FROM manageable_items mi
JOIN items i ON i.id = mi.item_id
LEFT JOIN latest_list_items lli ON lli.item_id = mi.item_id
LEFT JOIN household_store_available_items hsai
ON hsai.household_id = $1
AND hsai.store_id = $2
AND hsai.item_id = mi.item_id
LEFT JOIN household_item_classifications hic
ON hic.household_id = $1
AND hic.store_id = $2
AND hic.item_id = mi.item_id
WHERE mi.item_id = $3`,
[householdId, storeId, itemId] [householdId, storeId, itemId]
); );
@ -63,31 +90,58 @@ exports.listAvailableItems = async (householdId, storeId, query = "") => {
if (trimmedQuery) { if (trimmedQuery) {
values.push(`%${trimmedQuery}%`); values.push(`%${trimmedQuery}%`);
filterClause = "AND i.name ILIKE $3"; filterClause = "WHERE i.name ILIKE $3";
} }
const result = await pool.query( const result = await pool.query(
`SELECT `WITH manageable_items AS (
hsai.item_id, SELECT DISTINCT hl.item_id
i.name AS item_name, FROM household_lists hl
ENCODE(hsai.custom_image, 'base64') AS item_image, WHERE hl.household_id = $1
hsai.custom_image_mime_type AS image_mime_type, AND hl.store_id = $2
hic.item_type, UNION
hic.item_group, SELECT hsai.item_id
hic.zone, FROM household_store_available_items hsai
hsai.created_at, WHERE hsai.household_id = $1
hsai.updated_at AND hsai.store_id = $2
FROM household_store_available_items hsai ),
JOIN items i ON i.id = hsai.item_id latest_list_items AS (
LEFT JOIN household_item_classifications hic SELECT DISTINCT ON (hl.item_id)
ON hic.household_id = hsai.household_id hl.item_id,
AND hic.store_id = hsai.store_id hl.custom_image,
AND hic.item_id = hsai.item_id hl.custom_image_mime_type,
WHERE hsai.household_id = $1 hl.modified_on,
AND hsai.store_id = $2 hl.id
${filterClause} FROM household_lists hl
ORDER BY i.name ASC WHERE hl.household_id = $1
LIMIT 100`, AND hl.store_id = $2
ORDER BY hl.item_id, hl.modified_on DESC NULLS LAST, hl.id DESC
)
SELECT
mi.item_id,
i.name AS item_name,
ENCODE(COALESCE(hsai.custom_image, lli.custom_image), 'base64') AS item_image,
COALESCE(hsai.custom_image_mime_type, lli.custom_image_mime_type) AS image_mime_type,
hic.item_type,
hic.item_group,
hic.zone,
hsai.created_at,
hsai.updated_at,
(hsai.item_id IS NOT NULL OR hic.item_id IS NOT NULL) AS has_managed_settings
FROM manageable_items mi
JOIN items i ON i.id = mi.item_id
LEFT JOIN latest_list_items lli ON lli.item_id = mi.item_id
LEFT JOIN household_store_available_items hsai
ON hsai.household_id = $1
AND hsai.store_id = $2
AND hsai.item_id = mi.item_id
LEFT JOIN household_item_classifications hic
ON hic.household_id = $1
AND hic.store_id = $2
AND hic.item_id = mi.item_id
${filterClause}
ORDER BY i.name ASC
LIMIT 100`,
values values
); );
@ -143,17 +197,55 @@ exports.updateAvailableItem = async (householdId, storeId, itemId, updates = {})
removeImage = false, removeImage = false,
} = updates; } = updates;
const existing = await pool.query(
`SELECT item_id
FROM household_store_available_items
WHERE household_id = $1
AND store_id = $2
AND item_id = $3`,
[householdId, storeId, itemId]
);
const assignments = ["updated_at = NOW()"]; const assignments = ["updated_at = NOW()"];
const values = [householdId, storeId, itemId]; const values = [householdId, storeId, itemId];
let parameterIndex = values.length; let parameterIndex = values.length;
let targetItemId = itemId;
if (itemName !== undefined && String(itemName).trim() !== "") { if (itemName !== undefined && String(itemName).trim() !== "") {
const { itemId: nextItemId } = await findOrCreateItem(itemName); const { itemId: nextItemId } = await findOrCreateItem(itemName);
targetItemId = nextItemId;
parameterIndex += 1; parameterIndex += 1;
assignments.push(`item_id = $${parameterIndex}`); assignments.push(`item_id = $${parameterIndex}`);
values.push(nextItemId); values.push(nextItemId);
} }
if (existing.rowCount === 0) {
if (imageBuffer && mimeType) {
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())
ON CONFLICT (household_id, store_id, item_id)
DO UPDATE SET
custom_image = EXCLUDED.custom_image,
custom_image_mime_type = EXCLUDED.custom_image_mime_type,
updated_at = NOW()`,
[householdId, storeId, targetItemId, imageBuffer, mimeType]
);
} else if (targetItemId !== itemId) {
await pool.query(
`INSERT INTO household_store_available_items
(household_id, store_id, item_id, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (household_id, store_id, item_id)
DO UPDATE SET updated_at = NOW()`,
[householdId, storeId, targetItemId]
);
}
return getAvailableItemRecord(householdId, storeId, targetItemId);
}
if (removeImage) { if (removeImage) {
assignments.push("custom_image = NULL", "custom_image_mime_type = NULL"); assignments.push("custom_image = NULL", "custom_image_mime_type = NULL");
} else if (imageBuffer && mimeType) { } else if (imageBuffer && mimeType) {

View File

@ -377,13 +377,14 @@ exports.upsertClassification = async (householdId, storeId, itemId, classificati
* @param {number} itemId - Item ID * @param {number} itemId - Item ID
*/ */
exports.deleteClassification = async (householdId, storeId, itemId) => { exports.deleteClassification = async (householdId, storeId, itemId) => {
await pool.query( const result = await pool.query(
`DELETE FROM household_item_classifications `DELETE FROM household_item_classifications
WHERE household_id = $1 WHERE household_id = $1
AND store_id = $2 AND store_id = $2
AND item_id = $3`, AND item_id = $3`,
[householdId, storeId, itemId] [householdId, storeId, itemId]
); );
return result.rowCount > 0;
}; };
/** /**

View File

@ -10,6 +10,38 @@ describe("available-item.model", () => {
pool.query.mockReset(); pool.query.mockReset();
}); });
test("lists manageable items from household/store history even without stored overrides", async () => {
pool.query.mockResolvedValueOnce({
rowCount: 1,
rows: [
{
item_id: 55,
item_name: "milk",
item_image: null,
image_mime_type: null,
item_type: null,
item_group: null,
zone: null,
has_managed_settings: false,
},
],
});
const result = await AvailableItems.listAvailableItems(1, 2);
expect(result).toEqual([
expect.objectContaining({
item_id: 55,
item_name: "milk",
has_managed_settings: false,
}),
]);
expect(pool.query).toHaveBeenCalledWith(
expect.stringContaining("WITH manageable_items AS"),
[1, 2]
);
});
test("creates an available item using an existing catalog item", async () => { test("creates an available item using an existing catalog item", async () => {
pool.query pool.query
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55, name: "milk" }] }) .mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55, name: "milk" }] })
@ -72,10 +104,17 @@ describe("available-item.model", () => {
test("updates available item images and returns refreshed data", async () => { test("updates available item images and returns refreshed data", async () => {
const imageBuffer = Buffer.from("abc"); const imageBuffer = Buffer.from("abc");
pool.query pool.query
.mockResolvedValueOnce({ rowCount: 1, rows: [{ item_id: 55 }] })
.mockResolvedValueOnce({ rowCount: 1, rows: [{ item_id: 55 }] }) .mockResolvedValueOnce({ rowCount: 1, rows: [{ item_id: 55 }] })
.mockResolvedValueOnce({ .mockResolvedValueOnce({
rowCount: 1, rowCount: 1,
rows: [{ item_id: 55, item_name: "milk", item_image: "YWJj", image_mime_type: "image/jpeg" }], rows: [{
item_id: 55,
item_name: "milk",
item_image: "YWJj",
image_mime_type: "image/jpeg",
has_managed_settings: true,
}],
}); });
const result = await AvailableItems.updateAvailableItem(1, 2, 55, { const result = await AvailableItems.updateAvailableItem(1, 2, 55, {
@ -85,7 +124,7 @@ describe("available-item.model", () => {
expect(result).toEqual(expect.objectContaining({ item_id: 55, image_mime_type: "image/jpeg" })); expect(result).toEqual(expect.objectContaining({ item_id: 55, image_mime_type: "image/jpeg" }));
expect(pool.query).toHaveBeenNthCalledWith( expect(pool.query).toHaveBeenNthCalledWith(
1, 2,
expect.stringContaining("UPDATE household_store_available_items"), expect.stringContaining("UPDATE household_store_available_items"),
[1, 2, 55, imageBuffer, "image/jpeg"] [1, 2, 55, imageBuffer, "image/jpeg"]
); );

View File

@ -43,7 +43,7 @@ describe("available-items.controller", () => {
AvailableItems.importCurrentListItems.mockResolvedValue(2); AvailableItems.importCurrentListItems.mockResolvedValue(2);
AvailableItems.listAvailableItems.mockResolvedValue([]); AvailableItems.listAvailableItems.mockResolvedValue([]);
List.upsertClassification.mockResolvedValue(undefined); List.upsertClassification.mockResolvedValue(undefined);
List.deleteClassification.mockResolvedValue(undefined); List.deleteClassification.mockResolvedValue(false);
}); });
test("creates an available item and persists classification metadata", async () => { test("creates an available item and persists classification metadata", async () => {
@ -135,6 +135,22 @@ describe("available-items.controller", () => {
); );
}); });
test("clears managed settings without removing the underlying item", async () => {
const req = {
params: { householdId: "1", storeId: "2", itemId: "99" },
};
const res = createResponse();
AvailableItems.deleteAvailableItem.mockResolvedValueOnce(false);
List.deleteClassification.mockResolvedValueOnce(true);
await controller.deleteAvailableItem(req, res);
expect(AvailableItems.deleteAvailableItem).toHaveBeenCalledWith("1", "2", 99);
expect(List.deleteClassification).toHaveBeenCalledWith("1", "2", 99);
expect(res.json).toHaveBeenCalledWith({ message: "Store item settings cleared" });
});
test("returns an empty catalog payload when the available items table is missing", async () => { test("returns an empty catalog payload when the available items table is missing", async () => {
const req = { const req = {
params: { householdId: "1", storeId: "2" }, params: { householdId: "1", storeId: "2" },

View File

@ -92,7 +92,7 @@ export default function ManageStores() {
<section className="manage-section"> <section className="manage-section">
<h2>Your Stores ({householdStores.length})</h2> <h2>Your Stores ({householdStores.length})</h2>
<p className="manage-stores-help"> <p className="manage-stores-help">
Available item management lives inside each store card below. Item management lives inside each store card below for items already used in that household/store.
</p> </p>
{!isAdmin && ( {!isAdmin && (
<p className="manage-stores-note"> <p className="manage-stores-note">

View File

@ -1,9 +1,7 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { import {
createAvailableItem,
deleteAvailableItem, deleteAvailableItem,
getAvailableItems, getAvailableItems,
importCurrentAvailableItems,
updateAvailableItem, updateAvailableItem,
} from "../../api/availableItems"; } from "../../api/availableItems";
import useActionToast from "../../hooks/useActionToast"; import useActionToast from "../../hooks/useActionToast";
@ -61,28 +59,6 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
loadItems(query); loadItems(query);
}, [expanded, query, loadItems]); }, [expanded, query, loadItems]);
const handleCreate = async (payload) => {
if (!catalogReady) {
toast.info(
"Store item catalog unavailable",
catalogMessage || "Store item catalog is unavailable until the latest database migration is applied."
);
return;
}
try {
await createAvailableItem(householdId, store.id, payload);
toast.success("Added store item", `Added ${payload.itemName} to ${store.name}`);
setShowEditor(false);
setEditorItem(null);
await loadItems(query);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to add available item");
toast.error("Add store item failed", `Add store item failed: ${message}`);
throw error;
}
};
const handleUpdate = async (payload) => { const handleUpdate = async (payload) => {
if (!catalogReady) { if (!catalogReady) {
toast.info( toast.info(
@ -94,7 +70,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
try { try {
await updateAvailableItem(householdId, store.id, editorItem.item_id, payload); await updateAvailableItem(householdId, store.id, editorItem.item_id, payload);
toast.success("Updated store item", `Updated ${payload.itemName} for ${store.name}`); toast.success("Updated store item", `Updated ${editorItem.item_name} for ${store.name}`);
setShowEditor(false); setShowEditor(false);
setEditorItem(null); setEditorItem(null);
await loadItems(query); await loadItems(query);
@ -120,36 +96,11 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
try { try {
await deleteAvailableItem(householdId, store.id, item.item_id); await deleteAvailableItem(householdId, store.id, item.item_id);
toast.success("Removed store item", `Removed ${item.item_name} from ${store.name}`); toast.success("Cleared store item settings", `Cleared settings for ${item.item_name} in ${store.name}`);
await loadItems(query); await loadItems(query);
} catch (error) { } catch (error) {
const message = getApiErrorMessage(error, "Failed to remove available item"); const message = getApiErrorMessage(error, "Failed to clear store item settings");
toast.error("Remove store item failed", `Remove store item failed: ${message}`); toast.error("Clear store item settings failed", `Clear store item settings failed: ${message}`);
}
};
const handleImport = async () => {
if (!catalogReady) {
toast.info(
"Store item catalog unavailable",
catalogMessage || "Store item catalog is unavailable until the latest database migration is applied."
);
return;
}
try {
const response = await importCurrentAvailableItems(householdId, store.id);
const importedCount = response.data.imported_count || 0;
toast.success(
"Imported current list items",
importedCount > 0
? `Imported ${importedCount} current list items into ${store.name}`
: `No current list items to import for ${store.name}`
);
await loadItems(query);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to import current list items");
toast.error("Import store items failed", `Import store items failed: ${message}`);
} }
}; };
@ -162,7 +113,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
<div className="store-available-items-header"> <div className="store-available-items-header">
<div> <div>
<h4>Store Item Catalog</h4> <h4>Store Item Catalog</h4>
<p>Manage the available item list for {store.name}.</p> <p>Manage settings for items already used in {store.name} for this household.</p>
</div> </div>
<button <button
type="button" type="button"
@ -185,30 +136,9 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
className="store-available-items-search" className="store-available-items-search"
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
placeholder="Search store items" placeholder="Search household/store items"
disabled={!catalogReady} disabled={!catalogReady}
/> />
<div className="store-available-items-toolbar-actions">
<button
type="button"
className="btn-secondary btn-small"
onClick={handleImport}
disabled={!catalogReady}
>
Import Current List
</button>
<button
type="button"
className="btn-primary btn-small"
onClick={() => {
setEditorItem(null);
setShowEditor(true);
}}
disabled={!catalogReady}
>
Add Item
</button>
</div>
</div> </div>
{!catalogReady ? ( {!catalogReady ? (
@ -216,7 +146,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
) : loading ? ( ) : loading ? (
<p className="empty-message">Loading store items...</p> <p className="empty-message">Loading store items...</p>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<p className="empty-message">No available items saved for this store yet.</p> <p className="empty-message">No household items found for this store yet.</p>
) : ( ) : (
<div className="store-available-items-list"> <div className="store-available-items-list">
{items.map((item) => { {items.map((item) => {
@ -248,13 +178,15 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
> >
Edit Edit
</button> </button>
<button {item.has_managed_settings ? (
type="button" <button
className="btn-danger btn-small" type="button"
onClick={() => handleDelete(item)} className="btn-danger btn-small"
> onClick={() => handleDelete(item)}
Remove >
</button> Clear Settings
</button>
) : null}
</div> </div>
</div> </div>
); );
@ -271,7 +203,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
setShowEditor(false); setShowEditor(false);
setEditorItem(null); setEditorItem(null);
}} }}
onSave={editorItem ? handleUpdate : handleCreate} onSave={handleUpdate}
/> />
</div> </div>
); );

View File

@ -115,6 +115,7 @@ export default function AvailableItemEditorModal({ isOpen, item = null, onCancel
value={itemName} value={itemName}
onChange={(event) => setItemName(event.target.value)} onChange={(event) => setItemName(event.target.value)}
placeholder="Enter item name" placeholder="Enter item name"
disabled={Boolean(item)}
/> />
</div> </div>
@ -156,7 +157,7 @@ export default function AvailableItemEditorModal({ isOpen, item = null, onCancel
onClick={handleSubmit} onClick={handleSubmit}
disabled={saving} disabled={saving}
> >
{saving ? "Saving..." : item ? "Save Changes" : "Add Item"} {saving ? "Saving..." : "Save Changes"}
</button> </button>
</div> </div>
</div> </div>

View File

@ -45,7 +45,7 @@ async function mockHouseholdAndStoreShell(page: import("@playwright/test").Page)
}); });
} }
test("manage stores lets admins import and curate available items", async ({ page }) => { test("manage stores lets admins edit settings for existing household/store items", async ({ page }) => {
await seedAuthStorage(page); await seedAuthStorage(page);
await mockConfig(page); await mockConfig(page);
await mockHouseholdAndStoreShell(page); await mockHouseholdAndStoreShell(page);
@ -59,6 +59,17 @@ test("manage stores lets admins import and curate available items", async ({ pag
item_type: "dairy", item_type: "dairy",
item_group: "Milk", item_group: "Milk",
zone: "Dairy & Refrigerated", zone: "Dairy & Refrigerated",
has_managed_settings: true,
},
{
item_id: 777,
item_name: "apples",
item_image: null,
image_mime_type: null,
item_type: null,
item_group: null,
zone: null,
has_managed_settings: false,
}, },
]; ];
@ -70,30 +81,6 @@ test("manage stores lets admins import and curate available items", async ({ pag
}); });
}); });
await page.route("**/households/1/stores/10/available-items/import-current", async (route) => {
availableItems = [
...availableItems,
{
item_id: 777,
item_name: "granola",
item_image: null,
image_mime_type: null,
item_type: null,
item_group: null,
zone: null,
},
];
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
message: "Imported current list items",
imported_count: 1,
}),
});
});
await page.route("**/households/1/stores/10/available-items*", async (route) => { await page.route("**/households/1/stores/10/available-items*", async (route) => {
const request = route.request(); const request = route.request();
const url = new URL(request.url()); const url = new URL(request.url());
@ -104,30 +91,33 @@ test("manage stores lets admins import and curate available items", async ({ pag
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ items: filteredItems }), body: JSON.stringify({ items: filteredItems, catalog_ready: true }),
}); });
return; return;
} }
if (request.method() === "POST") { await route.fulfill({ status: 500 });
availableItems = [ });
...availableItems,
{ await page.route("**/households/1/stores/10/available-items/777", async (route) => {
item_id: 888, if (route.request().method() === "PATCH") {
item_name: "trail mix", availableItems = availableItems.map((item) =>
item_image: null, item.item_id === 777
image_mime_type: null, ? {
item_type: "snack", ...item,
item_group: "Trail Mix", item_type: "produce",
zone: "Snacks & Candy", item_group: "Fruits",
}, zone: "Produce & Fresh Vegetables",
]; has_managed_settings: true,
}
: item
);
await route.fulfill({ await route.fulfill({
status: 201, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ body: JSON.stringify({
message: "Available item added", message: "Available item updated",
item: availableItems[availableItems.length - 1], item: availableItems.find((item) => item.item_id === 777),
}), }),
}); });
return; return;
@ -136,13 +126,23 @@ test("manage stores lets admins import and curate available items", async ({ pag
await route.fulfill({ status: 500 }); await route.fulfill({ status: 500 });
}); });
await page.route("**/households/1/stores/10/available-items/888", async (route) => { await page.route("**/households/1/stores/10/available-items/501", async (route) => {
if (route.request().method() === "DELETE") { if (route.request().method() === "DELETE") {
availableItems = availableItems.filter((item) => item.item_id !== 888); availableItems = availableItems.map((item) =>
item.item_id === 501
? {
...item,
item_type: null,
item_group: null,
zone: null,
has_managed_settings: false,
}
: item
);
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ message: "Available item removed" }), body: JSON.stringify({ message: "Store item settings cleared" }),
}); });
return; return;
} }
@ -157,26 +157,27 @@ test("manage stores lets admins import and curate available items", async ({ pag
await expect(storeCard.getByText("Store Item Catalog")).toBeVisible(); await expect(storeCard.getByText("Store Item Catalog")).toBeVisible();
await expect(storeCard.getByText("milk")).toBeVisible(); await expect(storeCard.getByText("milk")).toBeVisible();
await expect(storeCard.getByText("apples")).toBeVisible();
await expect(storeCard.getByRole("button", { name: "Add Item" })).toHaveCount(0);
await expect(storeCard.getByRole("button", { name: "Import Current List" })).toHaveCount(0);
await storeCard.getByRole("button", { name: "Import Current List" }).click(); await storeCard.locator(".store-available-items-card").filter({ hasText: "apples" }).getByRole("button", { name: "Edit" }).click();
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Imported current list items");
await expect(storeCard.getByText("granola")).toBeVisible();
await storeCard.getByRole("button", { name: "Add Item" }).click();
const editorModal = page.locator(".available-item-editor-modal"); const editorModal = page.locator(".available-item-editor-modal");
await expect(editorModal).toBeVisible(); await expect(editorModal).toBeVisible();
await editorModal.getByLabel("Item Name").fill("trail mix"); await expect(editorModal.getByLabel("Item Name")).toBeDisabled();
await editorModal.locator(".available-item-editor-select").nth(0).selectOption("snack"); await editorModal.locator(".available-item-editor-select").nth(0).selectOption("produce");
await editorModal.locator(".available-item-editor-select").nth(1).selectOption("Trail Mix"); await editorModal.locator(".available-item-editor-select").nth(1).selectOption("Fruits");
await editorModal.locator(".available-item-editor-select").nth(2).selectOption("Snacks & Candy"); await editorModal.locator(".available-item-editor-select").nth(2).selectOption("Produce & Fresh Vegetables");
await editorModal.getByRole("button", { name: "Add Item" }).click(); await editorModal.getByRole("button", { name: "Save Changes" }).click();
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Added store item"); await expect(page.locator(".action-toast.action-toast-success")).toContainText("Updated store item");
await expect(storeCard.getByText("trail mix")).toBeVisible(); await expect(storeCard.getByText("produce | Fruits | Produce & Fresh Vegetables")).toBeVisible();
page.once("dialog", (dialog) => dialog.accept()); page.once("dialog", (dialog) => dialog.accept());
await storeCard.locator(".store-available-items-card").filter({ hasText: "trail mix" }).getByRole("button", { name: "Remove" }).click(); await storeCard.locator(".store-available-items-card").filter({ hasText: "milk" }).getByRole("button", { name: "Clear Settings" }).click();
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Removed store item"); await expect(page.locator(".action-toast.action-toast-success")).toContainText("Cleared store item settings");
await expect(storeCard.locator(".store-available-items-card").filter({ hasText: "milk" }).getByText("No store defaults set")).toBeVisible();
await expect(storeCard.locator(".store-available-items-card").filter({ hasText: "milk" }).getByRole("button", { name: "Clear Settings" })).toHaveCount(0);
}); });
test("grocery page remains unchanged and does not show a store items picker", async ({ page }) => { test("grocery page remains unchanged and does not show a store items picker", async ({ page }) => {