shoc-frontend-new/e2e/vendors/vendor-uplift-workflow.spec.ts
Alexandre Brandizzi 9f46a91cb9
Merge pull request #260 from Sea-Haven-Industries/fix/ab/sh-383-media-contract
fix(media): apply the SH-116 media contract to every upload surface
2026-09-25 06:06:59 +00:00

378 lines
12 KiB
TypeScript

import { expect, test, type Route } from "@playwright/test";
const INTERNAL_USER = {
token: "uplift-approver-token",
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
email: "approver@seahavenind.com",
userRoles: "Admin,Tier2Approver",
fullname: "Uplift Approver",
id: "1",
};
async function fulfillJson(route: Route, data: unknown, status = 200) {
await route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify({ status: status < 400 ? "Success" : "Error", data }),
});
}
test("vendor uploads scanned evidence and submits a dispatch-scoped uplift", async ({ page }) => {
let requestBody: Record<string, unknown> | undefined;
let uploadPurpose = "";
let created = false;
await page.route("**/api/vendor-portal/**", async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
if (path.endsWith("/session")) {
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
return;
}
if (path.endsWith("/dispatches/7/documents") && request.method() === "POST") {
uploadPurpose = request.postData() ?? "";
await fulfillJson(route, {
id: 22,
originalFileName: "estimate.pdf",
scanStatus: "Pending",
reviewStatus: "Processing",
purpose: "UpliftEvidence",
});
return;
}
if (path.endsWith("/dispatches/7/documents/22/status")) {
await fulfillJson(route, {
id: 22,
originalFileName: "estimate.pdf",
scanStatus: "Passed",
reviewStatus: "Approved",
purpose: "UpliftEvidence",
});
return;
}
if (path.endsWith("/dispatches/7/uplift-request") && request.method() === "POST") {
requestBody = request.postDataJSON();
created = true;
await fulfillJson(route, {
id: 31,
status: "Pending",
noApprovalRequired: false,
});
return;
}
if (path.endsWith("/dispatches/7")) {
await fulfillJson(route, {
id: 7,
dispatchNumber: "DSP-7",
poNumber: "PO-7",
status: "In Progress",
nteAmount: 100,
checklist: [],
comments: [],
signoffs: [],
documents: [],
upliftRequests: created
? [
{
id: 31,
status: "Pending",
currentNTE: 100,
requestedNTE: 125,
vendorReason: "Replacement compressor",
evidenceDocumentId: 22,
evidenceFileName: "estimate.pdf",
notificationStatus: "Sent",
},
]
: [],
});
return;
}
await fulfillJson(route, []);
});
await page.goto("/v/portal-token/dispatch/7");
await expect(page.getByRole("heading", { name: /DSP-7|PO-7/ })).toBeVisible();
await page.getByLabel("New total NTE").fill("125");
await page.getByLabel("Reason").fill("Replacement compressor");
await page.getByLabel("Evidence (PDF, JPG, PNG, HEIC, MP4, MOV)").setInputFiles({
name: "estimate.pdf",
mimeType: "application/pdf",
buffer: Buffer.from("%PDF-1.4\nmock estimate"),
});
await page.getByRole("button", { name: "Request uplift" }).click();
await expect(page.getByText("Pending", { exact: true })).toBeVisible();
expect(uploadPurpose).toContain('name="purpose"');
expect(uploadPurpose).toContain("UpliftEvidence");
expect(requestBody).toMatchObject({
requestedNTE: 125,
reason: "Replacement compressor",
evidenceDocumentId: 22,
});
expect(requestBody?.requestKey).toMatch(/^[0-9a-f-]{36}$/i);
});
test("vendor can withdraw a pending uplift from the dispatch", async ({ page }) => {
let withdrawnPath = "";
let pending = true;
await page.route("**/api/vendor-portal/**", async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
if (path.endsWith("/session")) {
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
return;
}
if (path.endsWith("/dispatches/7/uplift-request/31/withdraw")) {
withdrawnPath = path;
pending = false;
await fulfillJson(route, { id: 31, status: "Withdrawn" });
return;
}
if (path.endsWith("/dispatches/7")) {
await fulfillJson(route, {
id: 7,
dispatchNumber: "DSP-7",
status: "In Progress",
nteAmount: 100,
checklist: [],
comments: [],
signoffs: [],
documents: [],
upliftRequests: [
{
id: 31,
status: pending ? "Pending" : "Withdrawn",
currentNTE: 100,
requestedNTE: 125,
vendorReason: "Parts",
},
],
});
return;
}
await fulfillJson(route, []);
});
page.on("dialog", (dialog) => void dialog.accept());
await page.goto("/v/portal-token/dispatch/7");
await page.getByRole("button", { name: "Withdraw request" }).click();
await expect(page.getByText("Withdrawn", { exact: true })).toBeVisible();
expect(withdrawnPath).toBe("/api/vendor-portal/dispatches/7/uplift-request/31/withdraw");
});
test("vendor receives the no-approval result for a request within the current NTE", async ({
page,
}) => {
let requestBody: Record<string, unknown> | undefined;
await page.route("**/api/vendor-portal/**", async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
if (path.endsWith("/session")) {
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
return;
}
if (path.endsWith("/dispatches/7/uplift-request") && request.method() === "POST") {
requestBody = request.postDataJSON();
await fulfillJson(route, { noApprovalRequired: true, status: "Approved" });
return;
}
if (path.endsWith("/dispatches/7")) {
await fulfillJson(route, {
id: 7,
dispatchNumber: "DSP-7",
status: "In Progress",
nteAmount: 100,
checklist: [],
comments: [],
signoffs: [],
documents: [],
upliftRequests: [],
});
return;
}
await fulfillJson(route, []);
});
await page.goto("/v/portal-token/dispatch/7");
await page.getByLabel("New total NTE").fill("90");
await expect(page.getByLabel("Reason")).toHaveCount(0);
await page.getByRole("button", { name: "Request uplift" }).click();
await expect(
page.getByText("No approval required — the existing NTE already covers this amount."),
).toBeVisible();
expect(requestBody).toMatchObject({ requestedNTE: 90, reason: "" });
expect(requestBody).not.toHaveProperty("evidenceDocumentId");
});
test("vendor revises a changes-requested uplift using its existing evidence", async ({ page }) => {
let revisionBody: Record<string, unknown> | undefined;
let revised = false;
await page.route("**/api/vendor-portal/**", async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
if (path.endsWith("/session")) {
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
return;
}
if (path.endsWith("/dispatches/7/uplift-request/31/revise")) {
revisionBody = request.postDataJSON();
revised = true;
await fulfillJson(route, { id: 31, status: "Pending" });
return;
}
if (path.endsWith("/dispatches/7")) {
await fulfillJson(route, {
id: 7,
dispatchNumber: "DSP-7",
status: "In Progress",
nteAmount: 100,
checklist: [],
comments: [],
signoffs: [],
documents: [],
upliftRequests: [
{
id: 31,
status: revised ? "Pending" : "ChangesRequested",
currentNTE: 100,
requestedNTE: 125,
vendorReason: "Parts",
decisionNote: "Itemize the estimate",
evidenceDocumentId: 22,
evidenceFileName: "estimate.pdf",
},
],
});
return;
}
await fulfillJson(route, []);
});
await page.goto("/v/portal-token/dispatch/7");
await page.getByRole("button", { name: "Revise" }).click();
await page.getByLabel("New total NTE").fill("120");
await page.getByLabel("Reason").fill("Parts itemized in the attached estimate");
await page.getByRole("button", { name: "Submit revision" }).click();
await expect(page.getByText("Pending", { exact: true })).toBeVisible();
expect(revisionBody).toEqual({
requestedNTE: 120,
reason: "Parts itemized in the attached estimate",
evidenceDocumentId: 22,
});
});
test("internal approver can approve, reject, and revoke with audited notes", async ({ page }) => {
const decisions: Array<{ path: string; body: unknown }> = [];
const statuses = new Map<number, string>([
[41, "Pending"],
[42, "Pending"],
]);
await page.addInitScript(
(user) => localStorage.setItem("auth", JSON.stringify(user)),
INTERNAL_USER,
);
await page.route("**/api/Dashboard/Stats", (route) => fulfillJson(route, {}));
await page.route("**/api/User*", (route) => fulfillJson(route, []));
await page.route(/\/api\/uplifts(?:[/?]|$)/, async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
const action = path.match(/\/uplifts\/(41|42)\/(approve|reject|revoke)$/);
if (action && request.method() === "POST") {
const id = Number(action[1]);
statuses.set(
id,
action[2] === "approve" ? "Approved" : action[2] === "revoke" ? "Revoked" : "Rejected",
);
decisions.push({ path, body: request.postDataJSON() });
await fulfillJson(route, { id, status: statuses.get(id) });
return;
}
if (path.endsWith("/uplifts/can-approve")) {
await fulfillJson(route, { canApprove: true });
return;
}
const requestedStatus = new URL(request.url()).searchParams.get("status");
const items = [
{
id: 41,
dispatchId: 7,
workOrderId: 501,
dispatchNumber: "DSP-41",
poNumber: "PO-41",
vendorCompanyName: "Gateway Plumbing",
currentNTE: 100,
requestedNTE: 125,
delta: 25,
requiredTier: 1,
status: statuses.get(41),
canDecide: true,
evidenceDocumentId: 22,
evidenceFileName: "estimate.pdf",
evidenceContentType: "application/pdf",
evidenceSizeBytes: 2048,
},
{
id: 42,
dispatchId: 8,
workOrderId: 502,
dispatchNumber: "DSP-42",
poNumber: "PO-42",
vendorCompanyName: "Metro HVAC",
currentNTE: 200,
requestedNTE: 260,
delta: 60,
requiredTier: 2,
status: statuses.get(42),
canDecide: true,
evidenceDocumentId: 23,
evidenceFileName: "quote.pdf",
},
].filter((item) => requestedStatus == null || item.status === requestedStatus);
await fulfillJson(route, {
total: items.length,
items,
});
});
await page.goto("/uplifts");
await expect(page.getByRole("heading", { name: "Uplift Approvals" })).toBeVisible();
const approvalRow = page.getByRole("row").filter({ hasText: "DSP-41" });
await approvalRow.getByRole("button", { name: "Approve $25.00" }).click();
const changesRow = page.getByRole("row").filter({ hasText: "DSP-42" });
await changesRow.getByRole("button", { name: "Reject" }).click();
await page.getByLabel("Reason for rejection").fill("Please itemize labor and parts.");
await page.getByRole("button", { name: "Reject uplift" }).click();
await page.getByRole("tab", { name: "Approved" }).click();
const approvedRow = page.getByRole("row").filter({ hasText: "DSP-41" });
await approvedRow.getByRole("button", { name: "Revoke" }).click();
await page.getByLabel("Reason for revoking").fill("Approval was made in error.");
await page.getByRole("button", { name: "Revoke $25.00" }).click();
await expect
.poll(() => decisions)
.toEqual([
{ path: "/api/uplifts/41/approve", body: {} },
{
path: "/api/uplifts/42/reject",
body: { note: "Please itemize labor and parts." },
},
{
path: "/api/uplifts/41/revoke",
body: { note: "Approval was made in error." },
},
]);
});