From f822c2cc9b18d8bfd8eb9fb5c1a93f3819c8b241 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 23 Sep 2026 05:06:58 -0300 Subject: [PATCH] fix: align browser fixtures with media upload contract --- e2e/vendor-portal/portal-lifecycle.spec.ts | 20 +++ e2e/vendors/vendor-uplift-workflow.spec.ts | 32 +++++ .../completion-uploads-mobile.spec.ts | 136 +++++++++++++----- 3 files changed, 152 insertions(+), 36 deletions(-) diff --git a/e2e/vendor-portal/portal-lifecycle.spec.ts b/e2e/vendor-portal/portal-lifecycle.spec.ts index 294051a6..ed573ad9 100644 --- a/e2e/vendor-portal/portal-lifecycle.spec.ts +++ b/e2e/vendor-portal/portal-lifecycle.spec.ts @@ -136,6 +136,26 @@ async function mockPortalApi(page: Page, options: { sessionError?: boolean } = { fulfill(route, { data: detailState.current }), ); + await page.route(/\/api\/vendor-portal\/dispatches\/\d+\/media$/, (route) => + fulfill(route, { + data: { + policy: { + workOrderType: "Emergency", + canUpload: true, + limits: { + photoMaxBytes: 10_000_000, + videoMaxBytes: 100_000_000, + videoMaxSeconds: 90, + maxPhotos: 10, + maxVideos: 3, + }, + remaining: { photos: 10, videos: 3 }, + }, + items: [], + }, + }), + ); + await page.route(/\/api\/vendor-portal\/dispatches(?:\?.*)?$/, (route) => fulfill(route, { data: listDispatches }), ); diff --git a/e2e/vendors/vendor-uplift-workflow.spec.ts b/e2e/vendors/vendor-uplift-workflow.spec.ts index 69205a2e..c826dd1c 100644 --- a/e2e/vendors/vendor-uplift-workflow.spec.ts +++ b/e2e/vendors/vendor-uplift-workflow.spec.ts @@ -1,5 +1,21 @@ import { expect, test, type Route } from "@playwright/test"; +const vendorMedia = { + policy: { + workOrderType: "Reactive", + canUpload: true, + limits: { + photoMaxBytes: 10_000_000, + videoMaxBytes: 100_000_000, + videoMaxSeconds: 90, + maxPhotos: 10, + maxVideos: 3, + }, + remaining: { photos: 10, videos: 3 }, + }, + items: [], +}; + const INTERNAL_USER = { token: "uplift-approver-token", expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(), @@ -26,6 +42,10 @@ test("vendor uploads scanned evidence and submits a dispatch-scoped uplift", asy const request = route.request(); const path = new URL(request.url()).pathname; + if (path.endsWith("/media") && request.method() === "GET") { + await fulfillJson(route, vendorMedia); + return; + } if (path.endsWith("/session")) { await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" }); return; @@ -122,6 +142,10 @@ test("vendor can withdraw a pending uplift from the dispatch", async ({ page }) await page.route("**/api/vendor-portal/**", async (route) => { const request = route.request(); const path = new URL(request.url()).pathname; + if (path.endsWith("/media") && request.method() === "GET") { + await fulfillJson(route, vendorMedia); + return; + } if (path.endsWith("/session")) { await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" }); return; @@ -172,6 +196,10 @@ test("vendor receives the no-approval result for a request within the current NT await page.route("**/api/vendor-portal/**", async (route) => { const request = route.request(); const path = new URL(request.url()).pathname; + if (path.endsWith("/media") && request.method() === "GET") { + await fulfillJson(route, vendorMedia); + return; + } if (path.endsWith("/session")) { await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" }); return; @@ -216,6 +244,10 @@ test("vendor revises a changes-requested uplift using its existing evidence", as await page.route("**/api/vendor-portal/**", async (route) => { const request = route.request(); const path = new URL(request.url()).pathname; + if (path.endsWith("/media") && request.method() === "GET") { + await fulfillJson(route, vendorMedia); + return; + } if (path.endsWith("/session")) { await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" }); return; diff --git a/e2e/work-orders/completion-uploads-mobile.spec.ts b/e2e/work-orders/completion-uploads-mobile.spec.ts index f778567b..86c40555 100644 --- a/e2e/work-orders/completion-uploads-mobile.spec.ts +++ b/e2e/work-orders/completion-uploads-mobile.spec.ts @@ -1,7 +1,6 @@ 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. +// Signed completion PDFs remain multipart; Emergency/Reactive photos and videos use resumable chunks. test.use({ ...devices["Pixel 7"] }); const FROZEN_NOW = "2026-08-19T15:00:00.000Z"; @@ -45,15 +44,21 @@ async function fulfillJson(route: Route, body: unknown, status = 200) { await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); } +type UploadSessionFixture = { + uploadId: string; + state: "Created" | "Uploading" | "Completed"; + chunkSizeBytes: number; + sizeBytes: number; + receivedChunks: number[]; + expiresAt: string; + attachment?: { id: number }; +}; + 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: @@ -133,28 +138,84 @@ test.describe("Completion uploads on a mobile viewport", () => { 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 }) => { + test("uploads JPG, MP4 and MOV through acknowledged resumable chunks", 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 }), + fulfillJson(route, { info: row, completion: { docStatus: "No" }, media: [] }), ); - const uploads: { contentType: string; body: string }[] = []; - await page.route("**/api/workorders/72/media", (route) => { + const sessions = new Map(); + const creates: Record[] = []; + const chunks: { body: Buffer; headers: Record }[] = []; + const commits: string[] = []; + await page.route("**/api/workorders/72/media/policy", (route) => + fulfillJson(route, { + workOrderType: "Reactive", + canUpload: true, + limits: { + photoMaxBytes: 10_000_000, + videoMaxBytes: 100_000_000, + videoMaxSeconds: 90, + maxPhotos: 10, + maxVideos: 3, + }, + remaining: { photos: 10, videos: 3 }, + }), + ); + await page.route("**/api/workorders/72/media/uploads", async (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); + const dto = request.postDataJSON() as Record; + creates.push(dto); + const uploadId = `media-${creates.length}`; + const session: UploadSessionFixture = { + uploadId, + state: "Created", + chunkSizeBytes: 5_242_880, + sizeBytes: Number(dto.sizeBytes), + receivedChunks: [], + expiresAt: "2026-08-20T15:00:00.000Z", + }; + sessions.set(uploadId, session); + await fulfillJson(route, session); }); - 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 }); + await page.route("**/api/workorders/72/media/uploads/**", async (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname; + const match = /\/media\/uploads\/(media-\d+)(?:\/chunks\/(\d+)|\/commit)?$/.exec(path); + if (!match) return route.fallback(); + const uploadId = match[1]!; + const index = match[2]; + const session = sessions.get(uploadId)!; + + if (request.method() === "PUT" && index !== undefined) { + const body = request.postDataBuffer() ?? Buffer.alloc(0); + chunks.push({ body, headers: request.headers() }); + session.receivedChunks = [...new Set([...session.receivedChunks, Number(index)])]; + session.state = "Uploading"; + await fulfillJson(route, { + index: Number(index), + sha256: request.headers()["x-chunk-sha256"], + sizeBytes: body.length, + receivedChunks: session.receivedChunks, + }); + return; + } + if (request.method() === "POST" && path.endsWith("/commit")) { + commits.push(uploadId); + session.state = "Completed"; + session.attachment = { id: 900 + commits.length }; + await fulfillJson(route, session); + return; + } + if (request.method() === "GET") { + await fulfillJson(route, session); + return; + } + if (request.method() === "DELETE") { + await fulfillJson(route, {}); + return; + } + await route.fallback(); }); const panel = await openCompletionTab(page, row.woNumber); @@ -167,19 +228,22 @@ test.describe("Completion uploads on a mobile viewport", () => { ]; 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}`)); + await expect.poll(() => creates.length).toBe(files.length); + await expect.poll(() => chunks.length).toBe(files.length); + await expect.poll(() => commits.length).toBe(files.length); + expect(creates.map((dto) => dto.surface)).toEqual( + Array.from({ length: files.length }, () => "CompletionMedia"), + ); + expect(creates.map((dto) => dto.sizeBytes).sort()).toEqual( + files.map((file) => file.buffer.length).sort(), + ); + expect(chunks.map((chunk) => chunk.headers["content-type"])).toEqual( + Array.from({ length: files.length }, () => "application/octet-stream"), + ); + expect(chunks.map((chunk) => chunk.body.toString("hex")).sort()).toEqual( + files.map((file) => file.buffer.toString("hex")).sort(), + ); + expect([...sessions.values()].every((session) => session.state === "Completed")).toBe(true); + await expect(page.getByText("File uploaded").first()).toBeVisible(); }); });