shoc-frontend-new/src/test/api/upload-form-with-progress.test.ts
Arthur Bassi 74e5c58e5d fix(work-orders): restore completion media uploads over HTTP/1.1
Use XHR for media progress so Ky does not stream the body on HTTP/1.1
(Vite proxy), and keep completion vs extra tiles from mixing after upload.
2026-09-07 15:19:39 -03:00

184 lines
5.3 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const expireSession = vi.fn();
vi.mock("@/lib/auth/expire-session", () => ({
expireSessionAndRedirectToLogin: () => expireSession(),
}));
import { uploadFormWithProgress } from "@/api/upload-form-with-progress";
type Listener = (event?: unknown) => void;
class ListenerBag {
private readonly listeners = new Map<string, Listener[]>();
addEventListener(type: string, listener: Listener): void {
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
}
removeEventListener(type: string, listener: Listener): void {
this.listeners.set(
type,
(this.listeners.get(type) ?? []).filter((entry) => entry !== listener),
);
}
emit(type: string, event?: unknown): void {
for (const listener of [...(this.listeners.get(type) ?? [])]) {
listener(event);
}
}
}
class FakeXhr extends ListenerBag {
static last: FakeXhr | undefined;
readonly upload = new ListenerBag();
readonly headers: Record<string, string> = {};
status = 0;
responseText = "";
method = "";
url = "";
body: FormData | undefined;
abortCount = 0;
constructor() {
super();
FakeXhr.last = this;
}
open(method: string, url: string): void {
this.method = method;
this.url = url;
}
setRequestHeader(name: string, value: string): void {
this.headers[name] = value;
}
send(body: FormData): void {
this.body = body;
}
abort(): void {
this.abortCount += 1;
this.emit("abort");
}
respond(status: number, responseText = ""): void {
this.status = status;
this.responseText = responseText;
this.emit("load");
}
}
function currentXhr(): FakeXhr {
const instance = FakeXhr.last;
if (!instance) throw new Error("No XMLHttpRequest was created");
return instance;
}
function formWithFile(): FormData {
const body = new FormData();
body.append("file", new File(["bytes"], "after.jpg", { type: "image/jpeg" }));
return body;
}
describe("uploadFormWithProgress", () => {
const originalXhr = globalThis.XMLHttpRequest;
beforeEach(() => {
expireSession.mockReset();
FakeXhr.last = undefined;
globalThis.XMLHttpRequest = FakeXhr as unknown as typeof XMLHttpRequest;
localStorage.setItem("auth", JSON.stringify({ token: "abc123" }));
});
afterEach(() => {
globalThis.XMLHttpRequest = originalXhr;
localStorage.clear();
});
it("POSTs the FormData with the stored bearer token and no streamed body", async () => {
const pending = uploadFormWithProgress("workorders/10/media", formWithFile());
const request = currentXhr();
expect(request.method).toBe("POST");
expect(request.url.endsWith("/api/workorders/10/media")).toBe(true);
expect(request.headers.Authorization).toBe("Bearer abc123");
expect(request.body).toBeInstanceOf(FormData);
request.respond(201, JSON.stringify({ data: { id: 12 } }));
await expect(pending).resolves.toEqual({
ok: true,
status: 201,
data: { data: { id: 12 } },
});
});
it("reports upload progress as whole percentages", async () => {
const onProgress = vi.fn();
const pending = uploadFormWithProgress("workorders/10/media", formWithFile(), { onProgress });
const request = currentXhr();
request.upload.emit("progress", { lengthComputable: true, loaded: 25, total: 200 });
request.upload.emit("progress", { lengthComputable: false, loaded: 30, total: 200 });
request.respond(200, "{}");
await pending;
expect(onProgress.mock.calls).toEqual([[13]]);
});
it("resolves non-2xx responses so callers can map API errors", async () => {
const pending = uploadFormWithProgress("workorders/10/media", formWithFile());
currentXhr().respond(
422,
JSON.stringify({ code: "ReadOnly", message: "Work order is closed." }),
);
await expect(pending).resolves.toEqual({
ok: false,
status: 422,
data: { code: "ReadOnly", message: "Work order is closed." },
});
expect(expireSession).not.toHaveBeenCalled();
});
it("expires the session on 401", async () => {
const pending = uploadFormWithProgress("workorders/10/media", formWithFile());
currentXhr().respond(401);
await expect(pending).resolves.toMatchObject({ ok: false, status: 401 });
expect(expireSession).toHaveBeenCalledTimes(1);
});
it("rejects transport failures with an actionable message", async () => {
const pending = uploadFormWithProgress("workorders/10/media", formWithFile());
currentXhr().emit("error");
await expect(pending).rejects.toThrow(/Upload failed/);
});
it("aborts the request when the caller signal aborts", async () => {
const controller = new AbortController();
const pending = uploadFormWithProgress("workorders/10/media", formWithFile(), {
signal: controller.signal,
});
controller.abort();
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
expect(currentXhr().abortCount).toBe(1);
});
it("rejects immediately when the signal is already aborted", async () => {
await expect(
uploadFormWithProgress("workorders/10/media", formWithFile(), {
signal: AbortSignal.abort(),
}),
).rejects.toMatchObject({ name: "AbortError" });
expect(FakeXhr.last).toBeUndefined();
});
});