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]; } function multipartFileName(body: string): string | undefined { return /filename="([^"]+)"/.exec(body)?.[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) { 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 = multipartFileName(body) ?? "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(); const files = [ { 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 (await chooser).setFiles(files); await expect.poll(() => uploads.length).toBe(files.length); for (const { name } of files) { const upload = uploads.find((item) => multipartFileName(item.body) === name); expect(upload, `multipart upload for ${name}`).toBeTruthy(); expect(upload!.contentType).toMatch(/^multipart\/form-data; boundary=/); } 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}`)); }); });