Files
voiceon-character-admin/src/features/audio-contents/tests/audio-upload.test.ts

305 lines
9.8 KiB
TypeScript

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<string, string>();
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 });
});