shoc-frontend-new/e2e/vendors/vendors.spec.ts

451 lines
18 KiB
TypeScript
Raw Normal View History

import { expect, test, type Page, type Route } from "@playwright/test";
const TEST_USER = {
token: "vendor-e2e-token",
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
email: "admin@seahavenind.com",
userRoles: "Admin",
phoneNumber: "5551234567",
fullname: "Vendor E2E Admin",
id: "1",
};
const activeVendors = [
{
Id: 1,
CompanyId: 101,
CompanyName: "Gateway Plumbing",
ContactName: "Adam Whyte",
Email: "dispatch@gateway.test",
Phone: "314-555-0198",
CompanyPhone: "314-555-0100",
Address: "1 Market St",
City: "St. Louis",
State: "MO",
Zip: "63101",
TradeSpecialties: "Plumbing, Backflow Preventers",
GoogleMapsUrl: "https://maps.google.com/gateway",
Notes: "Preferred after-hours vendor",
TotalJobs: 72,
IsActive: true,
PreferredContact: "Email",
},
{
Id: 2,
CompanyId: 102,
CompanyName: "Metro HVAC",
ContactName: "Maya Chen",
Email: "maya@metro.test",
Phone: "314-555-0112",
CompanyPhone: "314-555-0110",
Address: "2 Pine St",
City: "Clayton",
State: "MO",
Zip: "63105",
TradeSpecialties: "HVAC",
GoogleMapsUrl: "",
Notes: "",
TotalJobs: 18,
IsActive: true,
},
];
const inactiveVendors = [
{
...activeVendors[0],
Id: 3,
CompanyName: "Drain Cleaning Bros",
ContactName: "Drew Banks",
TotalJobs: 151,
IsActive: false,
},
];
interface MockState {
listUrls: string[];
createdBody?: Record<string, unknown>;
updatedBody?: Record<string, unknown>;
deletedId?: string;
}
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
}
async function captureVisual(page: Page, path: string) {
if (!process.env.VENDOR_VISUAL_CAPTURE) return;
await page.waitForTimeout(400);
await page.screenshot({ path, fullPage: true });
}
async function mockVendorApi(
page: Page,
options: {
listError?: boolean;
deactivationBlocked?: boolean;
deleteConflict?: boolean;
legacyGateway?: boolean;
} = {},
) {
const state: MockState = { listUrls: [] };
const vendorRecords = [...activeVendors, ...inactiveVendors].map((vendor) => ({ ...vendor }));
if (options.legacyGateway) {
vendorRecords[0].Phone = "555-1234 ext 9";
vendorRecords[0].GoogleMapsUrl = "http://maps.example.test/legacy";
}
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
await page.route("**/api/DropdownOptions/ByCategory/Trade*", (route) =>
fulfillJson(route, {
data: [
{ id: 1, value: "Plumbing" },
{ id: 2, value: "HVAC" },
],
}),
);
await page.route("**/api/Vendor/facets*", (route) =>
fulfillJson(route, {
data: {
companies: activeVendors.map((vendor) => ({
companyId: vendor.CompanyId,
name: vendor.CompanyName,
companyPhone: vendor.CompanyPhone,
email: vendor.Email,
address: vendor.Address,
city: vendor.City,
state: vendor.State,
zip: vendor.Zip,
googleMapsUrl: vendor.GoogleMapsUrl,
})),
trades: ["Plumbing", "Backflow Preventers", "HVAC"],
locations: [
{ city: "St. Louis", state: "MO", label: "St. Louis, MO" },
{ city: "Clayton", state: "MO", label: "Clayton, MO" },
],
jobBuckets: [
{ id: "under-50", label: "Under 50" },
{ id: "50-99", label: "50–99" },
{ id: "100-149", label: "100–149" },
{ id: "150-plus", label: "150+" },
],
},
}),
);
await page.route("**/api/Vendor/GetVendorList*", async (route) => {
const url = new URL(route.request().url());
state.listUrls.push(url.toString());
if (options.listError && url.searchParams.get("pageSize") !== "1") {
await fulfillJson(route, { message: "Vendor directory unavailable" }, 500);
return;
}
const isActive = url.searchParams.get("isActive") !== "false";
const source = vendorRecords.filter((vendor) => vendor.IsActive === isActive);
const search = (url.searchParams.get("search") ?? "").toLowerCase();
const company = url.searchParams.get("companies[0]");
const filtered = source.filter(
(vendor) =>
(!search ||
`${vendor.CompanyName} ${vendor.ContactName} ${vendor.TradeSpecialties}`
.toLowerCase()
.includes(search)) &&
(!company || vendor.CompanyName === company),
);
const isCount = url.searchParams.get("pageSize") === "1";
await fulfillJson(route, {
Data: isCount ? filtered.slice(0, 1) : filtered,
TotalCount: filtered.length,
PageNumber: 1,
PageSize: Number(url.searchParams.get("pageSize") ?? 12),
});
});
await page.route(/\/api\/vendors\/\d+\/deactivation-impact$/, async (route) => {
const vendorId = Number(route.request().url().split("/").at(-2));
await fulfillJson(route, {
data: {
vendorId,
canDeactivate: !options.deactivationBlocked,
openWorkOrders: options.deactivationBlocked
? [
{
workOrderId: 501,
workOrderNumber: "WO-501",
workOrderTitle: "Emergency boiler repair",
status: "Dispatched",
scheduledDate: "2026-07-24T14:00:00Z",
dispatchId: 801,
},
]
: [],
},
});
});
await page.route(/\/api\/vendors\/\d+$/, async (route) => {
const id = route.request().url().split("/").pop() ?? "";
if (route.request().method() === "PUT") {
state.updatedBody = route.request().postDataJSON();
const vendor = vendorRecords.find((item) => String(item.Id) === id);
if (options.legacyGateway && vendor && typeof state.updatedBody?.isActive === "boolean") {
vendor.IsActive = state.updatedBody.isActive;
}
await fulfillJson(route, {
data: { ...vendor, ...state.updatedBody, Id: Number(id) },
});
return;
}
if (route.request().method() === "DELETE") {
if (options.deleteConflict) {
await fulfillJson(
route,
{ message: "Vendor has open work orders and cannot be deactivated." },
409,
);
return;
}
state.deletedId = id;
const vendor = vendorRecords.find((item) => String(item.Id) === id);
if (options.legacyGateway && vendor) vendor.IsActive = false;
await fulfillJson(route, { status: "200" });
return;
}
const vendor = vendorRecords.find((item) => String(item.Id) === id);
await fulfillJson(route, { data: vendor });
});
await page.route(/\/api\/vendors\/?$/, async (route) => {
if (route.request().method() !== "POST") {
await route.fallback();
return;
}
state.createdBody = route.request().postDataJSON();
await fulfillJson(route, { data: { ...state.createdBody, id: 99, totalJobs: 0 } });
});
return state;
}
test.describe("Vendor directory prototype parity", () => {
test("covers status tabs, global counts, search, facets, table fields, and filtered footer", async ({
page,
}) => {
const state = await mockVendorApi(page);
await page.goto("/vendors");
await expect(page.getByRole("heading", { name: "Vendors / Technicians" })).toBeVisible();
await expect(page.getByText("3 technicians · 2 active")).toBeVisible();
await expect(page.getByRole("tab", { name: "Active (2)" })).toHaveAttribute(
"aria-selected",
"true",
);
for (const heading of [
"Company",
"Technician",
"Location",
"Trade",
"Phone",
"Email",
"Total Jobs",
"Status",
]) {
await expect(page.getByRole("columnheader", { name: heading })).toBeVisible();
}
await expect(page.getByText("Gateway Plumbing")).toBeVisible();
await expect(page.getByText("+1")).toBeVisible();
await expect(page.getByText("2 active technicians · 2 active · 1 inactive")).toBeVisible();
await captureVisual(page, "/tmp/seahaven-vendor-implemented-list.png");
await page.getByPlaceholder("Search name, company, trade...").fill("metro");
await expect(page.getByText("Metro HVAC")).toBeVisible();
await expect(page.getByText(/1 active technician · filtered/)).toBeVisible();
expect(state.listUrls.some((url) => new URL(url).searchParams.get("search") === "metro")).toBe(
true,
);
await page.getByPlaceholder("Search name, company, trade...").clear();
await page.getByRole("button", { name: "Open vendor filters" }).click();
await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible();
await captureVisual(page, "/tmp/seahaven-vendor-implemented-filters.png");
await page.getByRole("button", { name: /Company/ }).click();
await page.getByPlaceholder("All companies").click();
await page.getByRole("option", { name: /Gateway Plumbing/ }).click();
await page.getByRole("button", { name: "Apply" }).click();
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
await expect(page.getByText(/1 active technician · filtered/)).toBeVisible();
expect(
state.listUrls.some(
(url) => new URL(url).searchParams.get("companies[0]") === "Gateway Plumbing",
),
).toBe(true);
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
await expect(page.getByRole("dialog", { name: "Deactivate Vendor" })).toContainText(
"history will be preserved",
);
await page.getByRole("button", { name: "Deactivate", exact: true }).click();
await expect.poll(() => state.deletedId).toBe("1");
await page.getByRole("tab", { name: "Inactive (1)" }).click();
await expect(page.getByText("No vendors match the current filters.")).toBeVisible();
await page.getByRole("button", { name: "Clear all" }).click();
await expect(page.getByText("Drain Cleaning Bros")).toBeVisible();
await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0);
});
test("creates with company autofill, then views and edits every detail field", async ({
page,
}) => {
const state = await mockVendorApi(page);
await page.goto("/vendors");
await page.getByRole("button", { name: "+ Add Vendor" }).click();
await expect(page.getByText("Register a technician and their vendor company.")).toBeVisible();
await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png");
await expect(page.getByRole("button", { name: "Add Vendor" }).last()).toBeDisabled();
await page.getByLabel("Technician Name").fill("New Technician");
await page.getByRole("combobox", { name: "Company" }).click();
await page.getByRole("option", { name: "Gateway Plumbing" }).click();
await expect(page.getByLabel("Company Phone")).toHaveValue("314-555-0100");
await expect(page.getByRole("textbox", { name: "Email" })).toHaveValue("dispatch@gateway.test");
await expect(page.getByLabel("Address")).toHaveValue("1 Market St");
await expect(page.getByLabel("Google Maps URL")).toHaveValue("https://maps.google.com/gateway");
await page.getByRole("combobox", { name: "Add Trade" }).fill("Plumbing");
await page.getByRole("button", { name: "Add trade" }).click();
await page.getByRole("combobox", { name: "Add Trade" }).fill("HVAC");
await page.getByRole("button", { name: "Add trade" }).click();
await page.getByRole("button", { name: "Move HVAC up" }).click();
await page.getByLabel("Notes").fill("Created in browser E2E");
await page.getByRole("button", { name: "Text" }).click();
await expect(page.getByRole("button", { name: "Text" })).toHaveAttribute(
"aria-pressed",
"true",
);
await page.getByRole("button", { name: "Add Vendor" }).last().click();
await expect(page.getByRole("dialog", { name: "Add Vendor" })).toHaveCount(0);
expect(state.createdBody).toMatchObject({
companyName: "Gateway Plumbing",
companyId: 101,
contactName: "New Technician",
companyPhone: "(314) 555-0100",
preferredContact: "Text",
tradeSpecialties: "HVAC, Plumbing",
notes: "Created in browser E2E",
});
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
const detailDrawer = page.locator(".MuiDrawer-paper").last();
await expect(detailDrawer.getByRole("heading", { name: "Gateway Plumbing" })).toBeVisible();
await expect(detailDrawer.getByText("Adam Whyte")).toBeVisible();
await expect(detailDrawer.getByText("72")).toBeVisible();
await expect(
detailDrawer.getByRole("link", { name: "Open in Google Maps" }).first(),
).toHaveAttribute("target", "_blank");
await captureVisual(page, "/tmp/seahaven-vendor-implemented-detail.png");
await detailDrawer.getByRole("button", { name: "Edit" }).click();
await expect(detailDrawer.getByLabel("Total Jobs")).toHaveValue("72");
await expect(detailDrawer.getByLabel("Total Jobs")).toHaveAttribute("readonly");
await detailDrawer.getByLabel("Notes").fill("Updated in browser E2E");
await detailDrawer.getByRole("switch", { name: "Active" }).click();
await detailDrawer.getByRole("button", { name: "Save" }).click();
await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E");
expect(state.updatedBody?.isActive).toBe(false);
});
test("reactivates a legacy inactive vendor with an exact sparse update", async ({ page }) => {
const state = await mockVendorApi(page, { legacyGateway: true });
await page.goto("/vendors");
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
await page.getByRole("button", { name: "Deactivate", exact: true }).click();
await expect.poll(() => state.deletedId).toBe("1");
await page.getByRole("tab", { name: "Inactive (2)" }).click();
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
const detailDrawer = page.locator(".MuiDrawer-paper").last();
await detailDrawer.getByRole("button", { name: "Edit" }).click();
await expect(detailDrawer.getByRole("button", { name: "Save" })).toBeDisabled();
await detailDrawer.getByRole("switch", { name: "Inactive" }).click();
await detailDrawer.getByRole("button", { name: "Save" }).click();
await expect.poll(() => state.updatedBody).toEqual({ isActive: true });
await expect(detailDrawer.getByText("Active").first()).toBeVisible();
await detailDrawer.getByRole("button", { name: "Close drawer" }).click();
await page.getByRole("tab", { name: "Active (2)" }).click();
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
});
test("supports keyboard dismissal and free-text company creation", async ({ page }) => {
const state = await mockVendorApi(page);
await page.goto("/vendors");
const addButton = page.getByRole("button", { name: "+ Add Vendor" });
await addButton.focus();
await page.keyboard.press("Enter");
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toHaveCount(0);
await expect(addButton).toBeFocused();
await addButton.click();
await expect(page.getByRole("button", { name: "Add Vendor" }).last()).toBeDisabled();
await page.getByLabel("Technician Name").fill("Independent Tech");
await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC");
await page.getByRole("button", { name: "Add Vendor" }).last().click();
await expect.poll(() => state.createdBody?.companyName).toBe("Independent Vendor LLC");
expect(state.createdBody?.companyId).toBeNull();
});
test("blocks deactivation for linked work orders and preserves the vendor on a raced 409", async ({
page,
}) => {
const blockedState = await mockVendorApi(page, { deactivationBlocked: true });
await page.goto("/vendors");
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
const blockedDialog = page.getByRole("dialog", { name: "Deactivate Vendor" });
await expect(blockedDialog).toContainText("WO-501 — Emergency boiler repair");
await expect(blockedDialog.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled();
expect(blockedState.deletedId).toBeUndefined();
await blockedDialog.getByRole("button", { name: "Cancel" }).click();
await page.unrouteAll({ behavior: "wait" });
const racedState = await mockVendorApi(page, { deleteConflict: true });
await page.reload();
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
await page
.getByRole("dialog", { name: "Deactivate Vendor" })
.getByRole("button", {
name: /^Deactivate$/,
})
.click();
await expect(page.getByRole("dialog", { name: "Deactivate Vendor" })).toContainText(
/open work orders|conflict/i,
);
expect(racedState.deletedId).toBeUndefined();
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
});
test("shows API failure and keeps drawers usable at a mobile viewport", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await mockVendorApi(page, { listError: true });
await page.goto("/vendors");
await expect(page.getByRole("main").getByRole("alert")).toContainText(
/Vendor directory unavailable|failed|server error/i,
{ timeout: 15_000 },
);
await page.getByRole("button", { name: "Open vendor filters" }).click();
const filterDrawer = page.locator(".MuiDrawer-paper").filter({ hasText: "Total Jobs" });
await expect(filterDrawer).toBeVisible();
const box = await filterDrawer.boundingBox();
expect(box?.width).toBeLessThanOrEqual(391);
await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile.png");
await page.getByRole("button", { name: "Close filters" }).click();
});
});