mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 06:53:12 +00:00
fix(api): send multipart uploads with their Content-Type (SH-368)
The shared ky instance declared Content-Type: application/json for every request. Ky bakes instance headers into the Request it builds, so a FormData body left with that header, and the beforeRequest hook that deleted it ran after the boundary had already been dropped: the multipart body went out with no Content-Type at all. ASP.NET then cannot bind [FromForm] fields, so the completion-doc upload (dialog and slide-over "Upload signed PDF") returned 400 "The file field is required.", media category updates arrived without category or workOrderVersion, and the legacy edit form post lost its fields. Drop the instance-wide Content-Type. Ky still sets application/json for json bodies, and the runtime sets multipart/form-data with the boundary. The old unit test tolerated a missing header, and ran under jsdom whose FormData the Node Request cannot serialize. It now runs in the node environment and requires a multipart Content-Type whose boundary matches the body. A Pixel 7 viewport e2e covers the signed PDF upload and JPG/MP4/MOV media upload plus categorize.
This commit is contained in:
parent
fc3e7591dc
commit
5d53b8e810
4 changed files with 274 additions and 79 deletions
168
e2e/work-orders/completion-uploads-mobile.spec.ts
Normal file
168
e2e/work-orders/completion-uploads-mobile.spec.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import { devices, expect, test, type Page, type Request, type Route } from "@playwright/test";
|
||||
|
||||
// SH-368 / SH-370: 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 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");
|
||||
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}`));
|
||||
});
|
||||
});
|
||||
|
|
@ -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 (SH-368).
|
||||
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)],
|
||||
|
|
|
|||
|
|
@ -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 (SH-368)", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue