319 lines
10 KiB
TypeScript
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");
|
|
});
|