mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
fix: align browser fixtures with media upload contract
This commit is contained in:
parent
6321f176aa
commit
f822c2cc9b
3 changed files with 152 additions and 36 deletions
|
|
@ -136,6 +136,26 @@ async function mockPortalApi(page: Page, options: { sessionError?: boolean } = {
|
||||||
fulfill(route, { data: detailState.current }),
|
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) =>
|
await page.route(/\/api\/vendor-portal\/dispatches(?:\?.*)?$/, (route) =>
|
||||||
fulfill(route, { data: listDispatches }),
|
fulfill(route, { data: listDispatches }),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
32
e2e/vendors/vendor-uplift-workflow.spec.ts
vendored
32
e2e/vendors/vendor-uplift-workflow.spec.ts
vendored
|
|
@ -1,5 +1,21 @@
|
||||||
import { expect, test, type Route } from "@playwright/test";
|
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 = {
|
const INTERNAL_USER = {
|
||||||
token: "uplift-approver-token",
|
token: "uplift-approver-token",
|
||||||
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
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 request = route.request();
|
||||||
const path = new URL(request.url()).pathname;
|
const path = new URL(request.url()).pathname;
|
||||||
|
|
||||||
|
if (path.endsWith("/media") && request.method() === "GET") {
|
||||||
|
await fulfillJson(route, vendorMedia);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (path.endsWith("/session")) {
|
if (path.endsWith("/session")) {
|
||||||
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
|
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
|
||||||
return;
|
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) => {
|
await page.route("**/api/vendor-portal/**", async (route) => {
|
||||||
const request = route.request();
|
const request = route.request();
|
||||||
const path = new URL(request.url()).pathname;
|
const path = new URL(request.url()).pathname;
|
||||||
|
if (path.endsWith("/media") && request.method() === "GET") {
|
||||||
|
await fulfillJson(route, vendorMedia);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (path.endsWith("/session")) {
|
if (path.endsWith("/session")) {
|
||||||
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
|
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
|
||||||
return;
|
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) => {
|
await page.route("**/api/vendor-portal/**", async (route) => {
|
||||||
const request = route.request();
|
const request = route.request();
|
||||||
const path = new URL(request.url()).pathname;
|
const path = new URL(request.url()).pathname;
|
||||||
|
if (path.endsWith("/media") && request.method() === "GET") {
|
||||||
|
await fulfillJson(route, vendorMedia);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (path.endsWith("/session")) {
|
if (path.endsWith("/session")) {
|
||||||
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
|
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
|
||||||
return;
|
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) => {
|
await page.route("**/api/vendor-portal/**", async (route) => {
|
||||||
const request = route.request();
|
const request = route.request();
|
||||||
const path = new URL(request.url()).pathname;
|
const path = new URL(request.url()).pathname;
|
||||||
|
if (path.endsWith("/media") && request.method() === "GET") {
|
||||||
|
await fulfillJson(route, vendorMedia);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (path.endsWith("/session")) {
|
if (path.endsWith("/session")) {
|
||||||
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
|
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { devices, expect, test, type Page, type Request, type Route } from "@playwright/test";
|
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
|
// Signed completion PDFs remain multipart; Emergency/Reactive photos and videos use resumable chunks.
|
||||||
// multipart/form-data with a boundary, or ASP.NET cannot bind the [FromForm] file/category.
|
|
||||||
test.use({ ...devices["Pixel 7"] });
|
test.use({ ...devices["Pixel 7"] });
|
||||||
|
|
||||||
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
|
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) });
|
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 {
|
function multipartBoundary(request: Request): string | undefined {
|
||||||
const contentType = request.headers()["content-type"] ?? "";
|
const contentType = request.headers()["content-type"] ?? "";
|
||||||
return /^multipart\/form-data; boundary=(.+)$/.exec(contentType)?.[1];
|
return /^multipart\/form-data; boundary=(.+)$/.exec(contentType)?.[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
function multipartFileName(body: string): string | undefined {
|
|
||||||
return /filename="([^"]+)"/.exec(body)?.[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function hideQueryDevtools(page: Page) {
|
async function hideQueryDevtools(page: Page) {
|
||||||
await page.addStyleTag({
|
await page.addStyleTag({
|
||||||
content:
|
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}`));
|
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 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 mockBoard(page, row);
|
||||||
await page.route("**/api/workorders/72/detail", (route) =>
|
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 }[] = [];
|
const sessions = new Map<string, UploadSessionFixture>();
|
||||||
await page.route("**/api/workorders/72/media", (route) => {
|
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 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route("**/api/workorders/72/media/uploads", async (route) => {
|
||||||
const request = route.request();
|
const request = route.request();
|
||||||
const body = request.postDataBuffer()?.toString("latin1") ?? "";
|
const dto = request.postDataJSON() as Record<string, unknown>;
|
||||||
uploads.push({ contentType: request.headers()["content-type"] ?? "", body });
|
creates.push(dto);
|
||||||
const fileName = multipartFileName(body) ?? "file";
|
const uploadId = `media-${creates.length}`;
|
||||||
const item = { id: 900 + media.length, category: 3, url: `/Assets/${fileName}`, fileName };
|
const session: UploadSessionFixture = {
|
||||||
media.push(item);
|
uploadId,
|
||||||
return fulfillJson(route, item);
|
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/uploads/**", async (route) => {
|
||||||
await page.route("**/api/workorders/72/media/*", (route) => {
|
const request = route.request();
|
||||||
categorize = route.request();
|
const path = new URL(request.url()).pathname;
|
||||||
const item = media[0]!;
|
const match = /\/media\/uploads\/(media-\d+)(?:\/chunks\/(\d+)|\/commit)?$/.exec(path);
|
||||||
return fulfillJson(route, { ...item, category: 1 });
|
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);
|
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 (await chooser).setFiles(files);
|
||||||
|
|
||||||
await expect.poll(() => uploads.length).toBe(files.length);
|
await expect.poll(() => creates.length).toBe(files.length);
|
||||||
for (const { name } of files) {
|
await expect.poll(() => chunks.length).toBe(files.length);
|
||||||
const upload = uploads.find((item) => multipartFileName(item.body) === name);
|
await expect.poll(() => commits.length).toBe(files.length);
|
||||||
expect(upload, `multipart upload for ${name}`).toBeTruthy();
|
expect(creates.map((dto) => dto.surface)).toEqual(
|
||||||
expect(upload!.contentType).toMatch(/^multipart\/form-data; boundary=/);
|
Array.from({ length: files.length }, () => "CompletionMedia"),
|
||||||
}
|
);
|
||||||
|
expect(creates.map((dto) => dto.sizeBytes).sort()).toEqual(
|
||||||
await panel.getByRole("combobox", { name: "Category for IMG_0001.jpg" }).selectOption("Before");
|
files.map((file) => file.buffer.length).sort(),
|
||||||
await expect.poll(() => categorize?.method()).toBe("PATCH");
|
);
|
||||||
const boundary = multipartBoundary(categorize!);
|
expect(chunks.map((chunk) => chunk.headers["content-type"])).toEqual(
|
||||||
expect(boundary, categorize!.headers()["content-type"]).toBeTruthy();
|
Array.from({ length: files.length }, () => "application/octet-stream"),
|
||||||
const body = categorize!.postDataBuffer()?.toString("latin1") ?? "";
|
);
|
||||||
expect(body).toMatch(/name="category"\r\n\r\nBefore/);
|
expect(chunks.map((chunk) => chunk.body.toString("hex")).sort()).toEqual(
|
||||||
expect(body).toMatch(new RegExp(`name="workOrderVersion"\\r\\n\\r\\n${ROW_VERSION}`));
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue