shoc-frontend-new/e2e/work-orders/completion-uploads-mobile.spec.ts
Codex Review Integration 59a5bc9252 test(work-orders): clear query devtools overlay in mobile upload journey
Also drops ticket keys from shipped comments and test names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 15:15:03 -03:00

181 lines
7.5 KiB
TypeScript

import { devices, expect, test, type Page, type Request, type Route } from "@playwright/test";
// Completion uploads from a mobile-sized viewport must reach the API as
// multipart/form-data with a boundary, or ASP.NET cannot bind the [FromForm] file/category.
test.use({ ...devices["Pixel 7"] });
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
const ROW_VERSION = "AAAAAAAAB9E=";
const TEST_USER = {
token: "wo-completion-uploads-e2e-token",
expiration: "2030-01-01T00:00:00.000Z",
email: "admin@seahavenind.com",
userRoles: "Admin",
phoneNumber: "5551234567",
fullname: "Work Order E2E Admin",
id: "1",
};
// Leading bytes of files written by real encoders (ffmpeg, iPhone .MOV, pdf).
const PDF = Buffer.from("%PDF-1.7\n%âã\n1 0 obj\n", "latin1");
const JPEG = Buffer.from("ffd8ffe000104a46494600010200000100010000", "hex");
const MP4 = Buffer.from("000000206674797069736f6d0000020069736f6d69736f32", "hex");
const MOV = Buffer.from("000000146674797071742020000002007174202000000008", "hex");
type BoardRowOptions = { id: number; woNumber: string; workOrderType: number };
function boardRow({ id, woNumber, workOrderType }: BoardRowOptions) {
return {
id,
woNumber,
workOrderType,
siteCode: "BK5",
lifecycleStatusLabel: "In Progress",
scheduledDate: "2026-08-19",
dispatcherId: "1",
dispatcherName: "Pat Dispatcher",
pm: "HVAC",
docStatus: "No",
rowVersion: ROW_VERSION,
};
}
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
}
function multipartBoundary(request: Request): string | undefined {
const contentType = request.headers()["content-type"] ?? "";
return /^multipart\/form-data; boundary=(.+)$/.exec(contentType)?.[1];
}
async function hideQueryDevtools(page: Page) {
await page.addStyleTag({
content:
".tsqd-parent-container { display: none !important; pointer-events: none !important; }",
});
await page.locator(".tsqd-parent-container").evaluateAll((nodes) => {
for (const node of nodes) {
node.remove();
}
});
}
async function mockBoard(page: Page, row: ReturnType<typeof boardRow>) {
await page.route("**/api/**", (route) => {
const pathname = new URL(route.request().url()).pathname;
if (!pathname.startsWith("/api/")) return route.fallback();
return fulfillJson(route, { data: [] });
});
await page.route("**/api/workorders/board*", (route) => {
if (route.request().url().includes("board/search")) return route.fallback();
return fulfillJson(route, {
weekStart: "2026-08-17",
weekEnd: "2026-08-21",
counts: { returned: 1, total: 1 },
unscheduled: [],
scheduled: [row],
});
});
await page.route("**/api/workorders/board/search*", (route) =>
fulfillJson(route, { items: [], totalCount: 0, page: 0, pageSize: 50 }),
);
}
async function openCompletionTab(page: Page, woNumber: string) {
await page.clock.install({ time: new Date(FROZEN_NOW) });
await page.clock.resume();
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
await page.goto("/workorders");
await hideQueryDevtools(page);
const row = page.getByRole("row").filter({ hasText: woNumber });
await row.getByRole("button", { name: "View details" }).click();
const panel = page.getByRole("dialog", { name: "Work order details" });
await expect(panel).toBeVisible({ timeout: 15_000 });
await panel.getByRole("tab", { name: "Completion Doc" }).click();
return panel;
}
test.describe("Completion uploads on a mobile viewport", () => {
test("uploads a signed PDF as multipart with the file and work order version", async ({
page,
}) => {
const row = boardRow({ id: 71, woNumber: "PM-UPLOAD-71", workOrderType: 2 });
await mockBoard(page, row);
await page.route("**/api/workorders/71/detail", (route) =>
fulfillJson(route, { info: row, completion: { docStatus: "No" }, media: [] }),
);
let upload: Request | undefined;
await page.route("**/api/workorders/71/completion-doc", (route) => {
upload = route.request();
return fulfillJson(route, { docStatus: "Yes", signOffAttachment: "/Assets/Documents/x.pdf" });
});
const panel = await openCompletionTab(page, row.woNumber);
const chooser = page.waitForEvent("filechooser");
await panel.getByRole("button", { name: "Upload signed PDF" }).click();
await (
await chooser
).setFiles({ name: "Signed Completion.pdf", mimeType: "application/pdf", buffer: PDF });
await expect.poll(() => upload?.method()).toBe("POST");
const boundary = multipartBoundary(upload!);
expect(boundary, upload!.headers()["content-type"]).toBeTruthy();
const body = upload!.postDataBuffer()?.toString("latin1") ?? "";
expect(body).toContain(`--${boundary}`);
expect(body).toContain('name="file"; filename="Signed Completion.pdf"');
expect(body).toContain("%PDF-1.7");
expect(body).toMatch(new RegExp(`name="workOrderVersion"\\r\\n\\r\\n${ROW_VERSION}`));
});
test("uploads JPG, MP4 and MOV media and categorizes as multipart", async ({ page }) => {
const row = boardRow({ id: 72, woNumber: "RX-MEDIA-72", workOrderType: 6 });
const media: { id: number; category: number; url: string; fileName: string }[] = [];
await mockBoard(page, row);
await page.route("**/api/workorders/72/detail", (route) =>
fulfillJson(route, { info: row, completion: { docStatus: "No" }, media }),
);
const uploads: { contentType: string; body: string }[] = [];
await page.route("**/api/workorders/72/media", (route) => {
const request = route.request();
const body = request.postDataBuffer()?.toString("latin1") ?? "";
uploads.push({ contentType: request.headers()["content-type"] ?? "", body });
const fileName = /filename="([^"]+)"/.exec(body)?.[1] ?? "file";
const item = { id: 900 + media.length, category: 3, url: `/Assets/${fileName}`, fileName };
media.push(item);
return fulfillJson(route, item);
});
let categorize: Request | undefined;
await page.route("**/api/workorders/72/media/*", (route) => {
categorize = route.request();
const item = media[0]!;
return fulfillJson(route, { ...item, category: 1 });
});
const panel = await openCompletionTab(page, row.woNumber);
const chooser = page.waitForEvent("filechooser");
await panel.getByRole("button", { name: /Drag files here or click to browse/ }).click();
await (
await chooser
).setFiles([
{ name: "IMG_0001.jpg", mimeType: "image/jpeg", buffer: JPEG },
{ name: "VID_0002.MP4", mimeType: "video/mp4", buffer: MP4 },
{ name: "IMG_1587.MOV", mimeType: "video/quicktime", buffer: MOV },
]);
await expect.poll(() => uploads.length).toBe(3);
for (const [index, name] of ["IMG_0001.jpg", "VID_0002.MP4", "IMG_1587.MOV"].entries()) {
expect(uploads[index]!.contentType).toMatch(/^multipart\/form-data; boundary=/);
expect(uploads[index]!.body).toContain(`filename="${name}"`);
}
await panel.getByRole("combobox", { name: "Category for IMG_0001.jpg" }).selectOption("Before");
await expect.poll(() => categorize?.method()).toBe("PATCH");
const boundary = multipartBoundary(categorize!);
expect(boundary, categorize!.headers()["content-type"]).toBeTruthy();
const body = categorize!.postDataBuffer()?.toString("latin1") ?? "";
expect(body).toMatch(/name="category"\r\n\r\nBefore/);
expect(body).toMatch(new RegExp(`name="workOrderVersion"\\r\\n\\r\\n${ROW_VERSION}`));
});
});