mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-04 18:22:05 +00:00
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.
168 lines
7.2 KiB
TypeScript
168 lines
7.2 KiB
TypeScript
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}`));
|
|
});
|
|
});
|