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(); 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 = {}; 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(); }); });