shoc-frontend-new/e2e/vendors/vendors.spec.ts
2026-08-10 12:11:38 -03:00

605 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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} ${vendor.City} ${vendor.State}`
.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\/vendor-company-roster(?:\/\d+)?(?:\?.*)?$/, async (route) => {
const request = route.request();
const url = new URL(request.url());
const pathCompanyId = url.pathname.match(/vendor-company-roster\/(\d+)$/)?.[1];
if (request.method() === "POST") {
state.createdBody = request.postDataJSON();
await fulfillJson(route, {
...state.createdBody,
companyId: 999,
rowVersion: "rv-created",
});
return;
}
if (request.method() === "PUT" && pathCompanyId) {
state.updatedBody = request.postDataJSON();
const technicians = Array.isArray(state.updatedBody.technicians)
? (state.updatedBody.technicians as Array<Record<string, unknown>>)
: [];
for (const technician of technicians) {
const vendor = vendorRecords.find((item) => String(item.Id) === String(technician.id));
if (vendor && typeof technician.isActive === "boolean") {
vendor.IsActive = technician.isActive;
}
}
await fulfillJson(route, {
...state.updatedBody,
companyId: Number(pathCompanyId),
rowVersion: "rv-updated",
});
return;
}
const vendorId = url.searchParams.get("vendorId");
const requestedCompanyId = url.searchParams.get("companyId") ?? pathCompanyId;
const anchor = vendorId
? vendorRecords.find((vendor) => String(vendor.Id) === vendorId)
: vendorRecords.find((vendor) => String(vendor.CompanyId) === requestedCompanyId);
if (!anchor) {
await fulfillJson(route, { message: "Vendor roster not found" }, 404);
return;
}
await fulfillJson(route, {
companyId: anchor.CompanyId,
rowVersion: "rv-1",
name: anchor.CompanyName,
companyPhone: anchor.CompanyPhone,
email: anchor.Email,
address: anchor.Address,
city: anchor.City,
state: anchor.State,
zip: anchor.Zip,
googleMapsUrl: anchor.GoogleMapsUrl,
notes: anchor.Notes,
technicians: vendorRecords
.filter((vendor) => vendor.CompanyId === anchor.CompanyId)
.map((vendor) => ({
id: vendor.Id,
contactName: vendor.ContactName,
phone: vendor.Phone,
email: vendor.Email,
preferredContact: vendor.PreferredContact ?? "Phone",
tradeSpecialties: vendor.TradeSpecialties,
isActive: vendor.IsActive,
totalJobs: vendor.TotalJobs,
})),
});
});
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+\/portal-token$/, (route) =>
fulfillJson(route, {
data: {
portalUrl: "https://vendor.example.test/token",
issuedAt: "2026-07-31T12:00:00Z",
expiresAt: "2026-08-07T12:00:00Z",
},
}),
);
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,
}) => {
await page.setViewportSize({ width: 1440, height: 1000 });
const state = await mockVendorApi(page);
await page.goto("/vendors");
const heading = page.getByRole("heading", { name: "Vendors / Technicians" });
await expect(heading).toBeVisible();
await expect(heading).toHaveCSS("font-family", /Montserrat/);
await expect(heading).toHaveCSS("font-size", "30px");
await expect(heading).toHaveCSS("font-weight", "800");
await expect(heading).toHaveCSS("line-height", "33px");
await expect(heading).toHaveCSS("letter-spacing", "-0.6px");
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",
]) {
await expect(page.getByRole("columnheader", { name: heading })).toBeVisible();
}
await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible();
const activeTabBox = await page.getByRole("tab", { name: "Active (2)" }).boundingBox();
expect(activeTabBox).toMatchObject({ x: 256, y: 159.5, height: 36 });
const tableContainerBox = await page.locator("table").locator("..").boundingBox();
expect(tableContainerBox).toMatchObject({ x: 260, y: 212.5, width: 1164 });
expect(tableContainerBox?.height).toBeCloseTo(738, 0);
expect((await page.locator("table").boundingBox())?.width).toBe(1550);
expect((await page.getByRole("columnheader", { name: "Company" }).boundingBox())?.height).toBe(
47,
);
expect((await page.locator("tbody tr").first().boundingBox())?.height).toBe(52);
await expect(page.getByText("Gateway Plumbing")).toBeVisible();
await expect(page.getByText("+1")).toBeVisible();
await expect(page.getByText("2 active technicians")).toBeVisible();
await expect(page.getByText("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…").fill("clayton");
await expect(page.getByText("Metro HVAC")).toBeVisible();
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 filters" }).click();
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
await expect(page.getByText("1 technician · filtered by")).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("tab", { name: "Inactive (1)" }).click();
await expect(page.getByText("No inactive technicians found")).toBeVisible();
await page.getByRole("button", { name: "Clear all" }).click();
await expect(page.getByText("Drain Cleaning Bros")).toBeVisible();
await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible();
await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0);
});
test("adds a technician to an existing company, then views and edits the aggregate", async ({
page,
}) => {
const state = await mockVendorApi(page);
await page.goto("/vendors");
await page.getByRole("button", { name: "Add Vendor", exact: true }).click();
await expect(page.getByText("Register a vendor company and its technicians.")).toBeVisible();
await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png");
await expect(
page.getByRole("button", { name: "Add Vendor", exact: true }).last(),
).toBeEnabled();
await page.getByRole("combobox", { name: "Company" }).click();
await page.getByRole("option", { name: "Gateway Plumbing" }).click();
await expect(page.getByLabel("Company Phone (optional)")).toHaveValue("314-555-0100");
await expect(page.getByRole("textbox", { name: "Email (optional)", exact: true })).toHaveValue(
"dispatch@gateway.test",
);
await expect(page.getByLabel("Address (optional)")).toHaveValue("1 Market St");
await expect(page.getByLabel("Google Maps URL (optional)")).toHaveValue(
"https://maps.google.com/gateway",
);
await expect(page.getByLabel("Preferred Contact")).toHaveCount(0);
await page.getByRole("button", { name: "Add technician" }).click();
await page.getByLabel("Technician name (optional)").last().fill("New Technician");
const tradeInput = page.getByRole("combobox", { name: "Add Trade" }).last();
await tradeInput.fill("HVAC");
await tradeInput.press("Enter");
await tradeInput.fill("Plumbing");
await tradeInput.press("Enter");
await page.getByRole("button", { name: "Move Plumbing up" }).click();
await page.getByLabel("Notes (optional)").fill("Created in browser E2E");
await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click();
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toHaveCount(0);
expect(state.updatedBody).toMatchObject({
name: "Gateway Plumbing",
companyPhone: "(314) 555-0100",
notes: "Created in browser E2E",
rowVersion: "rv-1",
});
expect(state.updatedBody?.technicians).toEqual(
expect.arrayContaining([
expect.objectContaining({
contactName: "New Technician",
tradeSpecialties: "Plumbing, HVAC",
}),
]),
);
const submittedTechnicians = Array.isArray(state.updatedBody?.technicians)
? (state.updatedBody.technicians as Array<Record<string, unknown>>)
: [];
const newTechnician = submittedTechnicians.find(
(technician) => technician.contactName === "New Technician",
);
expect(newTechnician?.preferredContact).toBeUndefined();
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
const detailDrawer = page.locator(".MuiDrawer-paper").last();
await expect(detailDrawer.getByRole("heading", { name: "Adam Whyte" })).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(page).toHaveURL(/\/vendors$/);
await detailDrawer.getByLabel("Notes (optional)").fill("Updated in browser E2E");
await detailDrawer.getByRole("button", { name: "Save changes" }).click();
await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E");
});
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: "Edit vendor Gateway Plumbing" }).click();
await page.getByRole("switch", { name: "Active status" }).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: "Edit vendor Gateway Plumbing" }).click();
const detailDrawer = page.locator(".MuiDrawer-paper").last();
await detailDrawer.getByRole("switch", { name: "Active status" }).click();
await detailDrawer.getByRole("button", { name: "Save changes" }).click();
await expect.poll(() => state.updatedBody).toEqual({ isActive: true });
await detailDrawer.getByRole("button", { name: "Close drawer" }).click();
await page.getByRole("tab", { name: "Active (2)", exact: true }).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", exact: true });
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", exact: true }).last(),
).toBeEnabled();
await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC");
await page.getByLabel("Company Phone (optional)").fill("3145550199");
await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click();
await expect.poll(() => state.createdBody?.name).toBe("Independent Vendor LLC");
expect(state.createdBody?.technicians).toEqual([]);
});
test("closes vendor details with Escape and keeps only Edit in the footer", async ({ page }) => {
await mockVendorApi(page);
await page.goto("/vendors");
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
const detailDrawer = page.locator(".MuiDrawer-paper").last();
await expect(detailDrawer).toBeVisible();
await expect(detailDrawer.getByRole("button", { name: "Edit" })).toBeVisible();
await expect(detailDrawer.getByRole("button", { name: "Maps" })).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(page.getByRole("button", { name: "Close drawer" })).toHaveCount(0);
});
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: "Edit vendor Gateway Plumbing" }).click();
await page.getByRole("switch", { name: "Active status" }).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: "Edit vendor Gateway Plumbing" }).click();
await page.getByRole("switch", { name: "Active status" }).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");
const mainBox = await page.getByRole("main").boundingBox();
expect(mainBox).toMatchObject({ x: 0, width: 390 });
const navigation = page.getByRole("navigation", { name: "Primary navigation" });
await expect(navigation).not.toBeVisible();
const openNavigation = page.getByRole("button", { name: "Toggle navigation" });
await openNavigation.focus();
await page.keyboard.press("Enter");
await expect(navigation).toBeVisible();
await expect(page.getByRole("link", { name: "Sites" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(navigation).not.toBeVisible();
await expect(openNavigation).toBeFocused();
await expect(page.getByRole("main").getByRole("alert")).toContainText(
/Vendor directory unavailable|failed|server error/i,
{ timeout: 15_000 },
);
await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile-list.png");
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();
});
test("keeps protected shell destinations available and marks their selected state", async ({
page,
}) => {
await mockVendorApi(page);
for (const destination of [
{ path: "/workorders", label: "Work Orders" },
{ path: "/accounts", label: "Accounts" },
{ path: "/locations", label: "Sites" },
{ path: "/notifications", label: "Notifications" },
]) {
await page.goto(destination.path);
await expect(page.getByRole("link", { name: destination.label })).toHaveAttribute(
"aria-current",
"page",
);
}
});
});