import { z } from "zod"; import { afterEach, expect, test, vi } from "vitest"; import { uploadAudioContent } from "@/features/audio-contents/api/upload-audio-content"; import { authSessionStorage } from "@/features/auth/model/auth-session-storage"; import { UNKNOWN_API_ERROR_MESSAGE } from "@/shared/api/api-error"; class FakeXMLHttpRequest { static instances: FakeXMLHttpRequest[] = []; readonly headers = new Map(); readonly upload = new EventTarget(); aborted = false; body: XMLHttpRequestBodyInit | null = null; method = ""; onerror: ((event: Event) => void) | null = null; onload: ((event: Event) => void) | null = null; readyState = 0; responseText = ""; status = 0; url = ""; constructor() { FakeXMLHttpRequest.instances.push(this); } abort(): void { this.aborted = true; } open(method: string, url: string): void { this.method = method; this.url = url; } setRequestHeader(name: string, value: string): void { this.headers.set(name, value); } send(body: XMLHttpRequestBodyInit | null): void { this.body = body; } progress(loaded: number, total: number): void { this.upload.dispatchEvent(new ProgressEvent("progress", { lengthComputable: true, loaded, total })); } fail(status: number, message: string): void { this.status = status; this.responseText = JSON.stringify({ success: false, message, data: null, errorProperty: "contentFile" }); this.onload?.(new Event("load")); } failWithBody(status: number, body: string): void { this.status = status; this.responseText = body; this.onload?.(new Event("load")); } failNetwork(): void { this.onerror?.(new Event("error")); } succeed(data: unknown): void { this.status = 200; this.responseText = JSON.stringify({ success: true, message: null, data, errorProperty: null }); this.onload?.(new Event("load")); } succeedWithInvalidJson(): void { this.status = 200; this.responseText = "not-json"; this.onload?.(new Event("load")); } } const createResponseSchema = z.object({ contentId: z.number().int() }); afterEach(() => { FakeXMLHttpRequest.instances = []; vi.unstubAllEnvs(); }); function installFakeUpload() { vi.stubEnv("VITE_API_BASE_URL", "https://api.example.com"); vi.stubEnv("VITE_API_MODE", "server"); vi.stubGlobal("XMLHttpRequest", FakeXMLHttpRequest); authSessionStorage.save({ token: "admin-token", role: "ADMIN" }); } test("uploadAudioContent reports progress and parses success through the API response schema", async () => { // Given installFakeUpload(); const progress: number[] = []; const body = new FormData(); // When const upload = uploadAudioContent({ authentication: "required", body, method: "POST", onProgress: (value) => progress.push(value), path: "/api/v2/admin/ai-characters/101/audio-contents", responseSchema: createResponseSchema, }); const request = FakeXMLHttpRequest.instances[0]; if (request === undefined) { throw new Error("expected upload request"); } request.progress(256, 1024); request.succeed({ contentId: 9301 }); // Then await expect(upload).resolves.toEqual({ contentId: 9301 }); expect(request.method).toBe("POST"); expect(request.url).toBe("https://api.example.com/api/v2/admin/ai-characters/101/audio-contents"); expect(request.headers.get("Accept-Language")).toBe("ko"); expect(request.headers.get("Authorization")).toBe("Bearer admin-token"); expect(request.body).toBe(body); expect(progress).toEqual([25]); }); test("uploadAudioContent aborts through AbortController and full retry creates a fresh request", async () => { // Given installFakeUpload(); const body = new FormData(); const controller = new AbortController(); // When const canceled = uploadAudioContent({ authentication: "required", body, method: "POST", path: "/api/v2/admin/ai-characters/101/audio-contents", responseSchema: createResponseSchema, signal: controller.signal, }); const firstRequest = FakeXMLHttpRequest.instances[0]; if (firstRequest === undefined) { throw new Error("expected first upload request"); } controller.abort(); // Then await expect(canceled).rejects.toMatchObject({ name: "AbortError" }); expect(firstRequest.aborted).toBe(true); // When const retry = uploadAudioContent({ authentication: "required", body, method: "POST", path: "/api/v2/admin/ai-characters/101/audio-contents", responseSchema: createResponseSchema, }); const secondRequest = FakeXMLHttpRequest.instances[1]; if (secondRequest === undefined) { throw new Error("expected retry upload request"); } secondRequest.succeed({ contentId: 9302 }); // Then await expect(retry).resolves.toEqual({ contentId: 9302 }); expect(secondRequest).not.toBe(firstRequest); expect(secondRequest.body).toBe(body); }); test("uploadAudioContent preserves 415 API error details without resumable upload state", async () => { // Given installFakeUpload(); // When const upload = uploadAudioContent({ authentication: "required", body: new FormData(), method: "POST", path: "/api/v2/admin/ai-characters/101/audio-contents", responseSchema: createResponseSchema, }); const request = FakeXMLHttpRequest.instances[0]; if (request === undefined) { throw new Error("expected upload request"); } request.fail(415, "지원하지 않는 오디오 형식입니다."); // Then await expect(upload).rejects.toMatchObject({ status: 415, message: "지원하지 않는 오디오 형식입니다.", errorProperty: "contentFile" }); expect(FakeXMLHttpRequest.instances).toHaveLength(1); }); test("uploadAudioContent clears session once for a burst of protected 401 upload failures", async () => { // Given installFakeUpload(); const clearSession = vi.fn(); const onAuthExpired = vi.fn(); const auth = { clearSession, getToken: () => "admin-token", onAuthExpired }; // When const firstUpload = uploadAudioContent({ auth, authentication: "required", body: new FormData(), method: "POST", path: "/api/v2/admin/ai-characters/101/audio-contents", responseSchema: createResponseSchema, }); const secondUpload = uploadAudioContent({ auth, authentication: "required", body: new FormData(), method: "POST", path: "/api/v2/admin/ai-characters/101/audio-contents", responseSchema: createResponseSchema, }); const firstRequest = FakeXMLHttpRequest.instances[0]; const secondRequest = FakeXMLHttpRequest.instances[1]; if (firstRequest === undefined || secondRequest === undefined) { throw new Error("expected upload requests"); } firstRequest.failWithBody(401, JSON.stringify({ success: false, message: "세션이 만료되었습니다.", data: null, errorProperty: null })); secondRequest.failWithBody(401, "not-json"); // Then await expect(firstUpload).rejects.toMatchObject({ status: 401 }); await expect(secondUpload).rejects.toMatchObject({ status: 401 }); expect(clearSession).toHaveBeenCalledTimes(1); expect(onAuthExpired).toHaveBeenCalledTimes(1); }); test("uploadAudioContent keeps session for non-401 upload failures", async () => { // Given installFakeUpload(); const clearSession = vi.fn(); const onAuthExpired = vi.fn(); // When const upload = uploadAudioContent({ auth: { clearSession, getToken: () => "admin-token", onAuthExpired }, authentication: "required", body: new FormData(), method: "POST", path: "/api/v2/admin/ai-characters/101/audio-contents", responseSchema: createResponseSchema, }); const request = FakeXMLHttpRequest.instances[0]; if (request === undefined) { throw new Error("expected upload request"); } request.failWithBody(403, JSON.stringify({ success: false, message: "권한이 없습니다.", data: null, errorProperty: null })); // Then await expect(upload).rejects.toMatchObject({ status: 403 }); expect(clearSession).not.toHaveBeenCalled(); expect(onAuthExpired).not.toHaveBeenCalled(); }); test("uploadAudioContent normalizes malformed success response to the shared unknown API error", async () => { // Given installFakeUpload(); // When const upload = uploadAudioContent({ authentication: "required", body: new FormData(), method: "POST", path: "/api/v2/admin/ai-characters/101/audio-contents", responseSchema: createResponseSchema, }); const request = FakeXMLHttpRequest.instances[0]; if (request === undefined) { throw new Error("expected upload request"); } request.succeedWithInvalidJson(); // Then await expect(upload).rejects.toMatchObject({ status: 200, message: UNKNOWN_API_ERROR_MESSAGE, errorProperty: null }); }); test.each([ ["network failure", (request: FakeXMLHttpRequest) => request.failNetwork(), 0], ["malformed error JSON", (request: FakeXMLHttpRequest) => request.failWithBody(500, "not-json"), 500], ["malformed error envelope", (request: FakeXMLHttpRequest) => request.failWithBody(500, JSON.stringify({ success: false, data: null, errorProperty: null })), 500], ["empty server message", (request: FakeXMLHttpRequest) => request.failWithBody(500, JSON.stringify({ success: false, message: "", data: null, errorProperty: null })), 500], ])("uploadAudioContent normalizes %s to the shared unknown API error", async (_label, fail, status) => { // Given installFakeUpload(); // When const upload = uploadAudioContent({ authentication: "required", body: new FormData(), method: "POST", path: "/api/v2/admin/ai-characters/101/audio-contents", responseSchema: createResponseSchema, }); const request = FakeXMLHttpRequest.instances[0]; if (request === undefined) { throw new Error("expected upload request"); } fail(request); // Then await expect(upload).rejects.toMatchObject({ status, message: UNKNOWN_API_ERROR_MESSAGE, errorProperty: null }); });