mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-02 12:03:12 +00:00
567 lines
20 KiB
TypeScript
567 lines
20 KiB
TypeScript
import { expect, test, type Page, type Route } from "@playwright/test";
|
|
|
|
const TEST_USER = {
|
|
token: "vendor-operations-e2e-token",
|
|
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
|
email: "admin@seahavenind.com",
|
|
userRoles: "Admin",
|
|
phoneNumber: "5551234567",
|
|
fullname: "Vendor Operations Admin",
|
|
id: "1",
|
|
};
|
|
|
|
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
|
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
|
}
|
|
|
|
async function authenticate(page: Page) {
|
|
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
|
await page.route("**/api/Dashboard/Stats", (route) =>
|
|
fulfillJson(route, {
|
|
data: {
|
|
openWorkOrders: 0,
|
|
pendingApprovals: 0,
|
|
scheduledPMs: 0,
|
|
activeAssets: 0,
|
|
},
|
|
}),
|
|
);
|
|
await page.route("**/api/User*", (route) => fulfillJson(route, { data: [] }));
|
|
}
|
|
|
|
test.describe("Vendor operations", () => {
|
|
test("keeps the most specific vendor navigation item exclusively current", async ({ page }) => {
|
|
await authenticate(page);
|
|
await page.route("**/api/vendor-operations/insights*", (route) =>
|
|
fulfillJson(route, { data: [] }),
|
|
);
|
|
|
|
await page.goto("/vendors/insights");
|
|
|
|
const vendorLinks = page.locator('a[href^="/vendors"]');
|
|
await expect(
|
|
vendorLinks.filter({ has: page.getByText("Vendor Insights", { exact: true }) }),
|
|
).toHaveAttribute("aria-current", "page");
|
|
await expect(
|
|
vendorLinks.filter({ has: page.getByText("Vendors", { exact: true }) }),
|
|
).not.toHaveAttribute("aria-current", "page");
|
|
await expect(
|
|
vendorLinks.filter({ has: page.getByText("Site Preferences", { exact: true }) }),
|
|
).not.toHaveAttribute("aria-current", "page");
|
|
});
|
|
|
|
test("keeps Vendor PO controls usable at a constrained viewport", async ({ page }) => {
|
|
await page.setViewportSize({ width: 700, height: 900 });
|
|
await authenticate(page);
|
|
await page.route("**/api/dispatches*", (route) => fulfillJson(route, { items: [], total: 0 }));
|
|
await page.route("**/api/Vendor/GetVendorList*", (route) =>
|
|
fulfillJson(route, { Data: [], TotalCount: 0 }),
|
|
);
|
|
|
|
await page.goto("/vendor-pos");
|
|
|
|
await expect(page.getByRole("heading", { name: "Vendor POs" })).toBeVisible();
|
|
await expect(page.getByText("0 total", { exact: true })).toBeVisible();
|
|
await expect(page.getByPlaceholder("Search PO #, dispatch, vendor, WO...")).toBeVisible();
|
|
await expect(page.locator("label").filter({ hasText: /^Status$/ })).toBeVisible();
|
|
await expect(page.locator("label").filter({ hasText: /^Vendor$/ })).toBeVisible();
|
|
await expect(page.getByLabel("From", { exact: true })).toBeVisible();
|
|
await expect(page.getByLabel("To", { exact: true })).toBeVisible();
|
|
await expect(page.getByRole("button", { name: "Clear filters" })).toBeVisible();
|
|
|
|
await expect
|
|
.poll(() =>
|
|
page
|
|
.getByPlaceholder("Search PO #, dispatch, vendor, WO...")
|
|
.evaluate((element) => element.clientWidth),
|
|
)
|
|
.toBeGreaterThanOrEqual(280);
|
|
});
|
|
|
|
test("groups 48-hour reminders and overlap conflicts with direct work-order actions", async ({
|
|
page,
|
|
}) => {
|
|
await authenticate(page);
|
|
await page.route("**/api/vendor-operations/notifications", (route) =>
|
|
fulfillJson(route, {
|
|
items: [
|
|
{
|
|
id: "no-vendor-501",
|
|
type: "NoVendor",
|
|
severity: "Medium",
|
|
group: "No Vendor",
|
|
title: "No vendor assigned — 48h to service",
|
|
workOrderId: 501,
|
|
workOrderNumber: "WO-501",
|
|
triggeredAt: "2026-07-23T12:00:00Z",
|
|
scheduledAt: "2026-07-25T12:00:00Z",
|
|
action: { label: "Choose vendor", href: "/workorders/edit/501?assignVendor=1" },
|
|
},
|
|
{
|
|
id: "vendor-conflict-801-802",
|
|
type: "VendorConflict",
|
|
severity: "Medium",
|
|
group: "Vendor Conflict",
|
|
title: "Vendor has overlapping appointments",
|
|
triggeredAt: "2026-07-23T12:00:00Z",
|
|
workOrders: [
|
|
{
|
|
workOrderId: 501,
|
|
workOrderNumber: "WO-501",
|
|
start: "2026-07-25T12:00:00Z",
|
|
end: "2026-07-25T14:00:00Z",
|
|
},
|
|
{
|
|
workOrderId: 502,
|
|
workOrderNumber: "WO-502",
|
|
start: "2026-07-25T13:00:00Z",
|
|
end: "2026-07-25T15:00:00Z",
|
|
},
|
|
],
|
|
actions: [
|
|
{ label: "WO-501", href: "/workorders/501" },
|
|
{ label: "WO-502", href: "/workorders/502" },
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
|
|
await page.goto("/notifications");
|
|
|
|
await expect(page.getByRole("heading", { name: "Items requiring attention" })).toBeVisible();
|
|
await expect(page.getByRole("heading", { name: "No Vendor" })).toBeVisible();
|
|
await expect(page.getByRole("heading", { name: "Vendor Conflict" })).toBeVisible();
|
|
await expect(page.getByRole("link", { name: "Choose vendor" })).toHaveAttribute(
|
|
"href",
|
|
"/workorders/edit/501?assignVendor=1",
|
|
);
|
|
await expect(page.getByRole("link", { name: "WO-501" })).toHaveAttribute(
|
|
"href",
|
|
"/workorders/501",
|
|
);
|
|
await expect(page.getByRole("link", { name: "WO-502" })).toHaveAttribute(
|
|
"href",
|
|
"/workorders/502",
|
|
);
|
|
});
|
|
|
|
test("filters insights and exposes both export formats", async ({ page }) => {
|
|
await authenticate(page);
|
|
const requestedUrls: string[] = [];
|
|
await page.route("**/api/vendor-operations/insights*", async (route) => {
|
|
requestedUrls.push(route.request().url());
|
|
const { pathname } = new URL(route.request().url());
|
|
if (pathname.endsWith(".csv")) {
|
|
await route.fulfill({ status: 200, contentType: "text/csv", body: "Vendor,Completed\n" });
|
|
return;
|
|
}
|
|
if (pathname.endsWith(".pdf")) {
|
|
await route.fulfill({ status: 200, contentType: "application/pdf", body: "%PDF-1.4" });
|
|
return;
|
|
}
|
|
await fulfillJson(route, {
|
|
data: [
|
|
{
|
|
vendorId: 7,
|
|
vendor: "Gateway Plumbing",
|
|
completedJobs: 12,
|
|
cancellationRefusalRate: 0.1,
|
|
onTimeArrivalRate: 0.9,
|
|
onTimeCompletionRate: 0.8,
|
|
averageCycleHours: 5.5,
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
await page.goto("/vendors/insights");
|
|
|
|
await expect(page.getByRole("cell", { name: "Gateway Plumbing" })).toBeVisible();
|
|
await page.getByLabel("From", { exact: true }).fill("2026-01-01");
|
|
await page.getByLabel("To", { exact: true }).fill("2026-01-31");
|
|
await page.getByLabel("Trade", { exact: true }).fill("Plumbing");
|
|
|
|
const insightsQueryHas = (name: string, value: string) =>
|
|
requestedUrls.some(
|
|
(url) =>
|
|
new URL(url).pathname.endsWith("/insights") &&
|
|
new URL(url).searchParams.get(name) === value,
|
|
);
|
|
await expect.poll(() => insightsQueryHas("from", "2026-01-01")).toBe(true);
|
|
await expect.poll(() => insightsQueryHas("to", "2026-01-31")).toBe(true);
|
|
await expect.poll(() => insightsQueryHas("trade", "Plumbing")).toBe(true);
|
|
|
|
await page.getByRole("button", { name: "Export CSV" }).click();
|
|
await page.getByRole("button", { name: "Export PDF" }).click();
|
|
|
|
const exportRequest = (format: "csv" | "pdf") =>
|
|
requestedUrls.find((url) => new URL(url).pathname.endsWith(`/insights.${format}`));
|
|
const exportHasFilters = (url: string | undefined) => {
|
|
if (!url) return false;
|
|
const params = new URL(url).searchParams;
|
|
return (
|
|
params.get("from") === "2026-01-01" &&
|
|
params.get("to") === "2026-01-31" &&
|
|
params.get("trade") === "Plumbing"
|
|
);
|
|
};
|
|
|
|
await expect.poll(() => exportHasFilters(exportRequest("csv"))).toBe(true);
|
|
await expect.poll(() => exportHasFilters(exportRequest("pdf"))).toBe(true);
|
|
});
|
|
|
|
test("reorders and saves preferred vendors for a site", async ({ page }) => {
|
|
await authenticate(page);
|
|
let savedPreferences: unknown;
|
|
await page.route("**/api/Contact/GetLocations", (route) =>
|
|
fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }),
|
|
);
|
|
await page.route("**/api/Vendor/Dropdown*", (route) =>
|
|
fulfillJson(route, {
|
|
data: [
|
|
{ Id: 7, CompanyName: "Gateway Plumbing", TradeSpecialties: "Plumbing" },
|
|
{ Id: 8, CompanyName: "Metro HVAC", TradeSpecialties: "HVAC" },
|
|
],
|
|
}),
|
|
);
|
|
await page.route("**/api/vendor-operations/sites/10/preferred-vendors", async (route) => {
|
|
if (route.request().method() === "PUT") {
|
|
savedPreferences = route.request().postDataJSON();
|
|
await fulfillJson(route, savedPreferences);
|
|
return;
|
|
}
|
|
await fulfillJson(route, [
|
|
{
|
|
id: 1,
|
|
locationId: 10,
|
|
vendorId: 7,
|
|
vendorName: "Gateway Plumbing",
|
|
trade: "Plumbing",
|
|
sortOrder: 0,
|
|
isStale: false,
|
|
},
|
|
{
|
|
id: 2,
|
|
locationId: 10,
|
|
vendorId: 8,
|
|
vendorName: "Metro HVAC",
|
|
trade: "HVAC",
|
|
sortOrder: 1,
|
|
isStale: false,
|
|
},
|
|
]);
|
|
});
|
|
|
|
await page.goto("/vendors/preferences");
|
|
await page.getByLabel("Site").click();
|
|
await page.getByRole("option", { name: "St. Louis HQ" }).click();
|
|
await expect(page.getByText(/1\. Gateway Plumbing/)).toBeVisible();
|
|
await page.getByRole("button", { name: "Move Metro HVAC up" }).click();
|
|
await expect(page.getByText(/1\. Metro HVAC/)).toBeVisible();
|
|
await page.getByRole("button", { name: "Save preferences" }).click();
|
|
|
|
await expect
|
|
.poll(() => savedPreferences)
|
|
.toEqual([
|
|
{ vendorId: 8, trade: "HVAC", sortOrder: 0 },
|
|
{ vendorId: 7, trade: "Plumbing", sortOrder: 1 },
|
|
]);
|
|
});
|
|
|
|
test("creates a work order and assigns the selected technician with ETA", async ({ page }) => {
|
|
await authenticate(page);
|
|
let assignment: unknown;
|
|
|
|
await page.route("**/api/Contact/GetLocations", (route) =>
|
|
fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }),
|
|
);
|
|
await page.route("**/api/Employee/Dropdown", (route) => fulfillJson(route, { data: [] }));
|
|
await page.route("**/api/DropdownOptions/ByCategory/**", (route) =>
|
|
fulfillJson(route, { data: [] }),
|
|
);
|
|
await page.route("**/api/vendor-operations/availability*", (route) =>
|
|
fulfillJson(route, {
|
|
data: [
|
|
{
|
|
id: 7,
|
|
companyName: "Gateway Plumbing",
|
|
contactName: "Taylor Tech",
|
|
availabilityStatus: "Available",
|
|
isPreferred: true,
|
|
conflicts: [],
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
await page.route("**/api/WorkOrder/AddWorkorder", (route) =>
|
|
fulfillJson(route, {
|
|
data: {
|
|
Id: 900,
|
|
WorkerOrderTitle: "Boiler repair",
|
|
LocationId: 10,
|
|
Priority: "Sev 1",
|
|
Status: "Open",
|
|
Comments: [],
|
|
AuditLog: [],
|
|
Dispatches: [],
|
|
},
|
|
}),
|
|
);
|
|
await page.route("**/api/vendor-operations/work-orders/900/assignment", async (route) => {
|
|
assignment = route.request().postDataJSON();
|
|
await fulfillJson(route, {
|
|
availabilityStatus: "Available",
|
|
conflicts: [
|
|
{
|
|
workOrderId: 502,
|
|
workOrderNumber: "WO-502",
|
|
start: "2026-07-25T12:00:00Z",
|
|
end: "2026-07-25T14:00:00Z",
|
|
},
|
|
],
|
|
});
|
|
});
|
|
await page.route("**/api/WorkOrder/GetWorkorderById*", (route) =>
|
|
fulfillJson(route, {
|
|
data: {
|
|
Id: 900,
|
|
WorkerOrderTitle: "Boiler repair",
|
|
LocationId: 10,
|
|
Priority: "Sev 1",
|
|
Status: "Open",
|
|
Comments: [],
|
|
AuditLog: [],
|
|
Dispatches: [],
|
|
},
|
|
}),
|
|
);
|
|
|
|
await page.goto("/workorders/new");
|
|
await page.getByLabel("Title").fill("Boiler repair");
|
|
await page.getByLabel("Location").click();
|
|
await page.getByRole("option", { name: "St. Louis HQ" }).click();
|
|
await page.getByLabel("Priority").click();
|
|
await page.getByRole("option", { name: "Sev 1" }).click();
|
|
await page.getByLabel("Scheduled").fill("2026-07-25");
|
|
await page.getByLabel("Appointment start").fill("2026-07-25T10:00");
|
|
await page.getByLabel("Appointment end").fill("2026-07-25T12:00");
|
|
await page.getByLabel("Company + Technician").click();
|
|
await page.getByRole("option", { name: /Gateway Plumbing · Taylor Tech/ }).click();
|
|
await page.getByLabel("Assignment ETA").fill("2026-07-25T08:30");
|
|
await page.getByLabel("Manual ETA override").check();
|
|
await page.getByRole("button", { name: "Create" }).click();
|
|
|
|
await expect
|
|
.poll(() => assignment)
|
|
.toEqual({
|
|
vendorId: 7,
|
|
scheduledStart: "2026-07-25T10:00:00.000Z",
|
|
scheduledEnd: "2026-07-25T12:00:00.000Z",
|
|
estimatedArrivalAt: "2026-07-25T08:30:00.000Z",
|
|
etaManualOverride: true,
|
|
});
|
|
await expect(page.getByText(/scheduling conflict/i)).toBeVisible();
|
|
await expect(page).toHaveURL(/\/workorders\/900$/);
|
|
});
|
|
|
|
test("preserves an existing ETA when editing in a non-UTC timezone", async ({ browser }) => {
|
|
const context = await browser.newContext({ timezoneId: "America/New_York" });
|
|
const page = await context.newPage();
|
|
await authenticate(page);
|
|
let assignment: Record<string, unknown> | undefined;
|
|
|
|
const workOrder = {
|
|
Id: 901,
|
|
WorkerOrderTitle: "Boiler repair",
|
|
LocationId: 10,
|
|
Priority: "Sev 1",
|
|
Status: "Open",
|
|
CreatedDate: "2026-07-20",
|
|
ScheduledDate: "2026-07-25",
|
|
Comments: [],
|
|
AuditLog: [],
|
|
Dispatches: [
|
|
{
|
|
Id: 44,
|
|
VendorId: 7,
|
|
Status: "Acknowledged",
|
|
EstimatedArrivalAt: "2026-07-25T12:30:00.000Z",
|
|
EtaManualOverride: true,
|
|
},
|
|
],
|
|
};
|
|
|
|
await page.route("**/api/Contact/GetLocations", (route) =>
|
|
fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }),
|
|
);
|
|
await page.route("**/api/Employee/Dropdown", (route) => fulfillJson(route, { data: [] }));
|
|
await page.route("**/api/DropdownOptions/ByCategory/**", (route) =>
|
|
fulfillJson(route, { data: [] }),
|
|
);
|
|
await page.route("**/api/vendor-operations/availability*", (route) =>
|
|
fulfillJson(route, {
|
|
data: [
|
|
{
|
|
id: 7,
|
|
companyName: "Gateway Plumbing",
|
|
contactName: "Taylor Tech",
|
|
availabilityStatus: "Available",
|
|
isPreferred: true,
|
|
conflicts: [],
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
await page.route("**/api/WorkOrder/GetWorkorderById*", (route) =>
|
|
fulfillJson(route, { data: workOrder }),
|
|
);
|
|
await page.route("**/api/WorkOrder/EditWorkorder", (route) =>
|
|
fulfillJson(route, { data: workOrder }),
|
|
);
|
|
await page.route("**/api/vendor-operations/work-orders/901/assignment", async (route) => {
|
|
assignment = route.request().postDataJSON() as Record<string, unknown>;
|
|
await fulfillJson(route, { availabilityStatus: "Available", conflicts: [] });
|
|
});
|
|
|
|
await page.goto("/workorders/edit/901");
|
|
await expect(page.getByLabel("Assignment ETA")).toHaveValue("2026-07-25T08:30");
|
|
await page.getByRole("button", { name: "Update" }).click();
|
|
|
|
await expect.poll(() => assignment?.estimatedArrivalAt).toBe("2026-07-25T12:30:00.000Z");
|
|
await context.close();
|
|
});
|
|
|
|
test("converts explicit local schedule bounds to the correct UTC instants across a DST transition", async ({
|
|
browser,
|
|
}) => {
|
|
// America/New_York springs forward on 2026-03-08 at 02:00 EST -> 03:00 EDT.
|
|
// The window below straddles the transition: 01:00 is EST (UTC-5) while
|
|
// 04:00 is EDT (UTC-4). Each bound must resolve its own offset, proving no
|
|
// fixed-window assumption remains.
|
|
const context = await browser.newContext({ timezoneId: "America/New_York" });
|
|
const page = await context.newPage();
|
|
await authenticate(page);
|
|
let assignment: Record<string, unknown> | undefined;
|
|
|
|
await page.route("**/api/Contact/GetLocations", (route) =>
|
|
fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }),
|
|
);
|
|
await page.route("**/api/Employee/Dropdown", (route) => fulfillJson(route, { data: [] }));
|
|
await page.route("**/api/DropdownOptions/ByCategory/**", (route) =>
|
|
fulfillJson(route, { data: [] }),
|
|
);
|
|
await page.route("**/api/vendor-operations/availability*", (route) =>
|
|
fulfillJson(route, {
|
|
data: [
|
|
{
|
|
id: 7,
|
|
companyName: "Gateway Plumbing",
|
|
contactName: "Taylor Tech",
|
|
availabilityStatus: "Available",
|
|
isPreferred: true,
|
|
conflicts: [],
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
await page.route("**/api/WorkOrder/AddWorkorder", (route) =>
|
|
fulfillJson(route, {
|
|
data: {
|
|
Id: 902,
|
|
WorkerOrderTitle: "Boiler repair",
|
|
LocationId: 10,
|
|
Priority: "Sev 1",
|
|
Status: "Open",
|
|
Comments: [],
|
|
AuditLog: [],
|
|
Dispatches: [],
|
|
},
|
|
}),
|
|
);
|
|
await page.route("**/api/vendor-operations/work-orders/902/assignment", async (route) => {
|
|
assignment = route.request().postDataJSON() as Record<string, unknown>;
|
|
await fulfillJson(route, { availabilityStatus: "Available", conflicts: [] });
|
|
});
|
|
await page.route("**/api/WorkOrder/GetWorkorderById*", (route) =>
|
|
fulfillJson(route, {
|
|
data: {
|
|
Id: 902,
|
|
WorkerOrderTitle: "Boiler repair",
|
|
LocationId: 10,
|
|
Priority: "Sev 1",
|
|
Status: "Open",
|
|
Comments: [],
|
|
AuditLog: [],
|
|
Dispatches: [],
|
|
},
|
|
}),
|
|
);
|
|
|
|
await page.goto("/workorders/new");
|
|
await page.getByLabel("Title").fill("Boiler repair");
|
|
await page.getByLabel("Location").click();
|
|
await page.getByRole("option", { name: "St. Louis HQ" }).click();
|
|
await page.getByLabel("Priority").click();
|
|
await page.getByRole("option", { name: "Sev 1" }).click();
|
|
await page.getByLabel("Appointment start").fill("2026-03-08T01:00");
|
|
await page.getByLabel("Appointment end").fill("2026-03-08T04:00");
|
|
await page.getByLabel("Company + Technician").click();
|
|
await page.getByRole("option", { name: /Gateway Plumbing · Taylor Tech/ }).click();
|
|
await page.getByRole("button", { name: "Create" }).click();
|
|
|
|
await expect.poll(() => assignment?.scheduledStart).toBe("2026-03-08T06:00:00.000Z");
|
|
await expect.poll(() => assignment?.scheduledEnd).toBe("2026-03-08T08:00:00.000Z");
|
|
await context.close();
|
|
});
|
|
|
|
test("focuses the Vendor & Time section when arriving from a no-vendor alert", async ({
|
|
page,
|
|
}) => {
|
|
await authenticate(page);
|
|
await page.route("**/api/Contact/GetLocations", (route) =>
|
|
fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }),
|
|
);
|
|
await page.route("**/api/Employee/Dropdown", (route) => fulfillJson(route, { data: [] }));
|
|
await page.route("**/api/DropdownOptions/ByCategory/**", (route) =>
|
|
fulfillJson(route, { data: [] }),
|
|
);
|
|
await page.route("**/api/vendor-operations/availability*", (route) =>
|
|
fulfillJson(route, {
|
|
data: [
|
|
{
|
|
id: 7,
|
|
companyName: "Gateway Plumbing",
|
|
contactName: "Taylor Tech",
|
|
availabilityStatus: "Available",
|
|
isPreferred: true,
|
|
conflicts: [],
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
await page.route("**/api/WorkOrder/GetWorkorderById*", (route) =>
|
|
fulfillJson(route, {
|
|
data: {
|
|
Id: 501,
|
|
WorkerOrderTitle: "Boiler repair",
|
|
LocationId: 10,
|
|
Priority: "Sev 1",
|
|
Status: "Open",
|
|
Comments: [],
|
|
AuditLog: [],
|
|
Dispatches: [],
|
|
},
|
|
}),
|
|
);
|
|
|
|
await page.goto("/workorders/edit/501?assignVendor=1");
|
|
|
|
const vendorSection = page.getByRole("region", { name: "Vendor & Time" });
|
|
await expect(vendorSection).toBeVisible();
|
|
await expect(vendorSection).toBeFocused();
|
|
await page.getByLabel("Company + Technician").click();
|
|
const option = page.getByRole("option", { name: /Gateway Plumbing · Taylor Tech/ });
|
|
await expect(option).toContainText("Unknown");
|
|
});
|
|
});
|