diff --git a/e2e/work-orders/completion-uploads-mobile.spec.ts b/e2e/work-orders/completion-uploads-mobile.spec.ts new file mode 100644 index 00000000..a8fde88f --- /dev/null +++ b/e2e/work-orders/completion-uploads-mobile.spec.ts @@ -0,0 +1,181 @@ +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) { + 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}`)); + }); +}); diff --git a/src/api/api.ts b/src/api/api.ts index 147c0571..e85e8e18 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -11,28 +11,22 @@ function isAuthLoginRequest(request: Request): boolean { return request.url.includes(API_PATHS.authentication.login); } -/** Ky's beforeRequest sees request.body as a stream; check options.body for FormData. */ -export function shouldStripJsonContentType(body: unknown): boolean { - return body instanceof FormData; -} - export const api: KyInstance = ky.create({ prefix: resolveApiPrefixUrl(env.apiUrl), + // hazard: no instance-wide Content-Type. Ky sets application/json for `json` bodies, and the + // runtime must set `multipart/form-data; boundary=…` for FormData while it builds the Request. + // A default here is baked into that Request; deleting it in a hook leaves the multipart body + // with no Content-Type at all, so ASP.NET cannot bind [FromForm] fields. headers: { Accept: "application/json", - "Content-Type": "application/json", }, hooks: { beforeRequest: [ - ({ request, options }) => { + ({ request }) => { const token = getAccessToken(); if (token) { request.headers.set("Authorization", token); } - // Let the runtime set multipart boundary; default JSON Content-Type breaks FormData. - if (shouldStripJsonContentType(options.body)) { - request.headers.delete("Content-Type"); - } }, ], beforeError: [({ error }) => normalizeApiRequestError(error)], diff --git a/src/test/api/api-formdata-content-type.test.ts b/src/test/api/api-formdata-content-type.test.ts index b914b9f0..94312aba 100644 --- a/src/test/api/api-formdata-content-type.test.ts +++ b/src/test/api/api-formdata-content-type.test.ts @@ -1,60 +1,90 @@ +// @vitest-environment node +// Node's fetch/FormData/Request follow the WHATWG rules a browser uses to derive the multipart +// Content-Type; jsdom's FormData does not, which is how a header-less upload once passed here. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { shouldStripJsonContentType } from "@/api/api"; -describe("shouldStripJsonContentType", () => { - it("returns true for FormData (multipart uploads)", () => { - const form = new FormData(); - form.append("file", new File(["x"], "a.png", { type: "image/png" })); - expect(shouldStripJsonContentType(form)).toBe(true); - }); +type Captured = { method: string; contentType: string | null; body: string }; - it("returns false for JSON body / undefined", () => { - expect(shouldStripJsonContentType(undefined)).toBe(false); - expect(shouldStripJsonContentType(JSON.stringify({ a: 1 }))).toBe(false); - expect(shouldStripJsonContentType({ a: 1 })).toBe(false); - }); -}); +const API_URL = "http://api.test/api"; +let captured: Captured[] = []; -describe("api.post FormData Content-Type", () => { - const originalFetch = globalThis.fetch; - - beforeEach(() => { - vi.resetModules(); - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - it("does not send Content-Type: application/json when body is FormData", async () => { - let capturedContentType: string | null = null; - - globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const headers = new Headers( - init?.headers ?? (input instanceof Request ? input.headers : undefined), - ); - capturedContentType = headers.get("Content-Type"); - return new Response(JSON.stringify({ ok: true }), { - status: 200, - headers: { "Content-Type": "application/json" }, +beforeEach(() => { + captured = []; + vi.resetModules(); + vi.doMock("@/lib/env", () => ({ env: { apiUrl: API_URL } })); + vi.doMock("@/lib/auth/auth-storage", () => ({ getAccessToken: () => "Bearer test-token" })); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + // Serialize exactly what would go on the wire. + const request = new Request(input, init); + captured.push({ + method: request.method, + contentType: request.headers.get("content-type"), + body: await request.text(), }); - }) as typeof fetch; + return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } }); + }), + ); +}); - const { api } = await import("@/api/api"); +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function lastRequest(): Captured { + const request = captured[captured.length - 1]; + if (!request) throw new Error("No request reached fetch"); + return request; +} + +describe("api multipart uploads", () => { + it("sends FormData with a multipart Content-Type that carries the body's boundary", async () => { + const { apiRequestRaw } = await import("@/api/api"); const form = new FormData(); - form.append("category", "Before"); - form.append("file", new File(["png"], "image.png", { type: "image/png" })); + form.append("file", new File(["%PDF-1.7"], "signed.pdf", { type: "application/pdf" })); + form.append("workOrderVersion", "AAAAAAAAB9E="); - await api.post("workorders/9/media", { body: form, throwHttpErrors: false }); + await apiRequestRaw("post", "workorders/10/completion-doc", { + body: form, + throwHttpErrors: false, + }); - expect(capturedContentType).not.toBe("application/json"); - // Browser sets multipart/form-data; boundary=... when Content-Type was stripped. - // In jsdom/fetch mock the runtime may leave it null or set multipart - both are OK - // as long as it is not forced JSON. - // Cast after expect(): Vitest assertion narrowing collapses string|null to never. - const contentType = capturedContentType as string | null; - if (contentType !== null) { - expect(contentType.toLowerCase()).toContain("multipart/form-data"); - } + const request = lastRequest(); + const boundary = /^multipart\/form-data; boundary=(.+)$/.exec(request.contentType ?? "")?.[1]; + expect(boundary, `Content-Type was ${String(request.contentType)}`).toBeTruthy(); + expect(request.body).toContain(`--${boundary}`); + expect(request.body).toContain('name="file"; filename="signed.pdf"'); + }); + + it("sends the multipart Content-Type for PATCH category updates and apiPostForm", async () => { + const { apiRequestRaw, apiPostForm } = await import("@/api/api"); + const category = new FormData(); + category.append("category", "Before"); + + await apiRequestRaw("patch", "workorders/10/media/5", { + body: category, + throwHttpErrors: false, + }); + expect(lastRequest().contentType).toMatch(/^multipart\/form-data; boundary=/); + + const edit = new FormData(); + edit.append("Id", "10"); + await apiPostForm("WorkOrder/EditWorkorder", edit); + expect(lastRequest().contentType).toMatch(/^multipart\/form-data; boundary=/); + }); + + it("keeps application/json for json bodies", async () => { + const { apiPost, apiPatch } = await import("@/api/api"); + + await apiPost("workorders/10/comments", { text: "hello" }); + expect(lastRequest()).toMatchObject({ + method: "POST", + contentType: "application/json", + body: '{"text":"hello"}', + }); + + await apiPatch("workorders/10/board", { field: "docStatus", value: "Yes" }); + expect(lastRequest().contentType).toBe("application/json"); }); }); diff --git a/src/test/setup.ts b/src/test/setup.ts index f2b26ff5..d435a466 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -39,24 +39,27 @@ if (!localStorageIsUsable()) { }); } -Object.defineProperty(window, "matchMedia", { - writable: true, - value: (query: string) => ({ - matches: false, - media: query, - onchange: null, - addListener: () => {}, - removeListener: () => {}, - addEventListener: () => {}, - removeEventListener: () => {}, - dispatchEvent: () => false, - }), -}); +// Files that opt into `@vitest-environment node` (real fetch/FormData semantics) have no window. +if (typeof window !== "undefined") { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), + }); -class ResizeObserverMock { - observe() {} - unobserve() {} - disconnect() {} + class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} + } + + window.ResizeObserver = ResizeObserverMock; } - -window.ResizeObserver = ResizeObserverMock;