grocery-app/frontend/tests/classification-details.spec.ts

319 lines
10 KiB
TypeScript

import { expect, test } from "@playwright/test";
function seedAuthStorage(page: import("@playwright/test").Page) {
return page.addInitScript(() => {
localStorage.setItem("token", "test-token");
localStorage.setItem("userId", "1");
localStorage.setItem("role", "admin");
localStorage.setItem("username", "classification-user");
});
}
async function mockConfig(page: import("@playwright/test").Page) {
await page.route("**/config", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
maxFileSizeMB: 20,
maxImageDimension: 800,
imageQuality: 85,
}),
});
});
}
async function setupGroceryListRoutes(page: import("@playwright/test").Page) {
let currentItem: {
id: number;
item_id: number;
item_name: string;
quantity: number;
bought: boolean;
item_image: string | null;
image_mime_type: string | null;
added_by_users: string[];
last_added_on: string;
item_type: string | null;
item_group: string | null;
zone: string | null;
} | null = null;
let currentClassification: {
item_type: string | null;
item_group: string | null;
zone: string | null;
} | null = null;
let classificationRequestMode: "success" | "error" = "success";
await page.route("**/households", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{ id: 1, name: "Classification House", role: "admin", invite_code: "ABCD1234" },
]),
});
});
await page.route("**/stores/household/1", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{ id: 10, name: "Costco", location: "Warehouse", is_default: true },
]),
});
});
await page.route("**/households/1/members", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{ id: 1, username: "owner", name: "Owner User", display_name: "Owner User", role: "owner" },
]),
});
});
await page.route("**/households/1/stores/10/list/recent", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
});
await page.route("**/households/1/stores/10/list/suggestions**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
});
await page.route("**/households/1/stores/10/list/classification**", async (route) => {
const request = route.request();
if (request.method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ classification: currentClassification }),
});
return;
}
const body = request.postDataJSON() as {
classification?: string | { item_type?: string | null; item_group?: string | null; zone?: string | null };
};
if (classificationRequestMode === "error") {
await route.fulfill({
status: 400,
contentType: "application/json",
body: JSON.stringify({
error: { message: "Invalid zone" },
}),
});
return;
}
const payload = typeof body.classification === "string"
? { item_type: body.classification, item_group: null, zone: null }
: {
item_type: body.classification?.item_type ?? null,
item_group: body.classification?.item_group ?? null,
zone: body.classification?.zone ?? null,
};
currentClassification = payload;
if (currentItem) {
currentItem = {
...currentItem,
item_type: payload.item_type,
item_group: payload.item_group,
zone: payload.zone,
};
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
message: "Classification set",
classification: payload,
}),
});
});
await page.route("**/households/1/stores/10/list/item**", async (route) => {
const request = route.request();
if (request.method() === "PUT") {
const body = request.postDataJSON() as { item_name?: string; quantity?: number };
if (currentItem) {
currentItem = {
...currentItem,
item_name: String(body.item_name || currentItem.item_name).toLowerCase(),
quantity: Number(body.quantity || currentItem.quantity),
};
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
message: "Item updated",
item: {
id: currentItem?.id || 201,
item_name: currentItem?.item_name || "yogurt",
quantity: currentItem?.quantity || 1,
},
}),
});
return;
}
const url = new URL(request.url());
const itemName = (url.searchParams.get("item_name") || "").toLowerCase();
const itemMatches = currentItem && currentItem.item_name === itemName;
await route.fulfill({
status: itemMatches ? 200 : 404,
contentType: "application/json",
body: JSON.stringify(itemMatches ? currentItem : { message: "Item not found" }),
});
});
await page.route("**/households/1/stores/10/list/add", async (route) => {
currentItem = {
id: 201,
item_id: 501,
item_name: "yogurt",
quantity: 1,
bought: false,
item_image: null,
image_mime_type: null,
added_by_users: ["Owner User"],
last_added_on: "2026-03-28T12:00:00.000Z",
item_type: currentClassification?.item_type ?? null,
item_group: currentClassification?.item_group ?? null,
zone: currentClassification?.zone ?? null,
};
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
message: "Item added",
item: {
id: 201,
item_name: "yogurt",
quantity: 1,
bought: false,
},
}),
});
});
await page.route("**/households/1/stores/10/list", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: currentItem ? [currentItem] : [],
}),
});
});
return {
setClassificationRequestMode(mode: "success" | "error") {
classificationRequestMode = mode;
},
};
}
async function openEditModal(itemRow: ReturnType<import("@playwright/test").Page["locator"]>, page: import("@playwright/test").Page) {
await itemRow.dispatchEvent("mousedown");
await page.waitForTimeout(650);
await itemRow.dispatchEvent("mouseup");
await expect(page.locator(".edit-modal-content")).toBeVisible();
}
test("add-details modal validates with toasts and persists classification details", async ({ page }) => {
await seedAuthStorage(page);
await mockConfig(page);
await setupGroceryListRoutes(page);
let dialogSeen = false;
page.on("dialog", async (dialog) => {
dialogSeen = true;
await dialog.dismiss();
});
await page.goto("/");
await page.getByPlaceholder("Enter item name").fill("yogurt");
await page.getByRole("button", { name: "Create + Add" }).click();
const addDetailsModal = page.locator(".add-item-details-modal");
await expect(addDetailsModal).toBeVisible();
await addDetailsModal.locator(".add-item-details-select").nth(0).selectOption("dairy");
await addDetailsModal.getByRole("button", { name: "Add Item" }).click();
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Select an item group");
expect(dialogSeen).toBe(false);
await addDetailsModal.locator(".add-item-details-select").nth(1).selectOption("Milk");
await addDetailsModal.locator(".add-item-details-select").nth(2).selectOption("Dairy & Refrigerated");
await addDetailsModal.getByRole("button", { name: "Add Item" }).click();
const yogurtRow = page.locator(".glist-li").filter({ hasText: "yogurt" });
await expect(yogurtRow).toBeVisible();
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Added item");
await openEditModal(yogurtRow, page);
const editModal = page.locator(".edit-modal-content");
await expect(editModal.locator(".edit-modal-select").nth(0)).toHaveValue("dairy");
await expect(editModal.locator(".edit-modal-select").nth(1)).toHaveValue("Milk");
await expect(editModal.locator(".edit-modal-select").nth(2)).toHaveValue("Dairy & Refrigerated");
});
test("edit modal supports zone-only updates and shows API error toasts", async ({ page }) => {
await seedAuthStorage(page);
await mockConfig(page);
const routes = await setupGroceryListRoutes(page);
await page.goto("/");
await page.getByPlaceholder("Enter item name").fill("yogurt");
await page.getByRole("button", { name: "Create + Add" }).click();
await page.locator(".add-item-details-modal").getByRole("button", { name: "Skip All" }).click();
const yogurtRow = page.locator(".glist-li").filter({ hasText: "yogurt" });
await expect(yogurtRow).toBeVisible();
await openEditModal(yogurtRow, page);
let editModal = page.locator(".edit-modal-content");
await editModal.locator(".edit-modal-select").nth(0).selectOption("");
await editModal.locator(".edit-modal-select").nth(1).selectOption("Checkout Area");
await editModal.getByRole("button", { name: "Save Changes" }).click();
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Updated item");
await expect(editModal).toBeHidden();
await openEditModal(yogurtRow, page);
editModal = page.locator(".edit-modal-content");
await expect(editModal.locator(".edit-modal-select").nth(0)).toHaveValue("");
await expect(editModal.locator(".edit-modal-select").nth(1)).toHaveValue("Checkout Area");
routes.setClassificationRequestMode("error");
await editModal.locator(".edit-modal-select").nth(1).selectOption("Bakery");
await editModal.getByRole("button", { name: "Save Changes" }).click();
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Invalid zone");
});