2026-09-16 13:39:20 -03:00
|
|
|
import { devices, expect, test, type Page, type Request, type Route } from "@playwright/test";
|
|
|
|
|
|
2026-09-23 05:06:58 -03:00
|
|
|
// Signed completion PDFs remain multipart; Emergency/Reactive photos and videos use resumable chunks.
|
2026-09-16 13:39:20 -03:00
|
|
|
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) });
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-23 05:06:58 -03:00
|
|
|
type UploadSessionFixture = {
|
|
|
|
|
uploadId: string;
|
|
|
|
|
state: "Created" | "Uploading" | "Completed";
|
|
|
|
|
chunkSizeBytes: number;
|
|
|
|
|
sizeBytes: number;
|
|
|
|
|
receivedChunks: number[];
|
|
|
|
|
expiresAt: string;
|
|
|
|
|
attachment?: { id: number };
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-16 13:39:20 -03:00
|
|
|
function multipartBoundary(request: Request): string | undefined {
|
|
|
|
|
const contentType = request.headers()["content-type"] ?? "";
|
|
|
|
|
return /^multipart\/form-data; boundary=(.+)$/.exec(contentType)?.[1];
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 15:15:03 -03:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 13:39:20 -03:00
|
|
|
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");
|
2026-09-16 15:15:03 -03:00
|
|
|
await hideQueryDevtools(page);
|
2026-09-16 13:39:20 -03:00
|
|
|
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}`));
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-23 05:06:58 -03:00
|
|
|
test("uploads JPG, MP4 and MOV through acknowledged resumable chunks", async ({ page }) => {
|
2026-09-16 13:39:20 -03:00
|
|
|
const row = boardRow({ id: 72, woNumber: "RX-MEDIA-72", workOrderType: 6 });
|
|
|
|
|
await mockBoard(page, row);
|
|
|
|
|
await page.route("**/api/workorders/72/detail", (route) =>
|
2026-09-23 05:06:58 -03:00
|
|
|
fulfillJson(route, { info: row, completion: { docStatus: "No" }, media: [] }),
|
|
|
|
|
);
|
|
|
|
|
const sessions = new Map<string, UploadSessionFixture>();
|
|
|
|
|
const creates: Record<string, unknown>[] = [];
|
|
|
|
|
const chunks: { body: Buffer; headers: Record<string, string> }[] = [];
|
|
|
|
|
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 },
|
|
|
|
|
}),
|
2026-09-16 13:39:20 -03:00
|
|
|
);
|
2026-09-23 05:06:58 -03:00
|
|
|
await page.route("**/api/workorders/72/media/uploads", async (route) => {
|
2026-09-16 13:39:20 -03:00
|
|
|
const request = route.request();
|
2026-09-23 05:06:58 -03:00
|
|
|
const dto = request.postDataJSON() as Record<string, unknown>;
|
|
|
|
|
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);
|
2026-09-16 13:39:20 -03:00
|
|
|
});
|
2026-09-23 05:06:58 -03:00
|
|
|
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();
|
2026-09-16 13:39:20 -03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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();
|
2026-09-20 21:37:34 +00:00
|
|
|
const files = [
|
2026-09-16 13:39:20 -03:00
|
|
|
{ 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 },
|
2026-09-20 21:37:34 +00:00
|
|
|
];
|
|
|
|
|
await (await chooser).setFiles(files);
|
|
|
|
|
|
2026-09-23 05:06:58 -03:00
|
|
|
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();
|
2026-09-16 13:39:20 -03:00
|
|
|
});
|
|
|
|
|
});
|