feat(ai-character): 오디오 콘텐츠 관리 기능 구현
This commit is contained in:
304
src/features/audio-contents/tests/audio-upload.test.ts
Normal file
304
src/features/audio-contents/tests/audio-upload.test.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
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 });
|
||||
});
|
||||
Reference in New Issue
Block a user