mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 08:03:13 +00:00
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.
184 lines
5.3 KiB
TypeScript
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();
|
|
});
|
|
});
|