mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 08:03:13 +00:00
Merge branch 'dev' into fix/ab/sh-339-wizard-complete-image
This commit is contained in:
commit
5f2d120965
4 changed files with 287 additions and 79 deletions
181
e2e/work-orders/completion-uploads-mobile.spec.ts
Normal file
181
e2e/work-orders/completion-uploads-mobile.spec.ts
Normal file
|
|
@ -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<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");
|
||||
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}`));
|
||||
});
|
||||
});
|
||||
|
|
@ -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)],
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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