feat(ai-character): 리소스 관리 기반 정비

This commit is contained in:
Yu Sung
2026-08-01 01:30:24 +09:00
parent 55ba0df77a
commit a1cae336d9
73 changed files with 5162 additions and 816 deletions

1
.gitignore vendored
View File

@@ -4,6 +4,7 @@
.idea
.codex
.opencode
.DS_Store
mise.toml

View File

@@ -14,7 +14,10 @@
"test": "vitest",
"test:run": "vitest run",
"e2e": "VITE_API_MODE=server playwright test",
"e2e:mock": "VITE_API_MODE=mock playwright test"
"e2e:mock": "sh -c 'if [ \"$#\" -gt 0 ]; then VITE_API_MODE=mock playwright test \"$@\"; else npm run e2e:mock:chromium && npm run e2e:mock:mobile-chrome; fi' --",
"e2e:mock:raw": "VITE_API_MODE=mock playwright test",
"e2e:mock:chromium": "VITE_API_MODE=mock playwright test --project=chromium --workers=1",
"e2e:mock:mobile-chrome": "VITE_API_MODE=mock playwright test --project=mobile-chrome --workers=1"
},
"dependencies": {
"@tanstack/react-query": "^5.101.4",

View File

@@ -8,7 +8,14 @@ const serverTestMatch = [
"**/auth.spec.ts",
"**/accessibility-shell.spec.ts",
] as const;
const mockTestMatch = ["**/mock-preview-shell.spec.ts", "**/mock-mode-boundary.spec.ts"] as const;
const serverDomainTestMatch = [
"**/series.spec.ts",
"**/fan-talk.spec.ts",
"**/comments.spec.ts",
] as const;
const mockTestMatch = ["**/mock-preview-shell.spec.ts", "**/mock-mode-boundary.spec.ts", "**/character-workspace.spec.ts", "**/audio-content.spec.ts", "**/series.spec.ts", "**/community.spec.ts", "**/fan-talk.spec.ts", "**/comments.spec.ts", "**/resource-workflows.spec.ts", "**/error-mapping.spec.ts", "**/responsive-capabilities.spec.ts", "**/accessibility.spec.ts"] as const;
const serverDomainSpecNames = ["series.spec.ts", "fan-talk.spec.ts", "comments.spec.ts"] as const;
const requestedServerDomainTestMatch = serverDomainTestMatch.filter((_, index) => process.argv.some((argument) => argument.endsWith(serverDomainSpecNames[index] ?? "")));
export default defineConfig({
testDir: "./tests/e2e",
@@ -16,16 +23,16 @@ export default defineConfig({
command: apiMode === "mock" ? "npm run dev:mock" : "npm run dev",
env: { VITE_API_MODE: apiMode },
url: `http://127.0.0.1:${webServerPort}`,
reuseExistingServer: !process.env.CI && apiMode === "server",
reuseExistingServer: !process.env.CI,
},
use: {
baseURL: `http://127.0.0.1:${webServerPort}`,
},
testMatch: apiMode === "mock" ? [...mockTestMatch] : [...serverTestMatch],
testMatch: apiMode === "mock" ? [...mockTestMatch] : [...serverTestMatch, ...requestedServerDomainTestMatch],
timeout: apiMode === "mock" ? 60_000 : undefined,
workers: apiMode === "mock" ? 1 : undefined,
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
{ name: "mobile-chrome", use: { ...devices["Pixel 5"] } },
{ name: "mobile-safari", use: { ...devices["iPhone 12"] } },
],
});

101
src/app/App.logout.test.tsx Normal file
View File

@@ -0,0 +1,101 @@
import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { App } from "./App";
import { apiBaseUrl, saveAdminSession, useAiCharactersFailure, useAiCharactersResponse } from "./app-test-support";
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
import { server } from "@/shared/test/server";
beforeEach(() => {
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
});
afterEach(() => {
vi.unstubAllEnvs();
window.history.replaceState({}, "", "/");
});
test("clears a previous protected route verification before reusing the same token after login", async () => {
saveAdminSession();
useAiCharactersResponse();
window.history.pushState({}, "", "/ai-characters");
server.use(
http.post(`${apiBaseUrl}/member/logout`, () => HttpResponse.json({ success: true, message: null, data: {}, errorProperty: null })),
http.post(`${apiBaseUrl}/admin/member/login`, () => HttpResponse.json({ success: true, message: null, data: { token: "admin-token", role: "ADMIN" }, errorProperty: null })),
);
render(<App />);
fireEvent.click(await screen.findByRole("button", { name: "로그아웃" }));
await waitFor(() => expect(window.location.pathname).toBe("/login"));
useAiCharactersFailure(404);
fireEvent.change(screen.getByLabelText("이메일"), { target: { value: "admin@test.com" } });
fireEvent.change(screen.getByLabelText("비밀번호"), { target: { value: "password" } });
fireEvent.click(screen.getByRole("button", { name: "로그인" }));
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters"));
expect(await screen.findByRole("alert")).toHaveTextContent("없습니다.");
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
});
test("shows a login warning when server logout confirmation fails", async () => {
saveAdminSession();
useAiCharactersResponse();
window.history.pushState({}, "", "/ai-characters");
server.use(http.post(`${apiBaseUrl}/member/logout`, () => HttpResponse.json({ success: false, message: "로그아웃 확인 실패", data: null, errorProperty: null }, { status: 500 })));
render(<App />);
fireEvent.click(await screen.findByRole("button", { name: "로그아웃" }));
await waitFor(() => expect(window.location.pathname).toBe("/login"));
expect(authSessionStorage.read()).toBeNull();
expect(screen.getByRole("alert")).toHaveTextContent("서버 로그아웃 확인에 실패했습니다.");
});
test("logs out from the protected shell and routes to login", async () => {
saveAdminSession();
useAiCharactersResponse();
window.history.pushState({}, "", "/ai-characters");
server.use(http.post(`${apiBaseUrl}/member/logout`, ({ request }) => {
expect(request.headers.get("Authorization")).toBe("Bearer admin-token");
return HttpResponse.json({ success: true, message: null, data: {}, errorProperty: null });
}));
render(<App />);
fireEvent.click(await screen.findByRole("button", { name: "로그아웃" }));
await waitFor(() => expect(window.location.pathname).toBe("/login"));
expect(authSessionStorage.read()).toBeNull();
});
test("sends only one logout request while the first logout is in flight", async () => {
saveAdminSession();
useAiCharactersResponse();
window.history.pushState({}, "", "/ai-characters");
let logoutCount = 0;
let resolveLogoutReady: (finishLogout: () => void) => void = () => undefined;
const logoutReady = new Promise<() => void>((resolve) => {
resolveLogoutReady = resolve;
});
server.use(http.post(`${apiBaseUrl}/member/logout`, () => {
logoutCount += 1;
return new Promise((resolve) => {
resolveLogoutReady(() => resolve(HttpResponse.json({ success: true, message: null, data: {}, errorProperty: null })));
});
}));
render(<App />);
const logoutButton = await screen.findByRole("button", { name: "로그아웃" });
fireEvent.click(logoutButton);
fireEvent.click(logoutButton);
await waitFor(() => expect(logoutCount).toBe(1));
const finishLogout = await logoutReady;
finishLogout();
await waitForElementToBeRemoved(logoutButton);
expect(window.location.pathname).toBe("/login");
});

View File

@@ -0,0 +1,229 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { App } from "./App";
import { apiBaseUrl, saveAdminSession, useAiCharactersFailure, useAiCharactersResponse } from "./app-test-support";
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
import { UNKNOWN_API_ERROR_MESSAGE } from "@/shared/api/api-error";
import { server } from "@/shared/test/server";
beforeEach(() => {
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
});
afterEach(() => {
vi.unstubAllEnvs();
window.history.replaceState({}, "", "/");
});
test("clears the session and routes to login when the protected route request returns 401", async () => {
saveAdminSession();
useAiCharactersResponse(401);
window.history.pushState({}, "", "/ai-characters");
render(<App />);
await waitFor(() => expect(window.location.pathname).toBe("/login"));
expect(authSessionStorage.read()).toBeNull();
expect(screen.getByRole("heading", { name: "관리자 로그인" })).toBeInTheDocument();
expect(screen.getByRole("alert")).toHaveTextContent("세션이 만료되었습니다. 다시 로그인하세요.");
});
test("routes to access denied without clearing the session when the protected route request returns 403", async () => {
saveAdminSession();
useAiCharactersResponse(403);
window.history.pushState({}, "", "/ai-characters");
render(<App />);
await waitFor(() => expect(window.location.pathname).toBe("/access-denied"));
expect(authSessionStorage.read()).toEqual({ token: "admin-token", role: "ADMIN" });
expect(screen.getByRole("heading", { name: "접근 권한이 없습니다" })).toBeInTheDocument();
expect(screen.queryByText("루나")).not.toBeInTheDocument();
});
test.each(["/ai-characters/new", "/ai-characters/101/edit"])("routes %s to access denied before rendering the form when the protected route request returns 403", async (path) => {
saveAdminSession();
useAiCharactersResponse(403);
window.history.pushState({}, "", path);
render(<App />);
await waitFor(() => expect(window.location.pathname).toBe("/access-denied"));
expect(authSessionStorage.read()).toEqual({ token: "admin-token", role: "ADMIN" });
expect(screen.getByRole("heading", { name: "접근 권한이 없습니다" })).toBeInTheDocument();
expect(screen.queryByLabelText("이름")).not.toBeInTheDocument();
});
test.each(["/ai-characters/new", "/ai-characters/101/edit"])("routes %s to login before rendering the form when the protected route request returns 401", async (path) => {
saveAdminSession();
useAiCharactersResponse(401);
window.history.pushState({}, "", path);
render(<App />);
await waitFor(() => expect(window.location.pathname).toBe("/login"));
expect(authSessionStorage.read()).toBeNull();
expect(screen.getByRole("heading", { name: "관리자 로그인" })).toBeInTheDocument();
expect(screen.queryByLabelText("이름")).not.toBeInTheDocument();
});
test("keeps the mock mode banner visible on the access denied page", async () => {
vi.stubEnv("VITE_API_MODE", "mock");
saveAdminSession();
useAiCharactersResponse(403);
window.history.pushState({}, "", "/ai-characters");
render(<App />);
await waitFor(() => expect(window.location.pathname).toBe("/access-denied"));
expect(screen.getByRole("heading", { name: "접근 권한이 없습니다" })).toBeInTheDocument();
expect(screen.getByRole("status", { name: "Mock Preview" })).toHaveTextContent("Mock Preview");
});
test("keeps the protected shell hidden when the protected route request returns 404", async () => {
saveAdminSession();
useAiCharactersFailure(404);
window.history.pushState({}, "", "/ai-characters");
render(<App />);
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
expect(await screen.findByRole("alert")).toHaveTextContent("없습니다.");
expect(screen.queryByRole("banner")).not.toBeInTheDocument();
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
});
test("keeps the mock mode banner visible on protected route errors", async () => {
vi.stubEnv("VITE_API_MODE", "mock");
saveAdminSession();
useAiCharactersFailure(404);
window.history.pushState({}, "", "/ai-characters");
render(<App />);
expect(await screen.findByRole("alert")).toHaveTextContent("없습니다.");
expect(screen.getByRole("status", { name: "Mock Preview" })).toHaveTextContent("Mock Preview");
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
});
test("keeps the protected shell hidden when the protected route request has a network error", async () => {
saveAdminSession();
useAiCharactersFailure("network");
window.history.pushState({}, "", "/ai-characters");
render(<App />);
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
expect(await screen.findByRole("alert")).toHaveTextContent(UNKNOWN_API_ERROR_MESSAGE);
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
});
test("retries a protected route 404 and reveals the shell only after the current retry succeeds", async () => {
saveAdminSession();
let requestCount = 0;
let resolveRetryReady: (finishRetry: () => void) => void = () => undefined;
const retryReady = new Promise<() => void>((resolve) => {
resolveRetryReady = resolve;
});
server.use(
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () => {
requestCount += 1;
if (requestCount === 1) {
return HttpResponse.json({ success: false, message: "없습니다.", data: null, errorProperty: null }, { status: 404 });
}
return new Promise((resolve) => {
resolveRetryReady(() => resolve(HttpResponse.json({ success: true, message: null, data: { totalCount: 0, content: [] }, errorProperty: null })));
});
}),
);
window.history.pushState({}, "", "/ai-characters");
render(<App />);
const alert = await screen.findByRole("alert");
const retryButton = screen.getByRole("button", { name: "보호 route 다시 시도" });
retryButton.focus();
expect(alert).toHaveTextContent("없습니다.");
expect(retryButton).toHaveFocus();
expect(requestCount).toBe(1);
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
fireEvent.click(retryButton);
const finishRetry = await retryReady;
expect(requestCount).toBe(2);
expect(screen.getByRole("status")).toHaveTextContent("보호 route 확인 중");
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "로그아웃" })).not.toBeInTheDocument();
finishRetry();
expect(await screen.findByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "로그아웃" })).toBeInTheDocument();
});
test("keeps retry available and the protected shell hidden when a network retry fails", async () => {
saveAdminSession();
let requestCount = 0;
server.use(http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () => {
requestCount += 1;
return HttpResponse.error();
}));
window.history.pushState({}, "", "/ai-characters");
render(<App />);
const retryButton = await screen.findByRole("button", { name: "보호 route 다시 시도" });
expect(screen.getByRole("alert")).toHaveTextContent(UNKNOWN_API_ERROR_MESSAGE);
expect(requestCount).toBe(1);
fireEvent.click(retryButton);
await waitFor(() => expect(requestCount).toBe(2));
expect(await screen.findByRole("button", { name: "보호 route 다시 시도" })).toBeInTheDocument();
expect(screen.getByRole("alert")).toHaveTextContent(UNKNOWN_API_ERROR_MESSAGE);
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "로그아웃" })).not.toBeInTheDocument();
});
test("clears a previous protected route verification before the same session re-enters the route", async () => {
saveAdminSession();
useAiCharactersResponse();
window.history.pushState({}, "", "/ai-characters");
render(<App />);
expect(await screen.findByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
window.history.pushState({}, "", "/login");
fireEvent.popState(window);
await waitFor(() => expect(screen.getByRole("heading", { name: "관리자 로그인" })).toBeInTheDocument());
useAiCharactersFailure(404);
window.history.pushState({}, "", "/ai-characters");
fireEvent.popState(window);
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
expect(await screen.findByRole("alert")).toHaveTextContent("없습니다.");
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
});
test("keeps the protected shell hidden while a stale ADMIN probe is pending and then denied", async () => {
saveAdminSession();
window.history.pushState({}, "", "/ai-characters");
let resolveDenyProbeReady: (denyProbe: () => void) => void = () => undefined;
const denyProbeReady = new Promise<() => void>((resolve) => {
resolveDenyProbeReady = resolve;
});
server.use(
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () =>
new Promise((resolve) => {
resolveDenyProbeReady(() => resolve(HttpResponse.json({ success: false, message: "접근 권한이 없습니다.", data: null, errorProperty: null }, { status: 403 })));
}),
),
);
render(<App />);
const triggerDenyProbe = await denyProbeReady;
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
triggerDenyProbe();
await waitFor(() => expect(window.location.pathname).toBe("/access-denied"));
expect(screen.getByRole("heading", { name: "접근 권한이 없습니다" })).toBeInTheDocument();
});

View File

@@ -0,0 +1,219 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { App } from "./App";
import { apiBaseUrl, installDesktopMediaQuery, requireElement, saveAdminSession, useAiCharactersResponse } from "./app-test-support";
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
import { server } from "@/shared/test/server";
beforeEach(() => {
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
});
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
window.history.replaceState({}, "", "/");
});
test("renders the protected admin shell for an existing ADMIN session", async () => {
saveAdminSession();
const requests: Request[] = [];
useAiCharactersResponse(200, (request) => requests.push(request));
window.history.pushState({}, "", "/ai-characters");
render(<App />);
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
expect(await screen.findByRole("heading", { name: "AI 캐릭터" })).toBeInTheDocument();
expect(requests[0]?.url).toContain("size=20");
expect(screen.getByRole("link", { name: "본문으로 건너뛰기" })).toHaveAttribute("href", "#app-main");
expect(screen.getByRole("banner")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "모바일 메뉴 열기" })).toHaveClass("whitespace-nowrap");
expect(screen.getByRole("navigation", { name: "브레드크럼" })).toHaveClass("whitespace-nowrap");
expect(screen.getByRole("button", { name: "로그아웃" })).toHaveClass("whitespace-nowrap");
expect(screen.getByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
expect(screen.getByRole("navigation", { name: "데스크톱 주 메뉴" })).toBeInTheDocument();
expect(screen.getByRole("navigation", { name: "브레드크럼" })).toHaveTextContent("AI 캐릭터");
expect(await screen.findByText("검색 조건에 맞는 AI 캐릭터가 없습니다.")).toBeInTheDocument();
expect(screen.queryByText("루나")).not.toBeInTheDocument();
await waitFor(() => expect(requests).toHaveLength(2));
});
test("shows an accessible status while the initial protected route probe is pending", async () => {
saveAdminSession();
let resolveProbeReady: (finishProbe: () => void) => void = () => undefined;
const probeReady = new Promise<() => void>((resolve) => {
resolveProbeReady = resolve;
});
server.use(
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () =>
new Promise((resolve) => {
resolveProbeReady(() => resolve(HttpResponse.json({ success: true, message: null, data: { totalCount: 0, content: [] }, errorProperty: null })));
}),
),
);
window.history.pushState({}, "", "/ai-characters");
render(<App />);
const finishProbe = await probeReady;
expect(screen.getByRole("status")).toHaveTextContent("보호 route 확인 중");
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "로그아웃" })).not.toBeInTheDocument();
finishProbe();
expect(await screen.findByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
});
test("keeps malformed protected routes behind the ADMIN probe", async () => {
saveAdminSession();
let resolveProbeReady: (finishProbe: () => void) => void = () => undefined;
const probeReady = new Promise<() => void>((resolve) => {
resolveProbeReady = resolve;
});
server.use(
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () =>
new Promise((resolve) => {
resolveProbeReady(() => resolve(HttpResponse.json({ success: false, message: "권한이 없습니다.", data: null, errorProperty: null }, { status: 403 })));
}),
),
);
window.history.pushState({}, "", "/ai-characters/%");
render(<App />);
const finishProbe = await probeReady;
expect(screen.getByRole("status")).toHaveTextContent("보호 route 확인 중");
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
finishProbe();
expect(await screen.findByRole("heading", { name: "접근 권한이 없습니다" })).toBeInTheDocument();
});
test.each([
{ path: "/ai-characters/%", status: 401, finalPath: "/login", finalHeading: "관리자 로그인", sessionCleared: true },
{ path: "/ai-characters/%", status: 403, finalPath: "/access-denied", finalHeading: "접근 권한이 없습니다", sessionCleared: false },
{ path: "/ai-characters/%E0%A4%A/audio-contents/1", status: 401, finalPath: "/login", finalHeading: "관리자 로그인", sessionCleared: true },
{ path: "/ai-characters/%E0%A4%A/audio-contents/1", status: 403, finalPath: "/access-denied", finalHeading: "접근 권한이 없습니다", sessionCleared: false },
{ path: "/ai-characters/101/series/%E0%A4%A", status: 401, finalPath: "/login", finalHeading: "관리자 로그인", sessionCleared: true },
{ path: "/ai-characters/101/series/%E0%A4%A", status: 403, finalPath: "/access-denied", finalHeading: "접근 권한이 없습니다", sessionCleared: false },
] as const)("keeps malformed route $path behind the ADMIN probe before resolving $status", async ({ path, status, finalPath, finalHeading, sessionCleared }) => {
saveAdminSession();
let resolveProbeReady: (finishProbe: () => void) => void = () => undefined;
const probeReady = new Promise<() => void>((resolve) => {
resolveProbeReady = resolve;
});
server.use(
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () =>
new Promise((resolve) => {
resolveProbeReady(() =>
resolve(
HttpResponse.json(
{ success: false, message: status === 401 ? "인증 정보가 없습니다." : "접근 권한이 없습니다.", data: null, errorProperty: null },
{ status },
),
),
);
}),
),
);
window.history.pushState({}, "", path);
render(<App />);
const finishProbe = await probeReady;
expect(screen.getByRole("status")).toHaveTextContent("보호 route 확인 중");
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "로그아웃" })).not.toBeInTheDocument();
finishProbe();
await waitFor(() => expect(window.location.pathname).toBe(finalPath));
expect(screen.getByRole("heading", { name: finalHeading })).toBeInTheDocument();
if (sessionCleared) {
expect(authSessionStorage.read()).toBeNull();
} else {
expect(authSessionStorage.read()).toEqual({ token: "admin-token", role: "ADMIN" });
}
});
test("shows the mock mode banner in the protected admin shell only in mock mode", async () => {
vi.stubEnv("VITE_API_MODE", "mock");
saveAdminSession();
useAiCharactersResponse();
window.history.pushState({}, "", "/ai-characters");
render(<App />);
expect(await screen.findByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
expect(screen.getByRole("status", { name: "Mock Preview" })).toHaveTextContent("Mock Preview");
});
test("keeps the mock mode banner inside the inert background while the mobile menu is open", async () => {
vi.stubEnv("VITE_API_MODE", "mock");
saveAdminSession();
useAiCharactersResponse();
window.history.pushState({}, "", "/ai-characters");
render(<App />);
await screen.findByRole("main", { name: "AI 캐릭터 관리" });
fireEvent.click(screen.getByRole("button", { name: "모바일 메뉴 열기" }));
const banner = requireElement(screen.getByText("Mock Preview").closest("[role='status']"), "mock banner");
const inertBackground = requireElement(banner.closest("[inert]"), "mock banner inert background");
expect(inertBackground).toHaveAttribute("aria-hidden", "true");
expect(screen.queryByRole("status", { name: "Mock Preview" })).not.toBeInTheDocument();
});
test("closes the mobile menu at the lg breakpoint without restoring focus to the hidden trigger", async () => {
const desktopMediaQuery = installDesktopMediaQuery();
saveAdminSession();
useAiCharactersResponse();
window.history.pushState({}, "", "/ai-characters");
render(<App />);
await screen.findByRole("main", { name: "AI 캐릭터 관리" });
const menuButton = screen.getByRole("button", { name: "모바일 메뉴 열기" });
fireEvent.click(menuButton);
await waitFor(() => expect(screen.getByRole("button", { name: "모바일 메뉴 닫기" })).toHaveFocus());
desktopMediaQuery.setDesktopMatch();
await waitFor(() => expect(screen.queryByRole("navigation", { name: "모바일 주 메뉴" })).not.toBeInTheDocument());
expect(menuButton).not.toHaveFocus();
expect(screen.getByRole("navigation", { name: "데스크톱 주 메뉴" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "로그아웃" })).toBeInTheDocument();
});
test("composes Task 1.5 shared empty state in the real admin shell without domain list data", async () => {
saveAdminSession();
useAiCharactersResponse();
window.history.pushState({}, "", "/ai-characters");
render(<App />);
expect(await screen.findByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
expect(await screen.findByText("검색 조건에 맞는 AI 캐릭터가 없습니다.")).toBeInTheDocument();
expect(screen.queryByText("루나")).not.toBeInTheDocument();
});
test("keeps keyboard focus inside the mobile menu and returns focus to the trigger", async () => {
saveAdminSession();
useAiCharactersResponse();
window.history.pushState({}, "", "/ai-characters");
render(<App />);
await screen.findByRole("heading", { name: "AI 캐릭터" });
const menuButton = screen.getByRole("button", { name: "모바일 메뉴 열기" });
fireEvent.click(menuButton);
const menu = screen.getByRole("navigation", { name: "모바일 주 메뉴" });
const closeButton = screen.getByRole("button", { name: "모바일 메뉴 닫기" });
await waitFor(() => expect(closeButton).toHaveFocus());
fireEvent.keyDown(menu, { key: "Tab", shiftKey: true });
expect(screen.getByRole("link", { name: "AI 캐릭터" })).toHaveFocus();
fireEvent.keyDown(menu, { key: "Tab" });
expect(closeButton).toHaveFocus();
fireEvent.keyDown(window, { key: "Escape" });
expect(screen.queryByRole("navigation", { name: "모바일 주 메뉴" })).not.toBeInTheDocument();
expect(menuButton).toHaveFocus();
});

View File

@@ -1,108 +1,11 @@
import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { App } from "./App";
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
import { apiBaseUrl, saveAdminSession, useAiCharacterDetailResponse, useAiCharactersResponse } from "./app-test-support";
import { server } from "@/shared/test/server";
const apiBaseUrl = "https://api.example.com";
function saveAdminSession() {
authSessionStorage.save({ token: "admin-token", role: "ADMIN" });
}
function useAiCharactersResponse(status: 200 | 401 | 403 = 200, onRequest: (request: Request) => void = () => undefined) {
server.use(
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, ({ request }) => {
onRequest(request);
if (status === 401) {
return HttpResponse.json(
{ success: false, message: "인증 정보가 없습니다.", data: null, errorProperty: null },
{ status },
);
}
if (status === 403) {
return HttpResponse.json(
{ success: false, message: "접근 권한이 없습니다.", data: null, errorProperty: null },
{ status },
);
}
return HttpResponse.json({ success: true, message: null, data: null, errorProperty: null });
}),
);
}
function useAiCharactersFailure(status: 404 | "network") {
server.use(
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () => {
if (status === "network") {
return HttpResponse.error();
}
return HttpResponse.json({ success: false, message: "없습니다.", data: null, errorProperty: null }, { status });
}),
);
}
function installDesktopMediaQuery() {
const desktopQuery = "(min-width: 1024px)";
let matches = false;
const listeners = new Set<(event: Event) => void>();
const mediaQueryList = {
get matches() {
return matches;
},
media: desktopQuery,
onchange: null,
addEventListener: (type: string, listener: EventListenerOrEventListenerObject | null) => {
if (type !== "change" || listener === null || typeof listener !== "function") {
return;
}
listeners.add(listener);
},
removeEventListener: (type: string, listener: EventListenerOrEventListenerObject | null) => {
if (type !== "change" || listener === null || typeof listener !== "function") {
return;
}
listeners.delete(listener);
},
dispatchEvent: (event: Event) => {
listeners.forEach((listener) => listener(event));
return true;
},
addListener: (listener: (event: Event) => void) => {
listeners.add(listener);
},
removeListener: (listener: (event: Event) => void) => {
listeners.delete(listener);
},
} satisfies MediaQueryList;
vi.stubGlobal("matchMedia", (query: string) => {
expect(query).toBe(desktopQuery);
return mediaQueryList;
});
return {
setDesktopMatch: () => {
matches = true;
mediaQueryList.dispatchEvent(new Event("change"));
},
};
}
function requireElement(element: Element | null, name: string): Element {
if (element === null) {
throw new Error(`${name} not found`);
}
return element;
}
beforeEach(() => {
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
});
@@ -124,10 +27,8 @@ test("redirects an unauthenticated direct visit to /ai-characters without exposi
render(<App />);
expect(screen.queryByText("Phase 3에서 AI 캐릭터 목록이 연결됩니다.")).not.toBeInTheDocument();
await waitFor(() => expect(window.location.pathname).toBe("/login"));
expect(screen.getByRole("heading", { name: "관리자 로그인" })).toBeInTheDocument();
expect(screen.queryByText("Phase 3에서 AI 캐릭터 목록이 연결됩니다.")).not.toBeInTheDocument();
});
test("renders the existing login page at /login", () => {
@@ -140,26 +41,20 @@ test("renders the existing login page at /login", () => {
});
test("shows the mock mode banner on the login page only in mock mode", () => {
// Given
vi.stubEnv("VITE_API_MODE", "mock");
window.history.pushState({}, "", "/login");
// When
render(<App />);
// Then
expect(screen.getByRole("status", { name: "Mock Preview" })).toHaveTextContent("Mock Preview");
});
test("does not show the mock mode banner on the login page in server mode", () => {
// Given
vi.stubEnv("VITE_API_MODE", "server");
window.history.pushState({}, "", "/login");
// When
render(<App />);
// Then
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
@@ -168,12 +63,7 @@ test("navigates to /ai-characters after a successful login", async () => {
useAiCharactersResponse();
server.use(
http.post(`${apiBaseUrl}/admin/member/login`, () =>
HttpResponse.json({
success: true,
message: null,
data: { token: "jwt-token", role: "ADMIN" },
errorProperty: null,
}),
HttpResponse.json({ success: true, message: null, data: { token: "jwt-token", role: "ADMIN" }, errorProperty: null }),
),
);
@@ -187,456 +77,42 @@ test("navigates to /ai-characters after a successful login", async () => {
expect(screen.getByRole("heading", { name: "AI 캐릭터" })).toBeInTheDocument();
});
test("renders the protected admin shell for an existing ADMIN session", async () => {
test("routes /ai-characters/new to the character create form", async () => {
saveAdminSession();
const requests: Request[] = [];
useAiCharactersResponse(200, (request) => requests.push(request));
window.history.pushState({}, "", "/ai-characters");
useAiCharactersResponse();
window.history.pushState({}, "", "/ai-characters/new");
render(<App />);
expect(await screen.findByRole("heading", { name: "AI 캐릭터 생성" })).toBeInTheDocument();
expect(screen.getByLabelText("이름")).toBeInTheDocument();
expect(screen.getByLabelText("시스템 프롬프트")).toBeInTheDocument();
expect(screen.getByLabelText("설명")).toBeInTheDocument();
expect(screen.getByLabelText("프로필 이미지")).toBeInTheDocument();
});
test("routes /ai-characters/:characterId/edit to the character edit form", async () => {
saveAdminSession();
useAiCharactersResponse();
useAiCharacterDetailResponse("101");
window.history.pushState({}, "", "/ai-characters/101/edit");
render(<App />);
expect(await screen.findByRole("heading", { name: "AI 캐릭터 수정" })).toBeInTheDocument();
expect(screen.getByLabelText("이름")).toHaveValue("루나");
expect(screen.getByLabelText("시스템 프롬프트")).toHaveValue("친절하게 답한다.");
expect(screen.getByLabelText("설명")).toHaveValue("차분한 상담형 캐릭터");
expect(screen.getByLabelText("지역")).toHaveValue("KR");
expect(screen.getByLabelText("지역")).toBeDisabled();
});
test.each(["/ai-characters/%", "/ai-characters/%E0%A4%A/audio-contents/1", "/ai-characters/101/series/%E0%A4%A"])("falls back to the character list for malformed route %s", async (path) => {
saveAdminSession();
useAiCharactersResponse();
window.history.pushState({}, "", path);
render(<App />);
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
expect(await screen.findByRole("heading", { name: "AI 캐릭터" })).toBeInTheDocument();
expect(requests[0]?.url).toContain("size=20");
expect(screen.getByRole("link", { name: "본문으로 건너뛰기" })).toHaveAttribute("href", "#app-main");
expect(screen.getByRole("banner")).toBeInTheDocument();
expect(screen.getByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
expect(screen.getByRole("navigation", { name: "데스크톱 주 메뉴" })).toBeInTheDocument();
expect(screen.getByRole("navigation", { name: "브레드크럼" })).toHaveTextContent("AI 캐릭터");
expect(screen.getByText("Phase 3에서 AI 캐릭터 목록이 연결됩니다.")).toBeInTheDocument();
expect(screen.queryByText("루나")).not.toBeInTheDocument();
});
test("shows an accessible status while the initial protected route probe is pending", async () => {
// Given
saveAdminSession();
let resolveProbeReady: (finishProbe: () => void) => void = () => undefined;
const probeReady = new Promise<() => void>((resolve) => {
resolveProbeReady = resolve;
});
server.use(
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () =>
new Promise((resolve) => {
resolveProbeReady(() => resolve(HttpResponse.json({ success: true, message: null, data: null, errorProperty: null })));
}),
),
);
window.history.pushState({}, "", "/ai-characters");
// When
render(<App />);
const finishProbe = await probeReady;
// Then
expect(screen.getByRole("status")).toHaveTextContent("보호 route 확인 중");
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "로그아웃" })).not.toBeInTheDocument();
finishProbe();
expect(await screen.findByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
});
test("shows the mock mode banner in the protected admin shell only in mock mode", async () => {
// Given
vi.stubEnv("VITE_API_MODE", "mock");
saveAdminSession();
useAiCharactersResponse();
window.history.pushState({}, "", "/ai-characters");
// When
render(<App />);
// Then
expect(await screen.findByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
expect(screen.getByRole("status", { name: "Mock Preview" })).toHaveTextContent("Mock Preview");
});
test("keeps the mock mode banner inside the inert background while the mobile menu is open", async () => {
// Given
vi.stubEnv("VITE_API_MODE", "mock");
saveAdminSession();
useAiCharactersResponse();
window.history.pushState({}, "", "/ai-characters");
// When
render(<App />);
await screen.findByRole("main", { name: "AI 캐릭터 관리" });
fireEvent.click(screen.getByRole("button", { name: "모바일 메뉴 열기" }));
// Then
const banner = requireElement(screen.getByText("Mock Preview").closest("[role='status']"), "mock banner");
const inertBackground = requireElement(banner.closest("[inert]"), "mock banner inert background");
expect(inertBackground).toHaveAttribute("aria-hidden", "true");
expect(screen.queryByRole("status", { name: "Mock Preview" })).not.toBeInTheDocument();
});
test("closes the mobile menu at the lg breakpoint without restoring focus to the hidden trigger", async () => {
// Given
const desktopMediaQuery = installDesktopMediaQuery();
saveAdminSession();
useAiCharactersResponse();
window.history.pushState({}, "", "/ai-characters");
// When
render(<App />);
await screen.findByRole("main", { name: "AI 캐릭터 관리" });
const menuButton = screen.getByRole("button", { name: "모바일 메뉴 열기" });
fireEvent.click(menuButton);
await waitFor(() => expect(screen.getByRole("button", { name: "모바일 메뉴 닫기" })).toHaveFocus());
desktopMediaQuery.setDesktopMatch();
// Then
await waitFor(() => expect(screen.queryByRole("navigation", { name: "모바일 주 메뉴" })).not.toBeInTheDocument());
expect(menuButton).not.toHaveFocus();
expect(screen.getByRole("navigation", { name: "데스크톱 주 메뉴" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "로그아웃" })).toBeInTheDocument();
});
test("composes Task 1.5 shared empty state in the real admin shell without domain list data", async () => {
saveAdminSession();
useAiCharactersResponse();
window.history.pushState({}, "", "/ai-characters");
render(<App />);
expect(await screen.findByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent("Phase 3에서 AI 캐릭터 목록이 연결됩니다.");
expect(screen.queryByText("루나")).not.toBeInTheDocument();
});
test("clears the session and routes to login when the protected route request returns 401", async () => {
saveAdminSession();
useAiCharactersResponse(401);
window.history.pushState({}, "", "/ai-characters");
render(<App />);
await waitFor(() => expect(window.location.pathname).toBe("/login"));
expect(authSessionStorage.read()).toBeNull();
expect(screen.getByRole("heading", { name: "관리자 로그인" })).toBeInTheDocument();
expect(screen.getByRole("alert")).toHaveTextContent("세션이 만료되었습니다. 다시 로그인하세요.");
expect(screen.queryByText("Phase 3에서 AI 캐릭터 목록이 연결됩니다.")).not.toBeInTheDocument();
});
test("routes to access denied without clearing the session when the protected route request returns 403", async () => {
saveAdminSession();
useAiCharactersResponse(403);
window.history.pushState({}, "", "/ai-characters");
render(<App />);
await waitFor(() => expect(window.location.pathname).toBe("/access-denied"));
expect(authSessionStorage.read()).toEqual({ token: "admin-token", role: "ADMIN" });
expect(screen.getByRole("heading", { name: "접근 권한이 없습니다" })).toBeInTheDocument();
expect(screen.queryByText("루나")).not.toBeInTheDocument();
});
test("keeps the mock mode banner visible on the access denied page", async () => {
// Given
vi.stubEnv("VITE_API_MODE", "mock");
saveAdminSession();
useAiCharactersResponse(403);
window.history.pushState({}, "", "/ai-characters");
// When
render(<App />);
// Then
await waitFor(() => expect(window.location.pathname).toBe("/access-denied"));
expect(screen.getByRole("heading", { name: "접근 권한이 없습니다" })).toBeInTheDocument();
expect(screen.getByRole("status", { name: "Mock Preview" })).toHaveTextContent("Mock Preview");
});
test("keeps the protected shell hidden when the protected route request returns 404", async () => {
saveAdminSession();
useAiCharactersFailure(404);
window.history.pushState({}, "", "/ai-characters");
render(<App />);
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
expect(await screen.findByRole("alert")).toHaveTextContent("없습니다.");
expect(screen.queryByRole("banner")).not.toBeInTheDocument();
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
});
test("keeps the mock mode banner visible on protected route errors", async () => {
// Given
vi.stubEnv("VITE_API_MODE", "mock");
saveAdminSession();
useAiCharactersFailure(404);
window.history.pushState({}, "", "/ai-characters");
// When
render(<App />);
// Then
expect(await screen.findByRole("alert")).toHaveTextContent("없습니다.");
expect(screen.getByRole("status", { name: "Mock Preview" })).toHaveTextContent("Mock Preview");
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
});
test("keeps the protected shell hidden when the protected route request has a network error", async () => {
saveAdminSession();
useAiCharactersFailure("network");
window.history.pushState({}, "", "/ai-characters");
render(<App />);
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
expect(await screen.findByRole("alert")).toHaveTextContent("보호 route 확인에 실패했습니다.");
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
});
test("retries a protected route 404 and reveals the shell only after the current retry succeeds", async () => {
// Given
saveAdminSession();
let requestCount = 0;
let resolveRetryReady: (finishRetry: () => void) => void = () => undefined;
const retryReady = new Promise<() => void>((resolve) => {
resolveRetryReady = resolve;
});
server.use(
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () => {
requestCount += 1;
if (requestCount === 1) {
return HttpResponse.json({ success: false, message: "없습니다.", data: null, errorProperty: null }, { status: 404 });
}
return new Promise((resolve) => {
resolveRetryReady(() => resolve(HttpResponse.json({ success: true, message: null, data: null, errorProperty: null })));
});
}),
);
window.history.pushState({}, "", "/ai-characters");
render(<App />);
const alert = await screen.findByRole("alert");
const retryButton = screen.getByRole("button", { name: "보호 route 다시 시도" });
retryButton.focus();
expect(alert).toHaveTextContent("없습니다.");
expect(retryButton).toHaveFocus();
expect(requestCount).toBe(1);
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
// When
fireEvent.click(retryButton);
const finishRetry = await retryReady;
// Then
expect(requestCount).toBe(2);
expect(screen.getByRole("status")).toHaveTextContent("보호 route 확인 중");
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "로그아웃" })).not.toBeInTheDocument();
finishRetry();
expect(await screen.findByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "로그아웃" })).toBeInTheDocument();
});
test("keeps retry available and the protected shell hidden when a network retry fails", async () => {
// Given
saveAdminSession();
let requestCount = 0;
server.use(
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () => {
requestCount += 1;
return HttpResponse.error();
}),
);
window.history.pushState({}, "", "/ai-characters");
render(<App />);
const retryButton = await screen.findByRole("button", { name: "보호 route 다시 시도" });
expect(screen.getByRole("alert")).toHaveTextContent("보호 route 확인에 실패했습니다.");
expect(requestCount).toBe(1);
// When
fireEvent.click(retryButton);
// Then
await waitFor(() => expect(requestCount).toBe(2));
expect(await screen.findByRole("button", { name: "보호 route 다시 시도" })).toBeInTheDocument();
expect(screen.getByRole("alert")).toHaveTextContent("보호 route 확인에 실패했습니다.");
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "로그아웃" })).not.toBeInTheDocument();
});
test("clears a previous protected route verification before reusing the same token after login", async () => {
saveAdminSession();
useAiCharactersResponse();
window.history.pushState({}, "", "/ai-characters");
server.use(
http.post(`${apiBaseUrl}/member/logout`, () => HttpResponse.json({ success: true, message: null, data: {}, errorProperty: null })),
http.post(`${apiBaseUrl}/admin/member/login`, () =>
HttpResponse.json({
success: true,
message: null,
data: { token: "admin-token", role: "ADMIN" },
errorProperty: null,
}),
),
);
render(<App />);
fireEvent.click(await screen.findByRole("button", { name: "로그아웃" }));
await waitFor(() => expect(window.location.pathname).toBe("/login"));
useAiCharactersFailure(404);
fireEvent.change(screen.getByLabelText("이메일"), { target: { value: "admin@test.com" } });
fireEvent.change(screen.getByLabelText("비밀번호"), { target: { value: "password" } });
fireEvent.click(screen.getByRole("button", { name: "로그인" }));
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters"));
expect(await screen.findByRole("alert")).toHaveTextContent("없습니다.");
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
});
test("clears a previous protected route verification before the same session re-enters the route", async () => {
saveAdminSession();
useAiCharactersResponse();
window.history.pushState({}, "", "/ai-characters");
render(<App />);
expect(await screen.findByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
window.history.pushState({}, "", "/login");
fireEvent.popState(window);
await waitFor(() => expect(screen.getByRole("heading", { name: "관리자 로그인" })).toBeInTheDocument());
useAiCharactersFailure(404);
window.history.pushState({}, "", "/ai-characters");
fireEvent.popState(window);
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
expect(await screen.findByRole("alert")).toHaveTextContent("없습니다.");
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
});
test("keeps the protected shell hidden while a stale ADMIN probe is pending and then denied", async () => {
saveAdminSession();
window.history.pushState({}, "", "/ai-characters");
let resolveDenyProbeReady: (denyProbe: () => void) => void = () => undefined;
const denyProbeReady = new Promise<() => void>((resolve) => {
resolveDenyProbeReady = resolve;
});
server.use(
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () =>
new Promise((resolve) => {
resolveDenyProbeReady(() =>
resolve(
HttpResponse.json(
{ success: false, message: "접근 권한이 없습니다.", data: null, errorProperty: null },
{ status: 403 },
),
),
);
}),
),
);
render(<App />);
const triggerDenyProbe = await denyProbeReady;
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
expect(screen.queryByText("Phase 3에서 AI 캐릭터 목록이 연결됩니다.")).not.toBeInTheDocument();
triggerDenyProbe();
await waitFor(() => expect(window.location.pathname).toBe("/access-denied"));
expect(screen.getByRole("heading", { name: "접근 권한이 없습니다" })).toBeInTheDocument();
});
test("keeps keyboard focus inside the mobile menu and returns focus to the trigger", async () => {
saveAdminSession();
useAiCharactersResponse();
window.history.pushState({}, "", "/ai-characters");
render(<App />);
await screen.findByRole("heading", { name: "AI 캐릭터" });
const menuButton = screen.getByRole("button", { name: "모바일 메뉴 열기" });
fireEvent.click(menuButton);
const menu = screen.getByRole("navigation", { name: "모바일 주 메뉴" });
const closeButton = screen.getByRole("button", { name: "모바일 메뉴 닫기" });
await waitFor(() => expect(closeButton).toHaveFocus());
fireEvent.keyDown(menu, { key: "Tab", shiftKey: true });
expect(screen.getByRole("link", { name: "AI 캐릭터" })).toHaveFocus();
fireEvent.keyDown(menu, { key: "Tab" });
expect(closeButton).toHaveFocus();
fireEvent.keyDown(window, { key: "Escape" });
expect(screen.queryByRole("navigation", { name: "모바일 주 메뉴" })).not.toBeInTheDocument();
expect(menuButton).toHaveFocus();
});
test("shows a login warning when server logout confirmation fails", async () => {
saveAdminSession();
useAiCharactersResponse();
window.history.pushState({}, "", "/ai-characters");
server.use(
http.post(`${apiBaseUrl}/member/logout`, () =>
HttpResponse.json(
{ success: false, message: "로그아웃 확인 실패", data: null, errorProperty: null },
{ status: 500 },
),
),
);
render(<App />);
fireEvent.click(await screen.findByRole("button", { name: "로그아웃" }));
await waitFor(() => expect(window.location.pathname).toBe("/login"));
expect(authSessionStorage.read()).toBeNull();
expect(screen.getByRole("alert")).toHaveTextContent("서버 로그아웃 확인에 실패했습니다.");
});
test("logs out from the protected shell and routes to login", async () => {
saveAdminSession();
useAiCharactersResponse();
window.history.pushState({}, "", "/ai-characters");
server.use(
http.post(`${apiBaseUrl}/member/logout`, ({ request }) => {
expect(request.headers.get("Authorization")).toBe("Bearer admin-token");
return HttpResponse.json({ success: true, message: null, data: {}, errorProperty: null });
}),
);
render(<App />);
fireEvent.click(await screen.findByRole("button", { name: "로그아웃" }));
await waitFor(() => expect(window.location.pathname).toBe("/login"));
expect(authSessionStorage.read()).toBeNull();
});
test("sends only one logout request while the first logout is in flight", async () => {
saveAdminSession();
useAiCharactersResponse();
window.history.pushState({}, "", "/ai-characters");
let logoutCount = 0;
let resolveLogoutReady: (finishLogout: () => void) => void = () => undefined;
const logoutReady = new Promise<() => void>((resolve) => {
resolveLogoutReady = resolve;
});
server.use(
http.post(`${apiBaseUrl}/member/logout`, () => {
logoutCount += 1;
return new Promise((resolve) => {
resolveLogoutReady(() => resolve(HttpResponse.json({ success: true, message: null, data: {}, errorProperty: null })));
});
}),
);
render(<App />);
const logoutButton = await screen.findByRole("button", { name: "로그아웃" });
fireEvent.click(logoutButton);
fireEvent.click(logoutButton);
await waitFor(() => expect(logoutCount).toBe(1));
const finishLogout = await logoutReady;
finishLogout();
await waitForElementToBeRemoved(logoutButton);
expect(window.location.pathname).toBe("/login");
});

View File

@@ -8,7 +8,7 @@ import { useAuthSession } from "@/features/auth/model/auth-session-context";
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
import { AccessDeniedPage } from "@/app/admin-pages";
import { routePaths } from "@/app/route-paths";
import { navigateTo, replaceWith, useBrowserLocation } from "@/app/browser-location";
import { isAiCharactersRoute, navigateTo, replaceWith, useBrowserLocation } from "@/app/browser-location";
import { ProtectedAdminShell } from "@/app/protected-admin-shell";
import { AccessDeniedError, ApiError } from "@/shared/api/api-error";
import { createApiClient } from "@/shared/api/client";
@@ -62,14 +62,15 @@ function RouteFrame({ apiMode, children }: { readonly apiMode: ApiMode; readonly
function AppShell({ apiMode }: { readonly apiMode: ApiMode }) {
const auth = useAuthSession();
const clearAuthSession = auth.clearSession;
const protectedRouteApiClient = useMemo(
() =>
createApiClient({
getToken: () => authSessionStorage.read()?.token ?? null,
clearSession: () => auth.clearSession(sessionExpiredNotice),
clearSession: () => clearAuthSession(sessionExpiredNotice),
onAuthExpired: () => replaceWith(routePaths.login),
}),
[auth],
[clearAuthSession],
);
const location = useBrowserLocation();
const [routeError, setRouteError] = useState<ProtectedRouteError | null>(null);
@@ -83,7 +84,7 @@ function AppShell({ apiMode }: { readonly apiMode: ApiMode }) {
}, [auth.session, location.path]);
useEffect(() => {
if (location.path !== routePaths.aiCharacters || auth.session === null) {
if (!isAiCharactersRoute(location.path) || auth.session === null) {
return undefined;
}
@@ -160,7 +161,7 @@ function AppShell({ apiMode }: { readonly apiMode: ApiMode }) {
: null;
if (
location.path === routePaths.aiCharacters &&
isAiCharactersRoute(location.path) &&
(verifiedProtectedRouteSession?.session !== auth.session ||
verifiedProtectedRouteSession.routeVisitKey !== location.visitKey ||
verifiedProtectedRouteSession.protectedRouteRetryKey !== protectedRouteRetryKey)
@@ -185,7 +186,7 @@ function AppShell({ apiMode }: { readonly apiMode: ApiMode }) {
}
return (
<ProtectedAdminShell apiMode={apiMode} routeError={currentRouteError} />
<ProtectedAdminShell apiClient={protectedRouteApiClient} apiMode={apiMode} routeError={currentRouteError} />
);
}

View File

@@ -1,5 +1,4 @@
import { routePaths } from "@/app/route-paths";
import { PageState } from "@/shared/ui/page-state";
export function AccessDeniedPage() {
return (
@@ -15,23 +14,3 @@ export function AccessDeniedPage() {
</main>
);
}
export function AiCharactersPage({ routeError }: { readonly routeError: string | null }) {
return (
<section className="flex flex-col gap-4" aria-labelledby="ai-characters-title">
<div className="flex flex-col gap-2">
<p className="text-xs font-semibold text-info">AI CHARACTER ADMIN</p>
<h1 className="text-2xl font-bold leading-tight" id="ai-characters-title">
AI
</h1>
<p className="text-sm text-muted-foreground"> Phase 3 .</p>
</div>
{routeError === null ? null : (
<p className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" role="alert">
{routeError}
</p>
)}
<PageState description="현재 route는 보호 shell과 권한 처리를 검증하는 명시적 빈 상태입니다." state="empty" title="Phase 3에서 AI 캐릭터 목록이 연결됩니다." />
</section>
);
}

125
src/app/app-test-support.ts Normal file
View File

@@ -0,0 +1,125 @@
import { http, HttpResponse } from "msw";
import { expect, vi } from "vitest";
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
import { server } from "@/shared/test/server";
export const apiBaseUrl = "https://api.example.com";
export function saveAdminSession() {
authSessionStorage.save({ token: "admin-token", role: "ADMIN" });
}
export function useAiCharactersResponse(status: 200 | 401 | 403 = 200, onRequest: (request: Request) => void = () => undefined) {
server.use(
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, ({ request }) => {
onRequest(request);
if (status === 401) {
return HttpResponse.json({ success: false, message: "인증 정보가 없습니다.", data: null, errorProperty: null }, { status });
}
if (status === 403) {
return HttpResponse.json({ success: false, message: "접근 권한이 없습니다.", data: null, errorProperty: null }, { status });
}
return HttpResponse.json({ success: true, message: null, data: { totalCount: 0, content: [] }, errorProperty: null });
}),
);
}
export function useAiCharacterDetailResponse(characterId: string) {
server.use(
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/${characterId}`, () =>
HttpResponse.json({
success: true,
message: null,
data: {
id: Number(characterId),
characterUUID: `character-${characterId}`,
name: "루나",
imageUrl: null,
description: "차분한 상담형 캐릭터",
systemPrompt: "친절하게 답한다.",
characterType: "Character",
age: null,
gender: null,
mbti: null,
speechPattern: null,
speechStyle: null,
appearance: null,
region: "KR",
isActive: true,
tags: [],
hobbies: [],
values: [],
goals: [],
relationships: [],
personalities: [],
backgrounds: [],
memories: [],
originalWork: null,
},
errorProperty: null,
}),
),
);
}
export function useAiCharactersFailure(status: 404 | "network") {
server.use(
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () => {
if (status === "network") {
return HttpResponse.error();
}
return HttpResponse.json({ success: false, message: "없습니다.", data: null, errorProperty: null }, { status });
}),
);
}
export function installDesktopMediaQuery() {
const desktopQuery = "(min-width: 1024px)";
let matches = false;
const listeners = new Set<(event: Event) => void>();
const mediaQueryList = {
get matches() {
return matches;
},
media: desktopQuery,
onchange: null,
addEventListener: (type: string, listener: EventListenerOrEventListenerObject | null) => {
if (type === "change" && typeof listener === "function") {
listeners.add(listener);
}
},
removeEventListener: (type: string, listener: EventListenerOrEventListenerObject | null) => {
if (type === "change" && typeof listener === "function") {
listeners.delete(listener);
}
},
dispatchEvent: (event: Event) => {
listeners.forEach((listener) => listener(event));
return true;
},
addListener: (listener: (event: Event) => void) => listeners.add(listener),
removeListener: (listener: (event: Event) => void) => listeners.delete(listener),
} satisfies MediaQueryList;
vi.stubGlobal("matchMedia", (query: string) => {
expect(query).toBe(desktopQuery);
return mediaQueryList;
});
return {
setDesktopMatch: () => {
matches = true;
mediaQueryList.dispatchEvent(new Event("change"));
},
};
}
export function requireElement(element: Element | null, name: string): Element {
if (element === null) {
throw new Error(`${name} not found`);
}
return element;
}

View File

@@ -0,0 +1,17 @@
import { expect, test } from "vitest";
import { isAiCharactersRoute } from "@/app/browser-location";
import { routePaths, type RoutePath } from "@/app/route-paths";
const protectedAiCharacterRoutes = [
routePaths.aiCharacters,
routePaths.aiCharacterCreate,
routePaths.aiCharacterDetail("101"),
routePaths.aiCharacterEdit("101"),
] as const satisfies readonly RoutePath[];
test("isAiCharactersRoute includes character list create detail and edit routes", () => {
for (const path of protectedAiCharacterRoutes) {
expect(isAiCharactersRoute(path)).toBe(true);
}
});

View File

@@ -1,17 +1,36 @@
import { useSyncExternalStore } from "react";
import { routePaths, type RoutePath } from "@/app/route-paths";
import { routePaths, type CharacterAudioContentCreateRoutePath, type CharacterAudioContentDetailRoutePath, type CharacterAudioContentEditRoutePath, type CharacterAudioContentsRoutePath, type CharacterCommunityPostCreateRoutePath, type CharacterCommunityPostsRoutePath, type CharacterEditRoutePath, type CharacterFanTalksRoutePath, type CharacterRoutePath, type CharacterSeriesCreateRoutePath, type CharacterSeriesDetailRoutePath, type CharacterSeriesEditRoutePath, type CharacterSeriesOrderRoutePath, type CharacterSeriesRoutePath, type RoutePath } from "@/app/route-paths";
export type BrowserLocationSnapshot = {
readonly path: RoutePath;
readonly successNotification: string | null;
readonly visitKey: number;
};
let currentSnapshot: BrowserLocationSnapshot = { path: readRoutePath(), visitKey: 0 };
type NavigateState = {
readonly successNotification?: string;
};
export type AudioContentDetailRouteParams = {
readonly characterId: string;
readonly contentId: string;
};
export type AudioContentCreateRouteParams = {
readonly characterId: string;
};
export type SeriesDetailRouteParams = {
readonly characterId: string;
readonly seriesId: string;
};
let currentSnapshot: BrowserLocationSnapshot = { path: readRoutePath(), successNotification: readSuccessNotification(), visitKey: 0 };
function subscribe(onStoreChange: () => void): () => void {
function handlePopState() {
currentSnapshot = { path: readRoutePath(), visitKey: currentSnapshot.visitKey + 1 };
currentSnapshot = { path: readRoutePath(), successNotification: readSuccessNotification(), visitKey: currentSnapshot.visitKey + 1 };
onStoreChange();
}
@@ -23,17 +42,269 @@ function subscribe(onStoreChange: () => void): () => void {
function readRoutePath(): RoutePath {
const path = window.location.pathname;
if (path === routePaths.login || path === routePaths.aiCharacters || path === routePaths.accessDenied) {
if (path === routePaths.login || path === routePaths.aiCharacters || path === routePaths.aiCharacterCreate || path === routePaths.accessDenied) {
return path;
}
if (isCharacterAudioContentCreateRoutePath(path) || isCharacterAudioContentEditRoutePath(path) || isCharacterAudioContentDetailRoutePath(path) || isCharacterAudioContentsRoutePath(path) || isCharacterCommunityPostCreateRoutePath(path) || isCharacterCommunityPostsRoutePath(path) || isCharacterFanTalksRoutePath(path) || isCharacterSeriesCreateRoutePath(path) || isCharacterSeriesEditRoutePath(path) || isCharacterSeriesOrderRoutePath(path) || isCharacterSeriesDetailRoutePath(path) || isCharacterSeriesRoutePath(path) || isCharacterEditRoutePath(path) || isCharacterRoutePath(path)) {
return isAiCharactersRoute(path) ? path : routePaths.aiCharacters;
}
return routePaths.aiCharacters;
}
function readSuccessNotification(): string | null {
const state: unknown = window.history.state;
if (state === null || typeof state !== "object" || !("successNotification" in state)) {
return null;
}
return typeof state.successNotification === "string" ? state.successNotification : null;
}
function isCharacterRoutePath(path: string): path is CharacterRoutePath {
return /^\/ai-characters\/[^/]+$/.test(path);
}
function isCharacterAudioContentsRoutePath(path: string): path is CharacterAudioContentsRoutePath {
return /^\/ai-characters\/[^/]+\/audio-contents$/.test(path);
}
function isCharacterSeriesRoutePath(path: string): path is CharacterSeriesRoutePath {
return /^\/ai-characters\/[^/]+\/series$/.test(path);
}
function isCharacterSeriesCreateRoutePath(path: string): path is CharacterSeriesCreateRoutePath {
return /^\/ai-characters\/[^/]+\/series\/new$/.test(path);
}
function isCharacterCommunityPostsRoutePath(path: string): path is CharacterCommunityPostsRoutePath {
return /^\/ai-characters\/[^/]+\/community-posts$/.test(path);
}
function isCharacterFanTalksRoutePath(path: string): path is CharacterFanTalksRoutePath {
return /^\/ai-characters\/[^/]+\/fan-talks$/.test(path);
}
function isCharacterCommunityPostCreateRoutePath(path: string): path is CharacterCommunityPostCreateRoutePath {
return /^\/ai-characters\/[^/]+\/community-posts\/new$/.test(path);
}
function isCharacterSeriesDetailRoutePath(path: string): path is CharacterSeriesDetailRoutePath {
return /^\/ai-characters\/[^/]+\/series\/(?!new$|order$)[^/]+$/.test(path);
}
function isCharacterSeriesEditRoutePath(path: string): path is CharacterSeriesEditRoutePath {
return /^\/ai-characters\/[^/]+\/series\/(?!new\/edit$|order\/edit$)[^/]+\/edit$/.test(path);
}
function isCharacterSeriesOrderRoutePath(path: string): path is CharacterSeriesOrderRoutePath {
return /^\/ai-characters\/[^/]+\/series\/order$/.test(path);
}
function isCharacterAudioContentCreateRoutePath(path: string): path is CharacterAudioContentCreateRoutePath {
return /^\/ai-characters\/[^/]+\/audio-contents\/new$/.test(path);
}
function isCharacterAudioContentDetailRoutePath(path: string): path is CharacterAudioContentDetailRoutePath {
return /^\/ai-characters\/[^/]+\/audio-contents\/(?!new$)[^/]+$/.test(path);
}
function isCharacterAudioContentEditRoutePath(path: string): path is CharacterAudioContentEditRoutePath {
return /^\/ai-characters\/[^/]+\/audio-contents\/(?!new\/edit$)[^/]+\/edit$/.test(path);
}
function isCharacterEditRoutePath(path: string): path is CharacterEditRoutePath {
return /^\/ai-characters\/[^/]+\/edit$/.test(path);
}
function decodeRouteSegment(segment: string): string | null {
try {
return decodeURIComponent(segment);
} catch (error: unknown) {
if (error instanceof URIError) {
return null;
}
throw error;
}
}
export function getCharacterEditIdFromPath(path: RoutePath): string | null {
if (!isCharacterEditRoutePath(path)) {
return null;
}
return decodeRouteSegment(path.slice(`${routePaths.aiCharacters}/`.length, -"/edit".length));
}
export function getCharacterIdFromPath(path: RoutePath): string | null {
if (path === routePaths.aiCharacters || path === routePaths.aiCharacterCreate || path === routePaths.login || path === routePaths.accessDenied) {
return null;
}
if (isCharacterEditRoutePath(path) || isCharacterAudioContentsRoutePath(path) || isCharacterAudioContentCreateRoutePath(path) || isCharacterAudioContentEditRoutePath(path) || isCharacterAudioContentDetailRoutePath(path) || isCharacterCommunityPostCreateRoutePath(path) || isCharacterCommunityPostsRoutePath(path) || isCharacterFanTalksRoutePath(path) || isCharacterSeriesRoutePath(path) || isCharacterSeriesCreateRoutePath(path) || isCharacterSeriesEditRoutePath(path) || isCharacterSeriesOrderRoutePath(path) || isCharacterSeriesDetailRoutePath(path)) {
return null;
}
return decodeRouteSegment(path.slice(`${routePaths.aiCharacters}/`.length));
}
export function getAudioContentListCharacterIdFromPath(path: RoutePath): string | null {
if (!isCharacterAudioContentsRoutePath(path)) {
return null;
}
return decodeRouteSegment(path.slice(`${routePaths.aiCharacters}/`.length, -"/audio-contents".length));
}
export function getAudioContentCreateCharacterIdFromPath(path: RoutePath): string | null {
if (!isCharacterAudioContentCreateRoutePath(path)) {
return null;
}
return decodeRouteSegment(path.slice(`${routePaths.aiCharacters}/`.length, -"/audio-contents/new".length));
}
export function getSeriesListCharacterIdFromPath(path: RoutePath): string | null {
if (!isCharacterSeriesRoutePath(path)) {
return null;
}
return decodeRouteSegment(path.slice(`${routePaths.aiCharacters}/`.length, -"/series".length));
}
export function getSeriesCreateCharacterIdFromPath(path: RoutePath): string | null {
if (!isCharacterSeriesCreateRoutePath(path)) {
return null;
}
return decodeRouteSegment(path.slice(`${routePaths.aiCharacters}/`.length, -"/series/new".length));
}
export function getCommunityPostListCharacterIdFromPath(path: RoutePath): string | null {
if (!isCharacterCommunityPostsRoutePath(path)) {
return null;
}
return decodeRouteSegment(path.slice(`${routePaths.aiCharacters}/`.length, -"/community-posts".length));
}
export function getCommunityPostCreateCharacterIdFromPath(path: RoutePath): string | null {
if (!isCharacterCommunityPostCreateRoutePath(path)) {
return null;
}
return decodeRouteSegment(path.slice(`${routePaths.aiCharacters}/`.length, -"/community-posts/new".length));
}
export function getFanTalkListCharacterIdFromPath(path: RoutePath): string | null {
if (!isCharacterFanTalksRoutePath(path)) {
return null;
}
return decodeRouteSegment(path.slice(`${routePaths.aiCharacters}/`.length, -"/fan-talks".length));
}
export function getSeriesDetailRouteFromPath(path: RoutePath): SeriesDetailRouteParams | null {
if (!isCharacterSeriesDetailRoutePath(path)) {
return null;
}
const match = /^\/ai-characters\/(?<characterId>[^/]+)\/series\/(?<seriesId>[^/]+)$/.exec(path);
const characterId = match?.groups?.characterId;
const seriesId = match?.groups?.seriesId;
if (characterId === undefined || seriesId === undefined) {
return null;
}
const decodedCharacterId = decodeRouteSegment(characterId);
const decodedSeriesId = decodeRouteSegment(seriesId);
if (decodedCharacterId === null || decodedSeriesId === null) {
return null;
}
return { characterId: decodedCharacterId, seriesId: decodedSeriesId };
}
export function getSeriesEditRouteFromPath(path: RoutePath): SeriesDetailRouteParams | null {
if (!isCharacterSeriesEditRoutePath(path)) {
return null;
}
const match = /^\/ai-characters\/(?<characterId>[^/]+)\/series\/(?<seriesId>[^/]+)\/edit$/.exec(path);
const characterId = match?.groups?.characterId;
const seriesId = match?.groups?.seriesId;
if (characterId === undefined || seriesId === undefined) {
return null;
}
const decodedCharacterId = decodeRouteSegment(characterId);
const decodedSeriesId = decodeRouteSegment(seriesId);
if (decodedCharacterId === null || decodedSeriesId === null) {
return null;
}
return { characterId: decodedCharacterId, seriesId: decodedSeriesId };
}
export function getSeriesOrderCharacterIdFromPath(path: RoutePath): string | null {
if (!isCharacterSeriesOrderRoutePath(path)) {
return null;
}
return decodeRouteSegment(path.slice(`${routePaths.aiCharacters}/`.length, -"/series/order".length));
}
export function getAudioContentDetailRouteFromPath(path: RoutePath): AudioContentDetailRouteParams | null {
if (!isCharacterAudioContentDetailRoutePath(path)) {
return null;
}
const match = /^\/ai-characters\/(?<characterId>[^/]+)\/audio-contents\/(?<contentId>[^/]+)$/.exec(path);
const characterId = match?.groups?.characterId;
const contentId = match?.groups?.contentId;
if (characterId === undefined || contentId === undefined) {
return null;
}
const decodedCharacterId = decodeRouteSegment(characterId);
const decodedContentId = decodeRouteSegment(contentId);
if (decodedCharacterId === null || decodedContentId === null) {
return null;
}
return { characterId: decodedCharacterId, contentId: decodedContentId };
}
export function getAudioContentEditRouteFromPath(path: RoutePath): AudioContentDetailRouteParams | null {
if (!isCharacterAudioContentEditRoutePath(path)) {
return null;
}
const match = /^\/ai-characters\/(?<characterId>[^/]+)\/audio-contents\/(?<contentId>[^/]+)\/edit$/.exec(path);
const characterId = match?.groups?.characterId;
const contentId = match?.groups?.contentId;
if (characterId === undefined || contentId === undefined) {
return null;
}
const decodedCharacterId = decodeRouteSegment(characterId);
const decodedContentId = decodeRouteSegment(contentId);
if (decodedCharacterId === null || decodedContentId === null) {
return null;
}
return { characterId: decodedCharacterId, contentId: decodedContentId };
}
export function isAiCharactersRoute(path: RoutePath): boolean {
return path === routePaths.aiCharacters || path === routePaths.aiCharacterCreate || getCharacterEditIdFromPath(path) !== null || getCharacterIdFromPath(path) !== null || getAudioContentListCharacterIdFromPath(path) !== null || getAudioContentCreateCharacterIdFromPath(path) !== null || getAudioContentEditRouteFromPath(path) !== null || getAudioContentDetailRouteFromPath(path) !== null || getCommunityPostCreateCharacterIdFromPath(path) !== null || getCommunityPostListCharacterIdFromPath(path) !== null || getFanTalkListCharacterIdFromPath(path) !== null || getSeriesListCharacterIdFromPath(path) !== null || getSeriesCreateCharacterIdFromPath(path) !== null || getSeriesEditRouteFromPath(path) !== null || getSeriesOrderCharacterIdFromPath(path) !== null || getSeriesDetailRouteFromPath(path) !== null;
}
function getSnapshot(): BrowserLocationSnapshot {
const path = readRoutePath();
if (currentSnapshot.path !== path) {
currentSnapshot = { path, visitKey: currentSnapshot.visitKey + 1 };
currentSnapshot = { path, successNotification: readSuccessNotification(), visitKey: currentSnapshot.visitKey + 1 };
}
return currentSnapshot;
@@ -43,8 +314,8 @@ export function useBrowserLocation(): BrowserLocationSnapshot {
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
export function navigateTo(path: RoutePath): void {
window.history.pushState({}, "", path);
export function navigateTo(path: string, state: NavigateState = {}): void {
window.history.pushState(state, "", path);
window.dispatchEvent(new PopStateEvent("popstate"));
}

View File

@@ -1,13 +1,29 @@
import { useEffect, useRef, useState } from "react";
import { navigateTo } from "@/app/browser-location";
import { AiCharactersPage } from "@/app/admin-pages";
import { getAudioContentCreateCharacterIdFromPath, getAudioContentDetailRouteFromPath, getAudioContentEditRouteFromPath, getAudioContentListCharacterIdFromPath, getCharacterEditIdFromPath, getCharacterIdFromPath, getCommunityPostCreateCharacterIdFromPath, getCommunityPostListCharacterIdFromPath, getFanTalkListCharacterIdFromPath, getSeriesCreateCharacterIdFromPath, getSeriesDetailRouteFromPath, getSeriesEditRouteFromPath, getSeriesListCharacterIdFromPath, getSeriesOrderCharacterIdFromPath, navigateTo, useBrowserLocation } from "@/app/browser-location";
import { routePaths } from "@/app/route-paths";
import { AudioContentDetailPage } from "@/features/audio-contents/pages/AudioContentDetailPage";
import { AudioContentFormPage } from "@/features/audio-contents/pages/AudioContentFormPage";
import { AudioContentListPage } from "@/features/audio-contents/pages/AudioContentListPage";
import { useAuthSession } from "@/features/auth/model/auth-session-context";
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
import { CharacterCreatePage } from "@/features/characters/pages/CharacterCreatePage";
import { CharacterDetailPage } from "@/features/characters/pages/CharacterDetailPage";
import { CharacterEditPage } from "@/features/characters/pages/CharacterEditPage";
import { CharacterListPage } from "@/features/characters/pages/CharacterListPage";
import { CommunityPostListPage } from "@/features/community-posts/pages/CommunityPostListPage";
import { CommunityPostFormPage } from "@/features/community-posts/pages/CommunityPostFormPage";
import { FanTalkListPage } from "@/features/fan-talks/pages/FanTalkListPage";
import { SeriesDetailPage } from "@/features/series/pages/SeriesDetailPage";
import { SeriesFormPage } from "@/features/series/pages/SeriesFormPage";
import { SeriesListPage } from "@/features/series/pages/SeriesListPage";
import { SeriesOrderPage } from "@/features/series/pages/SeriesOrderPage";
import type { ApiClient } from "@/shared/api/client";
import type { ApiMode } from "@/shared/config/env";
import { MockModeBanner } from "@/shared/ui/mock-mode-banner";
const focusableSelector = "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])";
const sessionExpiredNotice = "세션이 만료되었습니다. 다시 로그인하세요.";
function NavLink() {
return (
@@ -24,12 +40,33 @@ function NavLink() {
);
}
export function ProtectedAdminShell({ apiMode, routeError }: { readonly apiMode: ApiMode; readonly routeError: string | null }) {
export function ProtectedAdminShell({ apiClient, apiMode, routeError }: { readonly apiClient: ApiClient; readonly apiMode: ApiMode; readonly routeError: string | null }) {
const auth = useAuthSession();
const uploadAuth = {
clearSession: () => auth.clearSession(sessionExpiredNotice),
getToken: () => authSessionStorage.read()?.token ?? null,
onAuthExpired: () => navigateTo(routePaths.login),
};
const location = useBrowserLocation();
const audioContentDetailRoute = getAudioContentDetailRouteFromPath(location.path);
const audioContentEditRoute = getAudioContentEditRouteFromPath(location.path);
const audioContentCreateCharacterId = getAudioContentCreateCharacterIdFromPath(location.path);
const audioContentListCharacterId = getAudioContentListCharacterIdFromPath(location.path);
const seriesDetailRoute = getSeriesDetailRouteFromPath(location.path);
const seriesEditRoute = getSeriesEditRouteFromPath(location.path);
const seriesCreateCharacterId = getSeriesCreateCharacterIdFromPath(location.path);
const seriesOrderCharacterId = getSeriesOrderCharacterIdFromPath(location.path);
const seriesListCharacterId = getSeriesListCharacterIdFromPath(location.path);
const communityPostListCharacterId = getCommunityPostListCharacterIdFromPath(location.path);
const communityPostCreateCharacterId = getCommunityPostCreateCharacterIdFromPath(location.path);
const fanTalkListCharacterId = getFanTalkListCharacterIdFromPath(location.path);
const characterEditId = getCharacterEditIdFromPath(location.path);
const characterId = getCharacterIdFromPath(location.path);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const menuButtonRef = useRef<HTMLButtonElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const mobileMenuRef = useRef<HTMLElement>(null);
const mainRef = useRef<HTMLElement>(null);
const shouldRestoreMenuFocusRef = useRef(false);
useEffect(() => {
@@ -117,7 +154,10 @@ export function ProtectedAdminShell({ apiMode, routeError }: { readonly apiMode:
<div aria-hidden={isMobileMenuOpen} className="flex min-h-[100dvh] flex-col" inert={isMobileMenuOpen}>
<MockModeBanner apiMode={apiMode} />
<div className="flex min-h-0 flex-1">
<a className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-modal focus:rounded-md focus:bg-card focus:px-4 focus:py-2 focus:text-link" href="#app-main">
<a className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-modal focus:rounded-md focus:bg-card focus:px-4 focus:py-2 focus:text-link" href="#app-main" onClick={(event) => {
event.preventDefault();
mainRef.current?.focus();
}}>
</a>
<aside className="hidden w-60 shrink-0 border-r border-border bg-card p-4 lg:block">
@@ -131,14 +171,14 @@ export function ProtectedAdminShell({ apiMode, routeError }: { readonly apiMode:
<div className="flex min-w-0 items-center gap-3">
<button
aria-expanded={isMobileMenuOpen}
className="rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold lg:hidden"
className="shrink-0 whitespace-nowrap rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold lg:hidden"
onClick={() => setIsMobileMenuOpen(true)}
ref={menuButtonRef}
type="button"
>
</button>
<nav aria-label="브레드크럼" className="text-sm text-muted-foreground">
<nav aria-label="브레드크럼" className="hidden shrink-0 whitespace-nowrap text-sm text-muted-foreground sm:block">
<ol className="flex items-center gap-2">
<li></li>
<li aria-hidden="true">/</li>
@@ -146,12 +186,32 @@ export function ProtectedAdminShell({ apiMode, routeError }: { readonly apiMode:
</ol>
</nav>
</div>
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={() => void auth.logout()} type="button">
<button className="shrink-0 whitespace-nowrap rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={() => void auth.logout()} type="button">
</button>
</header>
<main aria-label="AI 캐릭터 관리" className="min-h-0 flex-1 overflow-auto p-4" id="app-main">
<AiCharactersPage routeError={routeError} />
<main aria-label="AI 캐릭터 관리" className="min-h-0 flex-1 overflow-auto p-4" id="app-main" ref={mainRef} tabIndex={-1}>
{location.successNotification === null ? null : (
<p aria-label="작업 성공" className="mb-4 rounded-lg border border-border bg-success-surface p-3 text-sm font-semibold text-success" role="status">
{location.successNotification}
</p>
)}
{location.path === routePaths.aiCharacterCreate ? <CharacterCreatePage apiClient={apiClient} /> : null}
{characterEditId !== null ? <CharacterEditPage apiClient={apiClient} characterId={characterEditId} /> : null}
{location.path !== routePaths.aiCharacterCreate && characterEditId === null && characterId === null && audioContentListCharacterId === null && audioContentCreateCharacterId === null && audioContentEditRoute === null && audioContentDetailRoute === null && communityPostCreateCharacterId === null && communityPostListCharacterId === null && fanTalkListCharacterId === null && seriesCreateCharacterId === null && seriesEditRoute === null && seriesListCharacterId === null && seriesOrderCharacterId === null && seriesDetailRoute === null ? <CharacterListPage apiClient={apiClient} routeError={routeError} /> : null}
{location.path !== routePaths.aiCharacterCreate && characterEditId === null && characterId !== null ? <CharacterDetailPage apiClient={apiClient} characterId={characterId} /> : null}
{audioContentListCharacterId !== null ? <AudioContentListPage apiClient={apiClient} characterId={audioContentListCharacterId} /> : null}
{audioContentCreateCharacterId !== null ? <AudioContentFormPage apiClient={apiClient} characterId={audioContentCreateCharacterId} uploadAuth={uploadAuth} /> : null}
{audioContentEditRoute !== null ? <AudioContentFormPage apiClient={apiClient} characterId={audioContentEditRoute.characterId} contentId={audioContentEditRoute.contentId} uploadAuth={uploadAuth} /> : null}
{audioContentDetailRoute !== null ? <AudioContentDetailPage apiClient={apiClient} characterId={audioContentDetailRoute.characterId} contentId={audioContentDetailRoute.contentId} /> : null}
{communityPostListCharacterId !== null ? <CommunityPostListPage apiClient={apiClient} characterId={communityPostListCharacterId} /> : null}
{communityPostCreateCharacterId !== null ? <CommunityPostFormPage apiClient={apiClient} characterId={communityPostCreateCharacterId} /> : null}
{fanTalkListCharacterId !== null ? <FanTalkListPage apiClient={apiClient} characterId={fanTalkListCharacterId} /> : null}
{seriesListCharacterId !== null ? <SeriesListPage apiClient={apiClient} characterId={seriesListCharacterId} /> : null}
{seriesCreateCharacterId !== null ? <SeriesFormPage apiClient={apiClient} characterId={seriesCreateCharacterId} /> : null}
{seriesEditRoute !== null ? <SeriesFormPage apiClient={apiClient} characterId={seriesEditRoute.characterId} seriesId={seriesEditRoute.seriesId} /> : null}
{seriesOrderCharacterId !== null ? <SeriesOrderPage apiClient={apiClient} characterId={seriesOrderCharacterId} /> : null}
{seriesDetailRoute !== null ? <SeriesDetailPage apiClient={apiClient} characterId={seriesDetailRoute.characterId} seriesId={seriesDetailRoute.seriesId} /> : null}
</main>
</div>
</div>

View File

@@ -1,7 +1,37 @@
export const routePaths = {
accessDenied: "/access-denied",
aiCharacterCreate: "/ai-characters/new",
aiCharacterAudioContentCreate: (characterId: string): CharacterAudioContentCreateRoutePath => `/ai-characters/${encodeURIComponent(characterId)}/audio-contents/new`,
aiCharacterAudioContentDetail: (characterId: string, contentId: string): CharacterAudioContentDetailRoutePath => `/ai-characters/${encodeURIComponent(characterId)}/audio-contents/${encodeURIComponent(contentId)}`,
aiCharacterAudioContentEdit: (characterId: string, contentId: string): CharacterAudioContentEditRoutePath => `/ai-characters/${encodeURIComponent(characterId)}/audio-contents/${encodeURIComponent(contentId)}/edit`,
aiCharacterAudioContents: (characterId: string): CharacterAudioContentsRoutePath => `/ai-characters/${encodeURIComponent(characterId)}/audio-contents`,
aiCharacterDetail: (characterId: string): CharacterRoutePath => `/ai-characters/${encodeURIComponent(characterId)}`,
aiCharacterEdit: (characterId: string): CharacterEditRoutePath => `/ai-characters/${encodeURIComponent(characterId)}/edit`,
aiCharacterCommunityPosts: (characterId: string): CharacterCommunityPostsRoutePath => `/ai-characters/${encodeURIComponent(characterId)}/community-posts`,
aiCharacterCommunityPostCreate: (characterId: string): CharacterCommunityPostCreateRoutePath => `/ai-characters/${encodeURIComponent(characterId)}/community-posts/new`,
aiCharacterFanTalks: (characterId: string): CharacterFanTalksRoutePath => `/ai-characters/${encodeURIComponent(characterId)}/fan-talks`,
aiCharacterSeries: (characterId: string): CharacterSeriesRoutePath => `/ai-characters/${encodeURIComponent(characterId)}/series`,
aiCharacterSeriesCreate: (characterId: string): CharacterSeriesCreateRoutePath => `/ai-characters/${encodeURIComponent(characterId)}/series/new`,
aiCharacterSeriesDetail: (characterId: string, seriesId: string): CharacterSeriesDetailRoutePath => `/ai-characters/${encodeURIComponent(characterId)}/series/${encodeURIComponent(seriesId)}`,
aiCharacterSeriesEdit: (characterId: string, seriesId: string): CharacterSeriesEditRoutePath => `/ai-characters/${encodeURIComponent(characterId)}/series/${encodeURIComponent(seriesId)}/edit`,
aiCharacterSeriesOrder: (characterId: string): CharacterSeriesOrderRoutePath => `/ai-characters/${encodeURIComponent(characterId)}/series/order`,
login: "/login",
aiCharacters: "/ai-characters",
} as const;
export type RoutePath = (typeof routePaths)[keyof typeof routePaths];
export type CharacterAudioContentCreateRoutePath = `/ai-characters/${string}/audio-contents/new`;
export type CharacterAudioContentDetailRoutePath = `/ai-characters/${string}/audio-contents/${string}`;
export type CharacterAudioContentEditRoutePath = `/ai-characters/${string}/audio-contents/${string}/edit`;
export type CharacterAudioContentsRoutePath = `/ai-characters/${string}/audio-contents`;
export type CharacterEditRoutePath = `/ai-characters/${string}/edit`;
export type CharacterCommunityPostsRoutePath = `/ai-characters/${string}/community-posts`;
export type CharacterCommunityPostCreateRoutePath = `/ai-characters/${string}/community-posts/new`;
export type CharacterRoutePath = `/ai-characters/${string}`;
export type CharacterFanTalksRoutePath = `/ai-characters/${string}/fan-talks`;
export type CharacterSeriesDetailRoutePath = `/ai-characters/${string}/series/${string}`;
export type CharacterSeriesCreateRoutePath = `/ai-characters/${string}/series/new`;
export type CharacterSeriesEditRoutePath = `/ai-characters/${string}/series/${string}/edit`;
export type CharacterSeriesOrderRoutePath = `/ai-characters/${string}/series/order`;
export type CharacterSeriesRoutePath = `/ai-characters/${string}/series`;
export type StaticRoutePath = typeof routePaths.accessDenied | typeof routePaths.login | typeof routePaths.aiCharacterCreate | typeof routePaths.aiCharacters;
export type RoutePath = CharacterAudioContentCreateRoutePath | CharacterAudioContentDetailRoutePath | CharacterAudioContentEditRoutePath | CharacterAudioContentsRoutePath | CharacterEditRoutePath | CharacterCommunityPostCreateRoutePath | CharacterCommunityPostsRoutePath | CharacterFanTalksRoutePath | CharacterRoutePath | CharacterSeriesCreateRoutePath | CharacterSeriesDetailRoutePath | CharacterSeriesEditRoutePath | CharacterSeriesOrderRoutePath | CharacterSeriesRoutePath | StaticRoutePath;

View File

@@ -2,6 +2,7 @@ import { http, HttpResponse } from "msw";
import { afterEach, describe, expect, test, vi } from "vitest";
import { login, logout } from "@/features/auth/api/auth-api";
import { UNKNOWN_API_ERROR_MESSAGE } from "@/shared/api/api-error";
import { createApiClient } from "@/shared/api/client";
import { server } from "@/shared/test/server";
@@ -77,7 +78,7 @@ describe("auth API", () => {
const request = login(client, { email: "admin@test.com", password: "password" });
// Then
await expect(request).rejects.toThrow("API 응답 형식이 올바르지 않습니다.");
await expect(request).rejects.toThrow(UNKNOWN_API_ERROR_MESSAGE);
});
test("posts logout once with Bearer and no body", async () => {

View File

@@ -0,0 +1,130 @@
import { render, screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { afterEach, expect, test, vi } from "vitest";
import { App } from "@/app/App";
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
import { server } from "@/shared/test/server";
const apiBaseUrl = "https://api.example.com";
const listEnvelope = {
success: true,
message: null,
data: { totalCount: 1, content: [] },
errorProperty: null,
} as const;
const activeDetail = {
id: 101,
characterUUID: "character-uuid-101",
name: "루나",
imageUrl: "https://cdn.example.com/luna.png",
description: "차분한 상담형 AI 캐릭터",
systemPrompt: "친절하게 답한다.",
characterType: "Character",
age: 24,
gender: "여성",
mbti: "INFJ",
speechPattern: "존댓말",
speechStyle: "다정함",
appearance: "푸른 머리와 밝은 눈",
region: "KR",
isActive: true,
tags: ["상담"],
hobbies: [],
values: [],
goals: [],
relationships: [],
personalities: [],
backgrounds: [],
memories: [],
originalWork: { id: 7, imageUrl: null, title: "달빛 상담소" },
} as const;
const inactiveDetail = { ...activeDetail, id: 202, characterUUID: "character-uuid-202", name: "미카", isActive: false, originalWork: null } as const;
function saveAdminSession() {
authSessionStorage.save({ token: "admin-token", role: "ADMIN" });
}
function useWorkspaceHandlers(detailStatus: 200 | 400 | 404 | 500 = 200) {
server.use(
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () => HttpResponse.json(listEnvelope)),
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/:characterId`, ({ params }) => {
if (detailStatus !== 200) {
return HttpResponse.json({ success: false, message: "상세 조회 실패", data: null, errorProperty: null }, { status: detailStatus });
}
return HttpResponse.json({
success: true,
message: null,
data: params.characterId === "202" ? inactiveDetail : activeDetail,
errorProperty: null,
});
}),
);
}
afterEach(() => {
window.history.replaceState({}, "", "/");
});
test("Character workspace restores an active detail from a deep link", async () => {
// Given
saveAdminSession();
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
useWorkspaceHandlers();
window.history.pushState({}, "", "/ai-characters/101");
// When
render(<App />);
// Then
expect(await screen.findByRole("heading", { name: "루나" })).toBeInTheDocument();
expect(screen.getByRole("navigation", { name: "캐릭터 워크스페이스 브레드크럼" })).toHaveTextContent(/AI 캐릭터.*루나/);
expect(screen.getByLabelText("상태: 공개")).toBeInTheDocument();
expect(screen.getByText("characterId: 101")).toBeInTheDocument();
expect(screen.getByText("character-uuid-101")).toBeInTheDocument();
const workspaceNavigation = screen.getByRole("navigation", { name: "캐릭터 워크스페이스 내비게이션" });
expect(workspaceNavigation).toHaveTextContent(/프로필.*오디오 콘텐츠.*시리즈.*커뮤니티.*FanTalk/);
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
expect(screen.queryByRole("tab", { name: "프로필" })).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "프로필" })).toHaveAttribute("aria-current", "page");
expect(screen.getByRole("img", { name: "루나 프로필 이미지" })).toBeInTheDocument();
});
test("Character workspace shows read-only policy for inactive detail without mutation entry points", async () => {
// Given
saveAdminSession();
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
useWorkspaceHandlers();
window.history.pushState({}, "", "/ai-characters/202");
// When
render(<App />);
// Then
expect(await screen.findByRole("heading", { name: "미카" })).toBeInTheDocument();
expect(screen.getByLabelText("상태: 비활성")).toBeInTheDocument();
expect(screen.getByText("characterId: 202")).toBeInTheDocument();
expect(screen.getByRole("alert")).toHaveTextContent("비활성 캐릭터는 읽기 전용입니다.");
expect(screen.getByRole("alert")).toHaveTextContent("조회만 가능하며 생성, 수정, 비활성화 같은 쓰기 작업은 실행되지 않습니다.");
expect(screen.getByRole("alert")).toHaveTextContent("복원 기능을 지원하지 않습니다.");
expect(screen.getByRole("alert")).toHaveTextContent("영구 삭제와 삭제 후 복구도 제공하지 않습니다.");
expect(screen.queryByText(/P3-T2|Task|범위/)).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "저장" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "비활성화" })).not.toBeInTheDocument();
});
test.each([400, 404, 500] as const)("Character workspace uses the common error state for detail %s", async (status) => {
// Given
saveAdminSession();
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
useWorkspaceHandlers(status);
window.history.pushState({}, "", "/ai-characters/999");
// When
render(<App />);
// Then
await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent("상세 조회 실패"));
expect(screen.queryByText("비활성 캐릭터는 읽기 전용입니다.")).not.toBeInTheDocument();
});

View File

@@ -0,0 +1,73 @@
import type { ReactNode } from "react";
import { navigateTo } from "@/app/browser-location";
import { routePaths } from "@/app/route-paths";
import type { CharacterDetail } from "@/features/characters/model/types";
import { StatusBadge } from "@/shared/ui/status-badge";
type CharacterWorkspaceTab = "audio" | "community" | "fanTalks" | "profile" | "series";
function getTabClass(tab: CharacterWorkspaceTab, activeTab: CharacterWorkspaceTab): string {
return tab === activeTab
? "rounded-md bg-accent px-4 py-2 font-semibold text-accent-foreground"
: "rounded-md px-4 py-2 font-semibold text-muted-foreground hover:bg-accent hover:text-accent-foreground";
}
export function CharacterWorkspaceLayout({ activeTab = "profile", character, children }: { readonly activeTab?: CharacterWorkspaceTab; readonly character: CharacterDetail; readonly children: ReactNode }) {
const status = character.isActive ? "OPEN" : "INACTIVE";
const profilePath = routePaths.aiCharacterDetail(String(character.id));
const audioPath = routePaths.aiCharacterAudioContents(String(character.id));
const communityPath = routePaths.aiCharacterCommunityPosts(String(character.id));
const fanTalkPath = routePaths.aiCharacterFanTalks(String(character.id));
const seriesPath = routePaths.aiCharacterSeries(String(character.id));
const workspaceNavigationItems = [
{ label: "프로필", path: profilePath, tab: "profile" },
{ label: "오디오 콘텐츠", path: audioPath, tab: "audio" },
{ label: "시리즈", path: seriesPath, tab: "series" },
{ label: "커뮤니티", path: communityPath, tab: "community" },
{ label: "FanTalk", path: fanTalkPath, tab: "fanTalks" },
] as const;
return (
<section className="flex flex-col gap-4" aria-labelledby="character-workspace-title">
<nav aria-label="캐릭터 워크스페이스 브레드크럼" className="text-sm text-muted-foreground">
<ol className="flex flex-wrap items-center gap-2">
<li><a className="font-semibold text-link hover:text-link-hover" href={routePaths.aiCharacters}>AI </a></li>
<li aria-hidden="true">/</li>
<li className="font-semibold text-foreground">{character.name}</li>
</ol>
</nav>
<header className="rounded-lg border border-border bg-card p-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
{character.imageUrl === null ? (
<div aria-hidden="true" className="grid size-16 place-items-center rounded-lg bg-muted text-xl font-bold text-muted-foreground">{character.name.slice(0, 1)}</div>
) : (
<img alt={`${character.name} 프로필 이미지`} className="size-16 rounded-lg object-cover" height="64" src={character.imageUrl} width="64" />
)}
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold text-info">CHARACTER WORKSPACE</p>
<div className="mt-1 flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-bold leading-tight" id="character-workspace-title">{character.name}</h1>
<StatusBadge status={status} />
</div>
<p className="mt-2 text-sm font-semibold text-muted-foreground">characterId: {character.id}</p>
</div>
</div>
</header>
{character.isActive ? null : (
<section className="rounded-lg border border-inactive bg-inactive-surface p-4 text-inactive" role="alert">
<h2 className="font-semibold"> .</h2>
<p className="mt-2 text-sm"> , , . . .</p>
</section>
)}
<nav className="flex flex-wrap gap-2 rounded-lg border border-border bg-card p-2" aria-label="캐릭터 워크스페이스 내비게이션">
{workspaceNavigationItems.map((item) => (
<a aria-current={item.tab === activeTab ? "page" : undefined} className={getTabClass(item.tab, activeTab)} href={item.path} key={item.tab} onClick={(event) => { event.preventDefault(); navigateTo(item.path); }}>{item.label}</a>
))}
</nav>
<div>
{children}
</div>
</section>
);
}

View File

@@ -1,7 +1,7 @@
import { http, HttpResponse } from "msw";
import { afterEach, describe, expect, test, vi } from "vitest";
import { AccessDeniedError } from "../api-error";
import { AccessDeniedError, ApiError, UNKNOWN_API_ERROR_MESSAGE } from "../api-error";
import { createApiClient } from "../client";
import { server } from "../../test/server";
import { apiBaseUrl, createTestClient, valueSchema } from "./client-test-helpers";
@@ -147,6 +147,42 @@ describe("authenticated API requests", () => {
expect(onAuthExpired).toHaveBeenCalledTimes(2);
});
test.each([
["malformed JSON", () => new HttpResponse("not-json", { status: 401 })],
["empty body", () => new HttpResponse(null, { status: 401 })],
["schema mismatch", () => HttpResponse.json({ success: false, data: null, errorProperty: null }, { status: 401 })],
])("clears the session once for concurrent protected 401 responses with %s", async (_label, responseFactory) => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client, clearSession, onAuthExpired } = createTestClient();
server.use(http.get(`${apiBaseUrl}/protected`, responseFactory));
// When
const results = await Promise.allSettled([
client.request({
path: "/protected",
responseSchema: valueSchema,
authentication: "required",
}),
client.request({
path: "/protected",
responseSchema: valueSchema,
authentication: "required",
}),
]);
// Then
for (const result of results) {
expect(result.status).toBe("rejected");
if (result.status === "rejected") {
expect(result.reason).toBeInstanceOf(ApiError);
expect(result.reason).toMatchObject({ status: 401, message: UNKNOWN_API_ERROR_MESSAGE, errorProperty: null });
}
}
expect(clearSession).toHaveBeenCalledTimes(1);
expect(onAuthExpired).toHaveBeenCalledTimes(1);
});
test("surfaces access denied without clearing the session", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);

View File

@@ -1,7 +1,7 @@
import { http, HttpResponse } from "msw";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ApiError } from "../api-error";
import { ApiError, UNKNOWN_API_ERROR_MESSAGE } from "../api-error";
import { server } from "../../test/server";
import { apiBaseUrl, createTestClient, valueSchema } from "./client-test-helpers";
@@ -115,7 +115,7 @@ describe("API client", () => {
await expect(request).rejects.toBeInstanceOf(ApiError);
});
test("surfaces a network failure without a mock response", async () => {
test("normalizes a network failure to the shared unknown API error", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client } = createTestClient();
@@ -129,6 +129,27 @@ describe("API client", () => {
});
// Then
await expect(request).rejects.toBeInstanceOf(TypeError);
await expect(request).rejects.toMatchObject({ status: 0, message: UNKNOWN_API_ERROR_MESSAGE, errorProperty: null });
});
test.each([
["malformed JSON", "not-json"],
["malformed envelope", JSON.stringify({ success: false, data: null, errorProperty: null })],
["empty message", JSON.stringify({ success: false, message: "", data: null, errorProperty: null })],
])("normalizes %s error responses to the shared unknown API error", async (_label, body) => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client } = createTestClient();
server.use(http.get(`${apiBaseUrl}/unknown-error`, () => new HttpResponse(body, { status: 500 })));
// When
const request = client.request({
path: "/unknown-error",
responseSchema: valueSchema,
authentication: "none",
});
// Then
await expect(request).rejects.toMatchObject({ status: 500, message: UNKNOWN_API_ERROR_MESSAGE, errorProperty: null });
});
});

View File

@@ -1,52 +1,8 @@
import { describe, expect, test } from "vitest";
import { createPageParams, type PageData } from "../pagination";
describe("createPageParams", () => {
test("uses documented default page and size", () => {
// Given
const request = {};
// When
const pageParams = createPageParams(request);
// Then
expect(pageParams).toEqual({ page: 0, size: 20 });
});
test("clamps size to the documented lower bound", () => {
// Given
const request = { size: 1 };
// When
const pageParams = createPageParams(request);
// Then
expect(pageParams.size).toBe(20);
});
test("clamps size to the documented upper bound", () => {
// Given
const request = { size: 51 };
// When
const pageParams = createPageParams(request);
// Then
expect(pageParams.size).toBe(50);
});
test("keeps a provided page unchanged", () => {
// Given
const request = { page: 3, size: 20 };
// When
const pageParams = createPageParams(request);
// Then
expect(pageParams.page).toBe(3);
});
import type { PageData } from "../pagination";
describe("PageData", () => {
test("defines the documented page response shape", () => {
// Given
const page: PageData<string> = {

View File

@@ -1,3 +1,5 @@
export const UNKNOWN_API_ERROR_MESSAGE = "알 수 없는 오류가 발생했습니다.";
export type ApiErrorOptions = {
readonly status: number;
readonly message: string;

View File

@@ -1,6 +1,6 @@
import type { z } from "zod";
import { AccessDeniedError, ApiError } from "./api-error";
import { AccessDeniedError, ApiError, UNKNOWN_API_ERROR_MESSAGE } from "./api-error";
import { getRuntimeEnv } from "../config/env";
import { createApiResponseSchema } from "./types";
@@ -32,21 +32,26 @@ function toApiError(
readonly errorProperty: string | null;
},
): ApiError {
const message = response.message.trim().length === 0 ? UNKNOWN_API_ERROR_MESSAGE : response.message;
if (status === 403) {
return new AccessDeniedError({
status,
message: response.message,
message,
errorProperty: response.errorProperty,
});
}
return new ApiError({
status,
message: response.message,
message,
errorProperty: response.errorProperty,
});
}
function unknownApiError(status: number): ApiError {
return new ApiError({ status, message: UNKNOWN_API_ERROR_MESSAGE, errorProperty: null });
}
export function createApiClient(dependencies: ApiClientDependencies): ApiClient {
let hasHandledAuthenticationExpiry = false;
let activeProtectedRequestCount = 0;
@@ -81,17 +86,36 @@ export function createApiClient(dependencies: ApiClientDependencies): ApiClient
init.body = options.body;
}
const response = await fetch(new URL(options.path, getRuntimeEnv().apiBaseUrl), init);
const parsedResponse = createApiResponseSchema(options.responseSchema).safeParse(
await response.json(),
);
let response: Response;
try {
response = await fetch(new URL(options.path, getRuntimeEnv().apiBaseUrl), init);
} catch (error) {
if (error instanceof TypeError) {
throw unknownApiError(0);
}
throw error;
}
if (response.status === 401 && isProtectedRequest && !hasHandledAuthenticationExpiry && dependencies.getToken() !== null) {
hasHandledAuthenticationExpiry = true;
dependencies.clearSession();
dependencies.onAuthExpired();
}
let responseJson: unknown;
try {
responseJson = await response.json();
} catch (error) {
if (error instanceof SyntaxError) {
throw unknownApiError(response.status);
}
throw error;
}
const parsedResponse = createApiResponseSchema(options.responseSchema).safeParse(responseJson);
if (!parsedResponse.success) {
throw new ApiError({
status: response.status,
message: "API 응답 형식이 올바르지 않습니다.",
errorProperty: null,
});
throw unknownApiError(response.status);
}
const apiResponse = parsedResponse.data;
@@ -100,23 +124,11 @@ export function createApiClient(dependencies: ApiClientDependencies): ApiClient
return apiResponse.data;
}
if (!apiResponse.success) {
if (response.status === 401 && isProtectedRequest) {
if (!hasHandledAuthenticationExpiry) {
hasHandledAuthenticationExpiry = true;
dependencies.clearSession();
dependencies.onAuthExpired();
}
}
if (!apiResponse.success) {
throw toApiError(response.status, apiResponse);
}
throw toApiError(response.status, apiResponse);
}
throw new ApiError({
status: response.status,
message: "API 오류 응답 형식이 올바르지 않습니다.",
errorProperty: null,
});
throw unknownApiError(response.status);
} finally {
if (isProtectedRequest) {
activeProtectedRequestCount -= 1;

View File

@@ -5,20 +5,3 @@ export type PageData<Item> = {
readonly hasNext: boolean;
readonly items: readonly Item[];
};
export type PageParams = {
readonly page?: number;
readonly size?: number;
};
export function createPageParams(params: PageParams = {}): {
readonly page: number;
readonly size: number;
} {
const size = params.size ?? 20;
return {
page: params.page ?? 0,
size: Math.min(Math.max(size, 20), 50),
};
}

View File

@@ -0,0 +1,51 @@
import { expect, test, vi } from "vitest";
import { createImageCropSource } from "@/shared/lib/create-image-crop-source";
test("createImageCropSource releases owned Blob URLs exactly once", async () => {
const image = new File(["image"], "profile.png", { type: "image/png" });
const createObjectURL = vi.fn(() => "blob:profile");
const revokeObjectURL = vi.fn();
vi.stubGlobal("URL", { createObjectURL, revokeObjectURL });
vi.stubGlobal(
"Image",
class FakeImage extends EventTarget {
naturalHeight = 600;
naturalWidth = 800;
set src(_value: string) {
queueMicrotask(() => this.dispatchEvent(new Event("load")));
}
},
);
const source = await createImageCropSource(image);
expect(source).toMatchObject({ file: image, height: 600, previewUrl: "blob:profile", width: 800 });
expect(source.release).toBeDefined();
if (source.release === undefined) {
throw new Error("release missing");
}
source.release();
source.release();
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
expect(revokeObjectURL).toHaveBeenCalledWith("blob:profile");
});
test("createImageCropSource releases owned Blob URLs when preview loading fails", async () => {
const createObjectURL = vi.fn(() => "blob:broken");
const revokeObjectURL = vi.fn();
vi.stubGlobal("URL", { createObjectURL, revokeObjectURL });
vi.stubGlobal(
"Image",
class FakeImage extends EventTarget {
set src(_value: string) {
queueMicrotask(() => this.dispatchEvent(new Event("error")));
}
},
);
await expect(createImageCropSource(new File(["image"], "broken.png", { type: "image/png" }))).rejects.toThrow("Image preview unavailable");
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
expect(revokeObjectURL).toHaveBeenCalledWith("blob:broken");
});

View File

@@ -0,0 +1,22 @@
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";
export function createImageCropSource(file: File): Promise<CropSourceImage> {
return new Promise((resolve, reject) => {
const image = new Image();
const previewUrl = URL.createObjectURL(file);
let isReleased = false;
const release = () => {
if (!isReleased) {
URL.revokeObjectURL(previewUrl);
isReleased = true;
}
};
image.addEventListener("load", () => resolve({ file, height: image.naturalHeight, previewUrl, release, width: image.naturalWidth }));
image.addEventListener("error", () => {
release();
reject(new Error("Image preview unavailable"));
});
image.src = previewUrl;
});
}

View File

@@ -23,13 +23,28 @@ function restoreDescriptor(property: "getContext" | "toBlob", descriptor: Proper
}
test("calculateCropOutputSize caps width at maxWidth and keeps aspect height", () => {
expect(calculateCropOutputSize({ aspect: 1, maxWidth: 800, noUpscale: true, sourceHeight: 600, sourceWidth: 1200 })).toEqual({ height: 600, width: 600 });
expect(calculateCropOutputSize({ aspect: 210 / 297, maxWidth: 1000, noUpscale: true, sourceHeight: 1600, sourceWidth: 1200 })).toEqual({ height: 1414, width: 1000 });
expect(calculateCropOutputSize({ aspect: 1, maxWidth: 800, noUpscale: true, sourceHeight: 600, sourceWidth: 1200, zoom: 1 })).toEqual({ height: 600, width: 600 });
expect(calculateCropOutputSize({ aspect: 210 / 297, maxWidth: 1000, noUpscale: true, sourceHeight: 1600, sourceWidth: 1200, zoom: 1 })).toEqual({ height: 1414, width: 1000 });
});
test("calculateCropOutputSize never upscales when noUpscale is true", () => {
expect(calculateCropOutputSize({ aspect: 2, maxWidth: 800, noUpscale: true, sourceHeight: 800, sourceWidth: 600 })).toEqual({ height: 300, width: 600 });
expect(calculateCropOutputSize({ aspect: 2, maxWidth: 800, noUpscale: false, sourceHeight: 800, sourceWidth: 600 })).toEqual({ height: 400, width: 800 });
expect(calculateCropOutputSize({ aspect: 2, maxWidth: 800, noUpscale: true, sourceHeight: 800, sourceWidth: 600, zoom: 1 })).toEqual({ height: 300, width: 600 });
expect(calculateCropOutputSize({ aspect: 2, maxWidth: 800, noUpscale: false, sourceHeight: 800, sourceWidth: 600, zoom: 1 })).toEqual({ height: 400, width: 800 });
});
test("calculateCropOutputSize caps no-upscale output to the zoomed source crop", () => {
expect(calculateCropOutputSize({ aspect: 1, maxWidth: 800, noUpscale: true, sourceHeight: 600, sourceWidth: 600, zoom: 1.5 })).toEqual({ height: 400, width: 400 });
});
test("calculateCropOutputSize keeps both dimensions within the zoomed source crop", () => {
expect(calculateCropSourceRect({ aspect: 210 / 297, offsetX: 0, offsetY: 0, sourceHeight: 600, sourceWidth: 600, zoom: 1.8 })).toEqual({ height: 333, sourceX: 182, sourceY: 134, width: 236 });
expect(calculateCropOutputSize({ aspect: 210 / 297, maxWidth: 1000, noUpscale: true, sourceHeight: 600, sourceWidth: 600, zoom: 1.8 })).toEqual({ height: 333, width: 236 });
});
test("calculateCropOutputSize does not collapse tiny source crops to zero", () => {
expect(calculateCropOutputSize({ aspect: 210 / 297, maxWidth: 1000, noUpscale: true, sourceHeight: 1, sourceWidth: 1, zoom: 1 })).toEqual({ height: 1, width: 1 });
expect(calculateCropOutputSize({ aspect: 2, maxWidth: 1000, noUpscale: true, sourceHeight: 1, sourceWidth: 1, zoom: 1 })).toEqual({ height: 1, width: 1 });
expect(calculateCropOutputSize({ aspect: "free", maxWidth: 1000, noUpscale: true, sourceHeight: 2, sourceWidth: 1, zoom: 1.4 })).toEqual({ height: 1, width: 1 });
});
test("calculateCropSourceRect crops the largest centered source rectangle for the requested aspect", () => {
@@ -37,10 +52,20 @@ test("calculateCropSourceRect crops the largest centered source rectangle for th
expect(calculateCropSourceRect({ aspect: 1, offsetX: 10, offsetY: -20, sourceHeight: 600, sourceWidth: 1200, zoom: 1 })).toEqual({ height: 600, sourceX: 290, sourceY: 0, width: 600 });
});
test("calculateCropSourceRect converts preview frame movement to source pixels", () => {
expect(calculateCropSourceRect({ aspect: 1, offsetX: 10, offsetY: 0, previewFrameHeight: 256, previewFrameWidth: 256, sourceHeight: 3000, sourceWidth: 4000, zoom: 1 })).toEqual({ height: 3000, sourceX: 383, sourceY: 0, width: 3000 });
expect(calculateCropSourceRect({ aspect: 210 / 297, offsetX: 10, offsetY: -10, previewFrameHeight: 256, previewFrameWidth: 181, sourceHeight: 3000, sourceWidth: 4000, zoom: 1 })).toEqual({ height: 3000, sourceX: 822, sourceY: 0, width: 2121 });
});
test("calculateCropSourceRect uses the crop viewport once when zoomed", () => {
expect(calculateCropSourceRect({ aspect: 1, offsetX: 10, offsetY: -10, previewFrameHeight: 256, previewFrameWidth: 256, sourceHeight: 3000, sourceWidth: 4000, zoom: 1.5 })).toEqual({ height: 2000, sourceX: 922, sourceY: 578, width: 2000 });
expect(calculateCropSourceRect({ aspect: 210 / 297, offsetX: 10, offsetY: -10, previewFrameHeight: 256, previewFrameWidth: 181, sourceHeight: 3000, sourceWidth: 4000, zoom: 1.5 })).toEqual({ height: 2000, sourceX: 1215, sourceY: 578, width: 1414 });
});
test("createCroppedImageFile keeps a wide no-upscale crop opaque at the top and bottom", async () => {
let renderedCrop: RenderedCrop | null = null;
const imageFile = new File(["wide"], "wide.png", { type: "image/png" });
const outputSize = calculateCropOutputSize({ aspect: 1, maxWidth: 800, noUpscale: true, sourceHeight: 600, sourceWidth: 1200 });
const outputSize = calculateCropOutputSize({ aspect: 1, maxWidth: 800, noUpscale: true, sourceHeight: 600, sourceWidth: 1200, zoom: 1 });
const originalGetContext = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, "getContext");
const originalToBlob = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, "toBlob");

View File

@@ -4,6 +4,7 @@ export type CropOutputSizeRequest = {
readonly noUpscale: boolean;
readonly sourceHeight: number;
readonly sourceWidth: number;
readonly zoom: number;
};
export type CropOutputSize = {
@@ -18,6 +19,8 @@ export type CropRenderRequest = {
readonly offsetY: number;
readonly outputHeight: number;
readonly outputWidth: number;
readonly previewFrameHeight?: number;
readonly previewFrameWidth?: number;
readonly previewUrl: string;
readonly sourceHeight: number;
readonly sourceWidth: number;
@@ -28,6 +31,8 @@ export type CropSourceRectRequest = {
readonly aspect: number | "free";
readonly offsetX: number;
readonly offsetY: number;
readonly previewFrameHeight?: number;
readonly previewFrameWidth?: number;
readonly sourceHeight: number;
readonly sourceWidth: number;
readonly zoom: number;
@@ -44,7 +49,15 @@ function getAspect(aspect: number | "free", sourceWidth: number, sourceHeight: n
return aspect === "free" ? sourceWidth / sourceHeight : aspect;
}
export function calculateCropSourceRect({ aspect, offsetX, offsetY, sourceHeight, sourceWidth, zoom }: CropSourceRectRequest): CropSourceRect {
function scalePreviewOffset(offset: number, sourceSize: number, previewSize: number | undefined, zoom: number): number {
if (previewSize === undefined || previewSize <= 0) {
return offset / zoom;
}
return (offset * sourceSize) / previewSize / zoom;
}
export function calculateCropSourceRect({ aspect, offsetX, offsetY, previewFrameHeight, previewFrameWidth, sourceHeight, sourceWidth, zoom }: CropSourceRectRequest): CropSourceRect {
const cropAspect = getAspect(aspect, sourceWidth, sourceHeight);
const sourceAspect = sourceWidth / sourceHeight;
const baseWidth = sourceAspect > cropAspect ? Math.round(sourceHeight * cropAspect) : sourceWidth;
@@ -53,8 +66,8 @@ export function calculateCropSourceRect({ aspect, offsetX, offsetY, sourceHeight
const height = Math.round(baseHeight / zoom);
const maxSourceX = sourceWidth - width;
const maxSourceY = sourceHeight - height;
const centeredX = Math.round((sourceWidth - width) / 2 - offsetX / zoom);
const centeredY = Math.round((sourceHeight - height) / 2 - offsetY / zoom);
const centeredX = Math.round((sourceWidth - width) / 2 - scalePreviewOffset(offsetX, baseWidth, previewFrameWidth, zoom));
const centeredY = Math.round((sourceHeight - height) / 2 - scalePreviewOffset(offsetY, baseHeight, previewFrameHeight, zoom));
return {
height,
@@ -64,12 +77,18 @@ export function calculateCropSourceRect({ aspect, offsetX, offsetY, sourceHeight
};
}
export function calculateCropOutputSize({ aspect, maxWidth, noUpscale, sourceHeight, sourceWidth }: CropOutputSizeRequest): CropOutputSize {
export function calculateCropOutputSize({ aspect, maxWidth, noUpscale, sourceHeight, sourceWidth, zoom }: CropOutputSizeRequest): CropOutputSize {
const cropAspect = getAspect(aspect, sourceWidth, sourceHeight);
const cropRect = calculateCropSourceRect({ aspect, offsetX: 0, offsetY: 0, sourceHeight, sourceWidth, zoom: 1 });
const width = noUpscale ? Math.min(maxWidth, cropRect.width) : maxWidth;
const cropRect = calculateCropSourceRect({ aspect, offsetX: 0, offsetY: 0, sourceHeight, sourceWidth, zoom });
let width = noUpscale ? Math.min(maxWidth, cropRect.width) : maxWidth;
let height = noUpscale && width > 0 ? Math.max(1, Math.floor(width / cropAspect)) : Math.round(width / cropAspect);
return { height: Math.round(width / cropAspect), width };
if (noUpscale && height > cropRect.height) {
height = cropRect.height;
width = height === 0 ? 0 : Math.min(width, Math.max(1, Math.floor(height * cropAspect)));
}
return { height, width };
}
export function createCroppedImageFile(request: CropRenderRequest): Promise<File> {

View File

@@ -0,0 +1,22 @@
import { expect, test } from "vitest";
import { focusFirstInvalidControl } from "@/shared/lib/focus-first-invalid-control";
test("focusFirstInvalidControl focuses the first enabled invalid control in form order", () => {
// Given
document.body.innerHTML = `
<form>
<input aria-invalid="true" disabled />
<input aria-invalid="true" />
<textarea aria-invalid="true"></textarea>
</form>
`;
const form = document.querySelector("form");
// When
focusFirstInvalidControl(form);
// Then
expect(document.activeElement).toBe(document.querySelectorAll("input, textarea")[1]);
});

View File

@@ -0,0 +1,14 @@
export function focusFirstInvalidControl(form: HTMLFormElement | null): void {
if (form === null) {
return;
}
for (const element of Array.from(form.elements)) {
if (!(element instanceof HTMLElement) || element.getAttribute("aria-invalid") !== "true" || element.hasAttribute("disabled")) {
continue;
}
element.focus();
return;
}
}

View File

@@ -12,10 +12,7 @@ const adminToken = "mock-admin-jwt";
const aiCharactersPreviewSchema = z.object({
totalCount: z.number(),
page: z.literal(0),
size: z.literal(20),
hasNext: z.boolean(),
items: z.array(z.unknown()),
content: z.array(z.unknown()),
});
function createClient(token: string | null = adminToken) {
@@ -179,7 +176,7 @@ describe("mock auth handlers", () => {
expect(parsedFreshResponse).toEqual({
success: true,
message: null,
data: { totalCount: 0, page: 0, size: 20, hasNext: false, items: [] },
data: { totalCount: 2, content: expect.arrayContaining([expect.objectContaining({ name: "루나" })]) },
errorProperty: null,
});
});

View File

@@ -0,0 +1,177 @@
import { z } from "zod";
import { describe, expect, test } from "vitest";
import { createApiResponseSchema } from "@/shared/api/types";
import { audioContentDetailSchema, audioContentListResponseSchema } from "@/features/audio-contents/model/types";
import { createMockHandlers, createMockStore } from "@/shared/mocks/handlers";
import { server } from "@/shared/test/server";
const apiBaseUrl = "https://api.example.com";
const adminToken = "mock-admin-jwt";
const characterDetailPreviewSchema = z.object({
id: z.number(),
name: z.string(),
description: z.string(),
systemPrompt: z.string(),
isActive: z.boolean(),
});
const aiCharactersPreviewSchema = z.object({
totalCount: z.number(),
content: z.array(z.object({ id: z.number(), name: z.string() })),
});
const originalWorkPreviewSchema = z.object({ id: z.number(), title: z.string(), imageUrl: z.string().nullable() });
const originalWorkSearchItemSchema = z.object({
id: z.number().int(),
title: z.string(),
contentType: z.string(),
category: z.string(),
isAdult: z.boolean(),
description: z.string(),
originalWork: z.string().nullable(),
originalLink: z.string().nullable(),
writer: z.string().nullable(),
studio: z.string().nullable(),
originalLinks: z.array(z.string()),
tags: z.array(z.string()),
imageUrl: z.string().nullable(),
});
const nullSuccessSchema = z.null();
function requireData<Data>(data: Data | null): Data {
if (data === null) {
throw new Error("response data missing");
}
return data;
}
function useMockHandlers() {
server.use(...createMockHandlers(createMockStore(), apiBaseUrl));
}
function authorizedFetch(path: string, init: RequestInit = {}) {
return fetch(`${apiBaseUrl}${path}`, {
...init,
headers: { Authorization: `Bearer ${adminToken}`, ...init.headers },
});
}
function mutationInit(request: object): RequestInit {
const boundary = "test-boundary";
return {
headers: { "Content-Type": `multipart/form-data; boundary=${boundary}` },
body: `--${boundary}\r\nContent-Disposition: form-data; name="request"\r\nContent-Type: application/json\r\n\r\n${JSON.stringify(request)}\r\n--${boundary}--\r\n`,
};
}
describe("mock character handlers", () => {
test("P10-T1 original work search handler serves v2 full DTO and rejects missing searchTerm", async () => {
// Given
useMockHandlers();
// When
const missingResponse = await authorizedFetch("/api/v2/admin/ai-characters/original-works/search");
const searchResponse = await authorizedFetch("/api/v2/admin/ai-characters/original-works/search?searchTerm=%EB%8B%AC%EB%B9%9B");
// Then
expect(missingResponse.status).toBe(400);
expect(requireData(createApiResponseSchema(z.array(originalWorkSearchItemSchema)).parse(await searchResponse.json()).data)).toEqual([
expect.objectContaining({
id: 7,
title: "달빛 상담소",
contentType: "WEBTOON",
category: "힐링",
isAdult: false,
originalWork: "Moonlight Office",
originalLinks: ["https://example.com/moonlight"],
tags: ["상담", "힐링"],
}),
]);
});
test("create, update, and deactivate mutate list and detail contract responses", async () => {
// Given
useMockHandlers();
// When
const createResponse = await authorizedFetch("/api/v2/admin/ai-characters", {
method: "POST",
...mutationInit({ name: "노아", systemPrompt: "명확하게 답한다.", description: "새 안내형 캐릭터" }),
});
const createdListResponse = await authorizedFetch("/api/v2/admin/ai-characters?page=0&size=20");
const createdList = requireData(createApiResponseSchema(aiCharactersPreviewSchema).parse(await createdListResponse.json()).data);
const created = createdList.content.find((character) => character.name === "노아");
if (created === undefined) {
throw new Error("created character missing from list");
}
const updateResponse = await authorizedFetch(`/api/v2/admin/ai-characters/${created.id}`, {
method: "PUT",
...mutationInit({ name: "노아 수정", systemPrompt: "짧게 답한다.", description: "수정된 안내형 캐릭터" }),
});
const updatedDetailResponse = await authorizedFetch(`/api/v2/admin/ai-characters/${created.id}`);
const deactivateResponse = await authorizedFetch(`/api/v2/admin/ai-characters/${created.id}`, {
method: "PUT",
...mutationInit({ isActive: false }),
});
const finalListResponse = await authorizedFetch("/api/v2/admin/ai-characters?page=0&size=20");
// Then
expect(createApiResponseSchema(nullSuccessSchema).parse(await createResponse.json()).data).toBeNull();
expect(createApiResponseSchema(nullSuccessSchema).parse(await updateResponse.json()).data).toBeNull();
expect(requireData(createApiResponseSchema(characterDetailPreviewSchema).parse(await updatedDetailResponse.json()).data)).toEqual({
id: created.id,
name: "노아 수정",
description: "수정된 안내형 캐릭터",
systemPrompt: "짧게 답한다.",
isActive: true,
});
expect(createApiResponseSchema(nullSuccessSchema).parse(await deactivateResponse.json()).data).toBeNull();
expect(requireData(createApiResponseSchema(aiCharactersPreviewSchema).parse(await finalListResponse.json()).data).content).not.toContainEqual(expect.objectContaining({ id: created.id }));
});
test("P10-T1 create and update persist selected originalWorkId and clear it when omitted", async () => {
// Given
useMockHandlers();
// When
const createResponse = await authorizedFetch("/api/v2/admin/ai-characters", {
method: "POST",
...mutationInit({ name: "원작 캐릭터", systemPrompt: "명확하게 답한다.", description: "원작 연결", originalWorkId: 7 }),
});
const createdListResponse = await authorizedFetch("/api/v2/admin/ai-characters?page=0&size=20");
const createdList = requireData(createApiResponseSchema(aiCharactersPreviewSchema).parse(await createdListResponse.json()).data);
const created = createdList.content.find((character) => character.name === "원작 캐릭터");
if (created === undefined) {
throw new Error("created character missing from list");
}
const createdDetailResponse = await authorizedFetch(`/api/v2/admin/ai-characters/${created.id}`);
const updateResponse = await authorizedFetch(`/api/v2/admin/ai-characters/${created.id}`, {
method: "PUT",
...mutationInit({ name: "원작 해제 캐릭터", systemPrompt: "짧게 답한다.", description: "원작 해제" }),
});
const updatedDetailResponse = await authorizedFetch(`/api/v2/admin/ai-characters/${created.id}`);
// Then
expect(createApiResponseSchema(nullSuccessSchema).parse(await createResponse.json()).data).toBeNull();
expect(requireData(createApiResponseSchema(z.object({ originalWork: originalWorkPreviewSchema.nullable() })).parse(await createdDetailResponse.json()).data).originalWork).toEqual({ id: 7, title: "달빛 상담소", imageUrl: null });
expect(createApiResponseSchema(nullSuccessSchema).parse(await updateResponse.json()).data).toBeNull();
expect(requireData(createApiResponseSchema(z.object({ originalWork: originalWorkPreviewSchema.nullable() })).parse(await updatedDetailResponse.json()).data).originalWork).toBeNull();
});
test("audio content mock handlers expose contract-shaped list and detail without timezone", async () => {
// Given
useMockHandlers();
// When
const listResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/audio-contents?search_word=달빛&page=0&size=20");
const detailResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/audio-contents/9001");
// Then
const list = requireData(createApiResponseSchema(audioContentListResponseSchema).parse(await listResponse.json()).data);
const detail = requireData(createApiResponseSchema(audioContentDetailSchema).parse(await detailResponse.json()).data);
expect(list).toMatchObject({ totalCount: 1, items: [{ audioContentId: 9001, title: "달빛 상담 오디오", coverImageUrl: expect.stringMatching(/^data:image\//) }] });
expect(detail).toMatchObject({ contentId: 9001, title: "달빛 상담 오디오", coverImageUrl: expect.stringMatching(/^data:image\//), contentUrl: expect.stringMatching(/^data:audio\//), duration: "00:01" });
});
});

View File

@@ -19,6 +19,97 @@ function expectContainsEvery(source: string, tokens: readonly string[]): void {
}
}
function sectionBetween(source: string, start: string, end: string): string {
const startIndex = source.indexOf(start);
const endIndex = source.indexOf(end, startIndex + start.length);
expect(startIndex).toBeGreaterThanOrEqual(0);
expect(endIndex).toBeGreaterThan(startIndex);
return source.slice(startIndex, endIndex);
}
function sectionAtHeading(source: string, heading: string): string {
const headingLevel = /^(#{1,6})\s/.exec(heading)?.[1].length ?? 0;
const lines = source.split("\n");
const startIndex = lines.findIndex((line) => line === heading);
expect(headingLevel).toBeGreaterThan(0);
expect(startIndex).toBeGreaterThanOrEqual(0);
const endIndex = lines.findIndex((line, index) => {
const level = /^(#{1,6})\s/.exec(line)?.[1].length ?? 0;
return index > startIndex && level > 0 && level <= headingLevel;
});
return lines.slice(startIndex, endIndex === -1 ? undefined : endIndex).join("\n");
}
function progressRecordAtMarker(source: string, marker: string): string {
const lines = source.split("\n");
let startIndex = lines.length - 1;
while (startIndex >= 0 && lines[startIndex] !== marker) {
startIndex -= 1;
}
expect(startIndex).toBeGreaterThanOrEqual(0);
const endIndex = lines.findIndex((line, index) => index > startIndex && (/^\*\*.+:\*\*$/.test(line) || /^#{2,6}\s/.test(line)));
return lines.slice(startIndex, endIndex === -1 ? undefined : endIndex).join("\n");
}
function latestProgressRecord(source: string): string {
const progressLog = sectionAtHeading(source, "## 7. 검증 기록");
const markers = progressLog.split("\n").filter((line) => /^\*\*.+ — \d{4}-\d{2}-\d{2}:\*\*$/.test(line));
const marker = markers[markers.length - 1];
expect(marker).toBeDefined();
return progressRecordAtMarker(progressLog, marker ?? "");
}
function latestSectionAtLevel(source: string, level: number): string {
const lines = source.split("\n");
const headings: { index: number; level: number }[] = [];
let fence: { character: string; length: number } | undefined;
for (const [index, line] of lines.entries()) {
const fenceMarker = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1];
if (fence) {
const closingFence = line.trim();
const { character, length } = fence;
if (closingFence.length >= length && [...closingFence].every((closingCharacter) => closingCharacter === character)) {
fence = undefined;
}
continue;
}
if (fenceMarker) {
fence = { character: fenceMarker[0] ?? "", length: fenceMarker.length };
continue;
}
const headingLevel = /^(#{1,6})\s/.exec(line)?.[1].length;
if (headingLevel) {
headings.push({ index, level: headingLevel });
}
}
const matchingHeadings = headings.filter((heading) => heading.level === level);
const startIndex = matchingHeadings[matchingHeadings.length - 1]?.index ?? -1;
const endIndex = headings.find((heading) => heading.index > startIndex && heading.level <= level)?.index;
expect(startIndex).toBeGreaterThanOrEqual(0);
return lines.slice(startIndex, endIndex).join("\n");
}
function matchingLines(source: string, token: string): readonly string[] {
return source.split("\n").filter((line) => line.includes(token));
}
describe("mock preview documentation", () => {
test("documents the actual npm scripts and mode boundary in README", () => {
// Given
@@ -29,11 +120,11 @@ describe("mock preview documentation", () => {
`npm run dev (${packageJson.scripts.dev})`,
`npm run dev:mock (${packageJson.scripts["dev:mock"]})`,
`npm run e2e (${packageJson.scripts.e2e})`,
`npm run e2e:mock (${packageJson.scripts["e2e:mock"]})`,
];
// Then
expectContainsEvery(readme, actualScripts);
expectContainsEvery(readme, ["npm run e2e:mock", "Chromium/mobile Chrome matrix", "file filter나 `--project` 인자"]);
expectContainsEvery(readme, ["server mode", "mock mode", "VITE_API_MODE=server", "VITE_API_MODE=mock"]);
expectContainsEvery(readme, ["mock data reset", "production", "no-auto-fallback"]);
});
@@ -47,9 +138,150 @@ describe("mock preview documentation", () => {
expectContainsEvery(environment, ["VITE_API_MODE=server | mock", "npm run dev", "npm run dev:mock"]);
expectContainsEvery(environment, ["mock data reset", "production", "no-auto-fallback"]);
expectContainsEvery(scripts, ["npm run dev", "npm run dev:mock", "npm run e2e", "npm run e2e:mock"]);
expectContainsEvery(scripts, ["Chromium/mobile Chrome matrix", "file filter나 `--project` 인자"]);
expectContainsEvery(scripts, ["handler", "fixture", "mock E2E"]);
});
test("keeps browser support docs synced with Playwright projects", () => {
// Given
const readme = projectFile("README.md");
const playwrightConfig = projectFile("playwright.config.ts");
const prd = projectFile("docs/20260725_AI캐릭터관리자웹/prd.md");
const decisionLog = sectionAtHeading(prd, "## 16. 결정 기록");
const plan = projectFile("docs/20260725_AI캐릭터관리자웹/plan-task.md");
const chromeOnlyDecisions = matchingLines(decisionLog, "Chromium/mobile Chrome");
// When, Then
expectContainsEvery(playwrightConfig, ["name: \"chromium\"", "name: \"mobile-chrome\""]);
expect(playwrightConfig).not.toContain("name: \"webkit\"");
expect(playwrightConfig).not.toContain("name: \"mobile-safari\"");
expectContainsEvery(readme, ["데스크톱 Chrome", "모바일 Chrome", "Chromium/mobile Chrome"]);
expectContainsEvery(prd, ["데스크톱 Chrome", "모바일 Chrome", "Chromium/mobile Chrome"]);
expect(chromeOnlyDecisions).toHaveLength(1);
expectContainsEvery(chromeOnlyDecisions[0] ?? "", ["사용자 직접 지시", "Chrome 2종", "테스트 시간"]);
expectContainsEvery(plan, ["Chromium/mobile Chrome", "WebKit·Mobile Safari는 지원 범위에서 제외"]);
const syntheticDecisionLog = sectionAtHeading("## 16. 결정 기록\n- stale\n\n## 17. 후속 기록\n- Chromium/mobile Chrome 사용자 직접 지시 Chrome 2종 테스트 시간\n", "## 16. 결정 기록");
expect(syntheticDecisionLog).not.toContain("Chrome 2종");
});
test("keeps current Phase 9 Gate docs aligned with Chromium-only projects", () => {
// Given
const plan = projectFile("docs/20260725_AI캐릭터관리자웹/plan-task.md");
const phase9Review = projectFile("docs/20260725_AI캐릭터관리자웹/reviews/phase9-cross-cutting-quality.md");
const p9R8Task = sectionBetween(plan, "### Task R9.8", "**P9-R8 수정 검증 기록");
const p9R9Task = sectionBetween(plan, "### Task R9.9", "**P9-R9 수정 검증 기록");
const p9R10Task = sectionBetween(plan, "### Task R9.10", "**P9-R10 수정 검증 기록");
const p9R17Task = sectionAtHeading(plan, "### Task R9.17 — Phase 9 current metadata·최신 결론 동기화");
const p9R18Task = sectionAtHeading(plan, "### Task R9.18 — Phase 9 finding·checklist 상태 종결 contract");
const p9R19Task = sectionAtHeading(plan, "### Task R9.19 — fenced code 내부 가짜 H2 배제");
const p9R12Review = sectionBetween(phase9Review, "## 20. P9-R11 수정 결과 재점검", "## 21. P9-R12 수정 결과 재점검");
const p9R17Finding = sectionAtHeading(phase9Review, "### `REV-P9-017` — Phase 9 current metadata가 완료된 P10-R14를 후속으로 유지함");
const p9R18Finding = sectionAtHeading(phase9Review, "### `REV-P9-018` — 완료 결론과 소유 finding·Task checklist 범위가 불일치함");
const p9R19Finding = sectionAtHeading(phase9Review, "### `REV-P9-019` — 최신 H2 helper가 fenced heading과 동일 제목을 오인함");
const phase9Metadata = sectionAtHeading(phase9Review, "## 1. 리뷰 정보");
const latestPhase9Review = latestSectionAtLevel(phase9Review, 2);
const latestPhase9Conclusion = sectionAtHeading(latestPhase9Review, "### 종료 판정");
// When, Then
expectContainsEvery(p9R9Task, ["tests/e2e/server-mode-boundary.spec.ts"]);
expectContainsEvery(plan, ["Chromium/mobile Chrome"]);
expect(`${p9R8Task}\n${p9R9Task}\n${p9R10Task}`).not.toMatch(/--project=webkit|mobile-safari|mock matrix 4 projects|4-project 분할 script|server-boundary\.spec\.ts/);
expect(p9R10Task).not.toContain("자동 WebKit harness");
expect(p9R17Task).not.toContain("- [ ]");
expect(`${p9R18Task}\n${p9R19Task}`).not.toContain("- [ ]");
expect(p9R12Review).toContain("`REV-P9-012`/`P9-R12` 수정 완료");
expectContainsEvery(p9R17Finding, ["| 상태 | 수정 완료 |", "2026-08-01"]);
expectContainsEvery(p9R18Finding, ["| 상태 | 수정 완료 |", "2026-08-01"]);
expectContainsEvery(p9R19Finding, ["| 상태 | 수정 완료 |", "2026-08-01"]);
expectContainsEvery(phase9Metadata, ["`REV-P9-018`~`REV-P9-019`", "`P9-R18`~`P9-R19` 수정 완료"]);
expect(phase9Metadata).not.toContain("P10-R16");
expectContainsEvery(latestPhase9Review, ["`REV-P9-018`~`REV-P9-019`", "`P9-R18`~`P9-R19` 수정 완료", "Chromium/mobile Chrome 2-project"]);
expectContainsEvery(latestPhase9Conclusion, ["자동 보완 Task는 완료", "실제 crop pixel", "stale ADMIN", "Series/FanTalk/Comments/file policy"]);
expect(latestPhase9Conclusion).not.toMatch(/4-browser matrix|WebKit\/Mobile Safari 미실행을 남은 위험|4-project|후속 goal 필요|후속 필요|다음 자동 보완/);
const syntheticConclusion = sectionAtHeading("### 종료 판정\n- stale\n\n### 후속 판정\n- `REV-P9-013`/`P9-R13` 수정 완료\n", "### 종료 판정");
expect(syntheticConclusion).not.toContain("수정 완료");
const syntheticHeading = sectionAtHeading("## 24. P9-R14~R15 수정 결과 재점검 — 2026-07-31 stale\n- stale\n\n## 24. P9-R14~R15 수정 결과 재점검 — 2026-07-31\n- current\n", "## 24. P9-R14~R15 수정 결과 재점검 — 2026-07-31");
expect(syntheticHeading).not.toContain("stale");
expect(syntheticHeading).toContain("current");
const syntheticLatestReview = latestSectionAtLevel("## 24. 과거\n- 후속 필요\n\n## 25. 현재\n### 종료 판정\n- `REV-P9-017`/`P9-R17` 수정 완료\n", 2);
expect(syntheticLatestReview).not.toContain("후속 필요");
});
test("ignores fenced code headings when finding the latest review H2", () => {
const latestReview = latestSectionAtLevel("## 27. 실제 최신 리뷰\n- current\n\n```md\n## 98. backtick 예시\n```\n\n~~~md\n## 99. tilde 예시\n~~~\n", 2);
expect(latestReview).toMatch(/^## 27\. 실제 최신 리뷰/);
});
test("returns the last review H2 when exact headings are repeated", () => {
const latestReview = latestSectionAtLevel("## 동일 제목\n- stale\n\n## 동일 제목\n- current\n", 2);
expect(latestReview).toContain("current");
expect(latestReview).not.toContain("stale");
});
test("selects the actual latest independent Progress record", () => {
const progressLog = "## 7. 검증 기록\n\n**동일 기록 — 2026-08-01:**\n- 자동 보완 완료\n\n**동일 기록 — 2026-08-01:**\n- 현재 재점검 기록\n- 보완 필요\n";
const latestProgress = latestProgressRecord(progressLog);
expect(latestProgress).toContain("현재 재점검 기록");
expect(latestProgress).toContain("보완 필요");
expect(latestProgress).not.toContain("자동 보완 완료");
});
test("keeps Phase 10 current state scoped to completed automatic remediation and manual QA", () => {
// Given
const plan = projectFile("docs/20260725_AI캐릭터관리자웹/plan-task.md");
const phase10Review = projectFile("docs/20260725_AI캐릭터관리자웹/reviews/phase10-openapi-follow-up.md");
const documentStatus = sectionBetween(plan, "| 문서 항목 | 내용 |", "## 목표");
const currentState = sectionAtHeading(plan, "## 현재 상태");
const topProgress = sectionAtHeading(plan, "## Progress");
const executionOrder = sectionAtHeading(plan, "## 실행 순서와 의존성");
const findings = sectionAtHeading(plan, "## 발견된 문제");
const p9R16Task = sectionBetween(plan, "### Task R9.16", "**P9-R16 수정 검증 기록");
const p10R14Task = sectionBetween(plan, "### Task R10.14", "**P10-R14 수정 검증 기록");
const p10R15Task = sectionAtHeading(plan, "### Task R10.15 — §7 최신 Progress와 실제 marker scope 복구");
const p10R16Task = sectionAtHeading(plan, "### Task R10.16 — Phase 10 finding·checklist·최신 Progress contract 종결");
const p10R17Task = sectionAtHeading(plan, "### Task R10.17 — 중복 Progress marker의 마지막 record 보장");
const p10R13Progress = progressRecordAtMarker(plan, "**P10-R13 수정 검증 기록 — 2026-07-31:**");
const latestProgress = latestProgressRecord(plan);
const p10R16Finding = sectionAtHeading(phase10Review, "### `REV-P10-016` — contract가 §7 최신 Progress 대신 Task-local 기록을 검사함");
const p10R17Finding = sectionAtHeading(phase10Review, "### `REV-P10-017` — 완료 finding·Task와 실제 최신 §7 Progress가 contract에서 누락됨");
const p10R18Finding = sectionAtHeading(phase10Review, "### `REV-P10-018` — 동일 Progress marker 반복 시 첫 record를 다시 선택함");
const phase10Metadata = sectionAtHeading(phase10Review, "## 1. 리뷰 정보");
const latestReview = latestSectionAtLevel(phase10Review, 2);
const latestConclusion = sectionAtHeading(latestReview, "### 종료 판정");
// When, Then
const currentStateTokens = ["자동 보완 완료", "P9-R18", "P9-R19", "P10-R16", "P10-R17", "실제 crop pixel", "stale ADMIN", "Series/FanTalk/Comments/file policy"];
expectContainsEvery(documentStatus, currentStateTokens);
expectContainsEvery(currentState, currentStateTokens);
expectContainsEvery(topProgress, currentStateTokens);
expectContainsEvery(executionOrder, ["자동 보완 Task는 완료", "Chromium/mobile Chrome"]);
expectContainsEvery(findings, ["P9-R18", "P9-R19", "P10-R16", "P10-R17", "보완했다", "실제 crop pixel", "stale ADMIN", "Series/FanTalk/Comments/file policy"]);
expect(`${documentStatus}\n${currentState}\n${executionOrder}\n${findings}`).not.toMatch(/보완 필요|다음 실행 순서/);
expect(`${p9R16Task}\n${p10R14Task}\n${p10R15Task}\n${p10R16Task}\n${p10R17Task}`).not.toContain("- [ ]");
expectContainsEvery(p10R13Progress, ["P9-R14", "P9-R15", "P10-R13", "실제 crop pixel", "stale ADMIN", "Series/FanTalk/Comments/file policy"]);
expect(p10R13Progress).not.toMatch(/P9-R16|P10-R14|남은 항목: `P9-R14`|`P9-R14` → `P9-R15` → `P10-R13`|보완 필요/);
expectContainsEvery(latestProgress, ["P9-R18", "P9-R19", "P10-R16", "P10-R17", "자동 보완 Task는 완료", "실제 crop pixel", "stale ADMIN", "Series/FanTalk/Comments/file policy"]);
expect(latestProgress).not.toMatch(/P9-R16` → `P10-R14|후속 필요|보완 필요/);
expectContainsEvery(p10R16Finding, ["| 상태 | 수정 완료 |", "2026-08-01"]);
expectContainsEvery(p10R17Finding, ["| 상태 | 수정 완료 |", "2026-08-01"]);
expectContainsEvery(p10R18Finding, ["| 상태 | 수정 완료 |", "2026-08-01"]);
expectContainsEvery(phase10Metadata, ["`REV-P10-018`/`P10-R17` 수정 완료"]);
expectContainsEvery(latestReview, ["`REV-P10-018`/`P10-R17` 수정 완료", "마지막 동일 marker occurrence"]);
expectContainsEvery(latestConclusion, ["`REV-P10-018`/`P10-R17` 수정 완료", "자동 보완 Task는 완료", "실제 crop pixel", "stale ADMIN", "Series/FanTalk/Comments/file policy"]);
const syntheticProgress = progressRecordAtMarker("**P10-R13 수정 검증 기록 — 2026-07-31:**\n- stale\n\n**P10-R14 수정 검증 기록 — 2026-07-31:**\n- 자동 보완 완료\n", "**P10-R13 수정 검증 기록 — 2026-07-31:**");
expect(syntheticProgress).not.toContain("자동 보완 완료");
const syntheticReview = sectionAtHeading("## 25. P10-R14 수정 후 검증 — 2026-07-31\n### 검토 범위\n- `REV-P10-015`/`P10-R14` 수정 완료\n\n### 종료 판정\n- stale\n", "## 25. P10-R14 수정 후 검증 — 2026-07-31");
expect(sectionAtHeading(syntheticReview, "### 종료 판정")).not.toContain("수정 완료");
const syntheticLatestReview = latestSectionAtLevel("## 25. 과거\n### 종료 판정\n- 후속 필요\n\n## 26. 현재\n### 종료 판정\n- `REV-P10-016`/`P10-R15` 수정 완료\n", 2);
expect(syntheticLatestReview).not.toContain("후속 필요");
});
test("keeps the Phase 2 plan files and progress synced with the implementation", () => {
// Given
const plan = projectFile("docs/20260725_AI캐릭터관리자웹/plan-task.md");

View File

@@ -21,7 +21,6 @@ describe("mock API mode scripts", () => {
// Given
const expectedDevelopmentMockScript = "VITE_API_MODE=mock vite --host 127.0.0.1 --port 8889 --strictPort";
const expectedE2eServerScript = "VITE_API_MODE=server playwright test";
const expectedE2eMockScript = "VITE_API_MODE=mock playwright test";
// When
const developmentMockScript = packageJson.scripts["dev:mock"];
@@ -31,7 +30,12 @@ describe("mock API mode scripts", () => {
// Then
expect(developmentMockScript).toBe(expectedDevelopmentMockScript);
expect(e2eServerScript).toBe(expectedE2eServerScript);
expect(e2eMockScript).toBe(expectedE2eMockScript);
expect(e2eMockScript).toContain("VITE_API_MODE=mock playwright test \"$@\"");
expect(e2eMockScript).toContain("npm run e2e:mock:chromium");
expect(e2eMockScript).toContain("npm run e2e:mock:mobile-chrome");
expect(e2eMockScript).not.toContain("webkit");
expect(e2eMockScript).not.toContain("mobile-safari");
expect(packageJson.scripts["e2e:mock:raw"]).toBe("VITE_API_MODE=mock playwright test");
});
test("keeps mode-specific E2E spec allowlists in Playwright config", () => {
@@ -41,8 +45,9 @@ describe("mock API mode scripts", () => {
"**/smoke.spec.ts",
"**/auth.spec.ts",
"**/accessibility-shell.spec.ts",
"**/series.spec.ts",
];
const expectedMockSpecs = ["**/mock-preview-shell.spec.ts", "**/mock-mode-boundary.spec.ts"];
const expectedMockSpecs = ["**/mock-preview-shell.spec.ts", "**/mock-mode-boundary.spec.ts", "**/character-workspace.spec.ts", "**/audio-content.spec.ts", "**/series.spec.ts", "**/community.spec.ts", "**/fan-talk.spec.ts", "**/resource-workflows.spec.ts", "**/error-mapping.spec.ts", "**/responsive-capabilities.spec.ts", "**/accessibility.spec.ts"];
// When, Then
expectContainsEvery(playwrightConfig, expectedServerSpecs);

View File

@@ -0,0 +1,126 @@
import type { AudioContentDetail, AudioContentListItem } from "@/features/audio-contents/model/types";
import type { AudioContentTheme } from "@/features/audio-contents/schemas/audio-content-schema";
function writeAscii(bytes: Uint8Array, offset: number, value: string): void {
for (let index = 0; index < value.length; index += 1) {
bytes[offset + index] = value.charCodeAt(index);
}
}
function createOneSecondWavDataUrl(): string {
const sampleRate = 8_000;
const bytesPerSample = 2;
const dataSize = sampleRate * bytesPerSample;
const bytes = new Uint8Array(44 + dataSize);
const view = new DataView(bytes.buffer);
writeAscii(bytes, 0, "RIFF");
view.setUint32(4, 36 + dataSize, true);
writeAscii(bytes, 8, "WAVEfmt ");
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * bytesPerSample, true);
view.setUint16(32, bytesPerSample, true);
view.setUint16(34, 16, true);
writeAscii(bytes, 36, "data");
view.setUint32(40, dataSize, true);
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return `data:audio/wav;base64,${btoa(binary)}`;
}
export const previewAudioUrl = createOneSecondWavDataUrl();
const previewCoverImageUrl = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='128' height='128' viewBox='0 0 128 128'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop stop-color='%23D9F6FF'/%3E%3Cstop offset='1' stop-color='%2300BDF7'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='128' height='128' rx='20' fill='url(%23g)'/%3E%3Ccircle cx='64' cy='64' r='30' fill='%23FFFFFF' fill-opacity='.72'/%3E%3Cpath d='M52 48v32l30-16z' fill='%23062B36'/%3E%3C/svg%3E";
export const mockAudioContentThemes = [
{ id: 7, theme: "힐링", image: previewCoverImageUrl },
{ id: 8, theme: "안내", image: previewCoverImageUrl },
] as const satisfies readonly AudioContentTheme[];
export const mockAudioContentListItems = [
{
audioContentId: 9001,
title: "달빛 상담 오디오",
detail: "잠들기 전 듣는 상담 오디오",
coverImageUrl: previewCoverImageUrl,
creatorNickname: "루나",
theme: "힐링",
price: 1000,
totalContentCount: 5,
remainingContentCount: 4,
isAdult: false,
isPointAvailable: true,
isCommentAvailable: true,
remainingTime: "7일",
contentUrl: previewAudioUrl,
date: "2026-07-28T01:00:00Z",
releaseDate: "2026-07-28T01:00:00Z",
tags: "상담,힐링",
},
{
audioContentId: 9002,
title: "아침 안내 오디오",
detail: "하루를 시작하는 안내",
coverImageUrl: previewCoverImageUrl,
creatorNickname: "루나",
theme: "안내",
price: 0,
totalContentCount: null,
remainingContentCount: null,
isAdult: false,
isPointAvailable: false,
isCommentAvailable: false,
remainingTime: "",
contentUrl: previewAudioUrl,
date: "2026-07-27 09:00:00",
releaseDate: null,
tags: "안내",
},
] as const satisfies readonly AudioContentListItem[];
export const mockAudioContentDetails = [
{
contentId: 9001,
title: "달빛 상담 오디오",
detail: "잠들기 전 듣는 상담 오디오",
languageCode: "ko",
coverImageUrl: previewCoverImageUrl,
contentUrl: previewAudioUrl,
themeStr: "힐링",
tag: "상담,힐링",
price: 1000,
duration: "00:01",
releaseDate: "2026-07-28T01:00:00Z",
totalContentCount: 5,
remainingContentCount: 4,
orderSequence: 1,
isActivePreview: true,
isAdult: false,
isMosaic: false,
isOnlyRental: false,
existOrdered: false,
purchaseOption: "RENT_ONLY",
orderType: null,
remainingTime: "7일",
creatorOtherContentList: [],
sameThemeOtherContentList: [],
isCommentAvailable: true,
isLike: false,
likeCount: 0,
commentList: [],
commentCount: 0,
isPin: false,
isAvailablePin: false,
creator: { creatorId: 101, nickname: "루나", profileImageUrl: "https://cdn.example.com/mock/luna.png", isFollowing: false, isFollow: false, isNotify: false },
previousContent: null,
nextContent: null,
buyerList: [],
isAvailableUsePoint: true,
translated: null,
},
] as const satisfies readonly AudioContentDetail[];

View File

@@ -0,0 +1,133 @@
import { http, HttpResponse } from "msw";
import type { RequestHandler } from "msw";
import type { AudioContentCreateRequest, AudioContentDeactivateRequest, AudioContentTheme, AudioContentUpdateRequest } from "@/features/audio-contents/schemas/audio-content-schema";
import { error, ok } from "@/shared/mocks/contract";
type AccessResponse = (request: Request) => Response | null;
type AudioContentStore = {
readonly createAudioContent: (characterId: string, request: AudioContentCreateRequest) => number | null;
readonly deactivateAudioContent: (characterId: string, contentId: string, request: AudioContentDeactivateRequest) => boolean;
readonly getAudioContent: (characterId: string, contentId: string) => unknown | null;
readonly listAudioContentThemes: () => readonly AudioContentTheme[];
readonly listAudioContents: (characterId: string, searchWord: string | null, page: number, size: number) => unknown | null;
readonly updateAudioContent: (characterId: string, contentId: string, request: AudioContentUpdateRequest) => boolean;
};
type AudioContentMockHandlerOptions = {
readonly apiBaseUrl: string;
readonly invalidRequestMessage: string;
readonly parseCreateRequest: (request: Request) => Promise<AudioContentCreateRequest | null>;
readonly parseDeactivateRequest: (request: Request) => Promise<AudioContentDeactivateRequest | null>;
readonly parseUpdateRequest: (request: Request) => Promise<AudioContentUpdateRequest | null>;
};
function endpointUrl(apiBaseUrl: string, path: string): string {
return new URL(path, apiBaseUrl).toString();
}
function parseListQuery(request: Request): { readonly page: number; readonly searchWord: string | null; readonly size: number } | null {
const url = new URL(request.url);
const page = Number(url.searchParams.get("page") ?? "0");
const size = Number(url.searchParams.get("size") ?? "20");
const searchWord = url.searchParams.get("search_word");
if (!Number.isInteger(page) || page < 0 || !Number.isInteger(size) || size < 1 || (searchWord !== null && searchWord.trim().length < 2)) {
return null;
}
return { page, searchWord, size };
}
export function createAudioContentMockHandlers(store: AudioContentStore, accessResponse: AccessResponse, options: AudioContentMockHandlerOptions): readonly RequestHandler[] {
return [
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/audio-content-themes"), ({ request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
return HttpResponse.json(ok(store.listAudioContentThemes()));
}),
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/audio-contents"), ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const query = parseListQuery(request);
if (query === null) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const audioContents = store.listAudioContents(characterId, query.searchWord, query.page, query.size);
if (audioContents === null) {
return HttpResponse.json(error("AI 캐릭터를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(audioContents));
}),
http.post(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/audio-contents"), async ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const mutation = await options.parseCreateRequest(request);
if (mutation === null) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const contentId = store.createAudioContent(characterId, mutation);
if (contentId === null) {
return HttpResponse.json(error("AI 캐릭터 또는 오디오 테마를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok({ contentId }));
}),
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/audio-contents/:contentId"), ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const contentId = typeof params.contentId === "string" ? params.contentId : "";
const audioContent = store.getAudioContent(characterId, contentId);
if (audioContent === null) {
return HttpResponse.json(error("오디오 콘텐츠를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(audioContent));
}),
http.put(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/audio-contents/:contentId"), async ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const updateMutation = await options.parseUpdateRequest(request.clone());
if (updateMutation !== null) {
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const contentId = typeof params.contentId === "string" ? params.contentId : "";
if (!store.updateAudioContent(characterId, contentId, updateMutation)) {
return HttpResponse.json(error("오디오 콘텐츠를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(null));
}
const deactivateMutation = await options.parseDeactivateRequest(request);
if (deactivateMutation === null) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const contentId = typeof params.contentId === "string" ? params.contentId : "";
if (!store.deactivateAudioContent(characterId, contentId, deactivateMutation)) {
return HttpResponse.json(error("오디오 콘텐츠를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(null));
}),
];
}

View File

@@ -0,0 +1,228 @@
import { z } from "zod";
import type { AudioContentDetail, AudioContentListItem } from "@/features/audio-contents/model/types";
import { audioContentCreateRequestSchema, audioContentDeactivateRequestSchema, audioContentUpdateRequestSchema } from "@/features/audio-contents/schemas/audio-content-schema";
import type { AudioContentCreateRequest, AudioContentDeactivateRequest, AudioContentTheme, AudioContentUpdateRequest } from "@/features/audio-contents/schemas/audio-content-schema";
import type { CharacterDetail } from "@/features/characters/model/types";
import { mockAudioContentDetails, mockAudioContentListItems, mockAudioContentThemes } from "@/shared/mocks/audio-content-fixtures";
type GetCharacter = (characterId: string) => CharacterDetail | null;
export class AudioContentMockStore {
#audioContentDetails: AudioContentDetail[] = mockAudioContentDetails.map(toMutableAudioDetail);
#audioContents: AudioContentListItem[] = mockAudioContentListItems.map(toMutableAudioListItem);
#nextAudioContentId = 9300;
readonly #getCharacter: GetCharacter;
constructor(getCharacter: GetCharacter) {
this.#getCharacter = getCharacter;
}
listAudioContents(characterId: string, searchWord: string | null, page: number, size: number) {
if (this.#getCharacter(characterId) === null) {
return null;
}
const normalizedSearch = searchWord?.trim().toLowerCase() ?? "";
const filteredAudioContents = normalizedSearch.length === 0
? this.#audioContents
: this.#audioContents.filter((audio) => `${audio.title} ${audio.detail} ${audio.theme} ${audio.tags}`.toLowerCase().includes(normalizedSearch));
const start = page * size;
return {
totalCount: filteredAudioContents.length,
items: filteredAudioContents.slice(start, start + size),
};
}
getAudioContent(characterId: string, contentId: string) {
if (this.#getCharacter(characterId) === null) {
return null;
}
return this.#audioContentDetails.find((audio) => String(audio.contentId) === contentId) ?? null;
}
listAudioContentThemes(): readonly AudioContentTheme[] {
return mockAudioContentThemes;
}
createAudioContent(characterId: string, request: AudioContentCreateRequest): number | null {
const character = this.#getCharacter(characterId);
if (character === null) {
return null;
}
const theme = mockAudioContentThemes.find((item) => item.id === request.themeId);
if (theme === undefined) {
return null;
}
this.#nextAudioContentId += 1;
const contentId = this.#nextAudioContentId;
const listItem: AudioContentListItem = {
audioContentId: contentId,
title: request.title,
detail: request.detail,
coverImageUrl: theme.image,
creatorNickname: character.name,
theme: theme.theme,
price: request.price,
totalContentCount: request.limited,
remainingContentCount: request.limited,
isAdult: request.isAdult,
isPointAvailable: request.isPointAvailable,
isCommentAvailable: request.isCommentAvailable,
remainingTime: "",
contentUrl: mockAudioContentListItems[0].contentUrl,
date: "2026-07-28 12:00:00",
releaseDate: request.releaseDate,
tags: request.tags,
};
const detail: AudioContentDetail = {
...mockAudioContentDetails[0],
contentId,
title: request.title,
detail: request.detail,
languageCode: request.languageCode,
coverImageUrl: theme.image,
contentUrl: listItem.contentUrl,
themeStr: theme.theme,
tag: request.tags,
price: request.price,
releaseDate: request.releaseDate,
totalContentCount: request.limited,
remainingContentCount: request.limited,
isAdult: request.isAdult,
purchaseOption: request.purchaseOption,
isCommentAvailable: request.isCommentAvailable,
isAvailableUsePoint: request.isPointAvailable,
creator: { ...mockAudioContentDetails[0].creator, creatorId: character.id, nickname: character.name },
};
this.#audioContents = [...this.#audioContents, listItem];
this.#audioContentDetails = [...this.#audioContentDetails, detail];
return contentId;
}
updateAudioContent(characterId: string, contentId: string, request: AudioContentUpdateRequest): boolean {
if (this.#getCharacter(characterId) === null) {
return false;
}
const current = this.getAudioContent(characterId, contentId);
if (current === null) {
return false;
}
const nextDetail: AudioContentDetail = {
...current,
title: request.title ?? current.title,
detail: request.detail ?? current.detail,
tag: request.tags ?? current.tag,
price: request.price ?? current.price,
isAdult: request.isAdult ?? current.isAdult,
isCommentAvailable: request.isCommentAvailable ?? current.isCommentAvailable,
isAvailableUsePoint: request.isPointAvailable ?? current.isAvailableUsePoint,
};
this.#audioContentDetails = this.#audioContentDetails.map((audio) => (audio.contentId === nextDetail.contentId ? nextDetail : audio));
this.#audioContents = this.#audioContents.map((audio) => (String(audio.audioContentId) === contentId ? {
...audio,
title: nextDetail.title,
detail: nextDetail.detail,
tags: nextDetail.tag,
price: nextDetail.price,
isAdult: nextDetail.isAdult,
isCommentAvailable: nextDetail.isCommentAvailable,
isPointAvailable: nextDetail.isAvailableUsePoint,
} : audio));
return true;
}
deactivateAudioContent(characterId: string, contentId: string, request: AudioContentDeactivateRequest): boolean {
if (!request.isActive) {
if (this.#getCharacter(characterId) === null) {
return false;
}
const current = this.getAudioContent(characterId, contentId);
if (current === null) {
return false;
}
this.#audioContents = this.#audioContents.filter((audio) => String(audio.audioContentId) !== contentId);
return true;
}
return false;
}
}
export async function parseAudioContentCreateRequest(request: Request): Promise<AudioContentCreateRequest | null> {
try {
const body = await request.text();
if (!body.includes('name="contentFile"') || !body.includes('name="coverImage"') || body.includes('name="audioFile"')) {
return null;
}
return audioContentCreateRequestSchema.parse(JSON.parse(extractMultipartJsonRequest(body)));
} catch (parseError) {
if (parseError instanceof SyntaxError || parseError instanceof z.ZodError || parseError instanceof Error && parseError.message === "missing request part") {
return null;
}
throw parseError;
}
}
export async function parseAudioContentUpdateRequest(request: Request): Promise<AudioContentUpdateRequest | null> {
try {
const body = await request.text();
if (body.includes('name="contentFile"') || body.includes('name="audioFile"')) {
return null;
}
return audioContentUpdateRequestSchema.parse(JSON.parse(extractMultipartJsonRequest(body)));
} catch (parseError) {
if (parseError instanceof SyntaxError || parseError instanceof z.ZodError || parseError instanceof Error && parseError.message === "missing request part") {
return null;
}
throw parseError;
}
}
export async function parseAudioContentDeactivateRequest(request: Request): Promise<AudioContentDeactivateRequest | null> {
try {
const body = await request.text();
return audioContentDeactivateRequestSchema.parse(JSON.parse(extractMultipartJsonRequest(body)));
} catch (parseError) {
if (parseError instanceof SyntaxError || parseError instanceof z.ZodError || parseError instanceof Error && parseError.message === "missing request part") {
return null;
}
throw parseError;
}
}
function extractMultipartJsonRequest(body: string): string {
const match = /name="request"(?:; filename="[^"]*")?\r\n(?:Content-Type: application\/json\r\n)?\r\n(?<json>.*?)\r\n--/s.exec(body);
const json = match?.groups?.json;
if (json === undefined) {
throw new Error("missing request part");
}
return json;
}
function toMutableAudioListItem(audio: (typeof mockAudioContentListItems)[number]): AudioContentListItem {
return { ...audio };
}
function toMutableAudioDetail(audio: (typeof mockAudioContentDetails)[number]): AudioContentDetail {
return {
...audio,
creatorOtherContentList: [...audio.creatorOtherContentList],
sameThemeOtherContentList: [...audio.sameThemeOtherContentList],
commentList: [...audio.commentList],
buyerList: [...audio.buyerList],
};
}

View File

@@ -17,13 +17,14 @@ describe("startMockWorker", () => {
test("starts browser MSW with an error policy for unhandled requests", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", "https://api.example.com");
const startOptions = { onUnhandledRequest: "error" };
const startOptions = { onUnhandledRequest: "error", quiet: true };
// When
await startMockWorker();
// Then
expect(setupWorkerMock).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything());
expect(setupWorkerMock).toHaveBeenCalled();
expect(setupWorkerMock.mock.calls[0]?.length).toBeGreaterThan(0);
expect(workerStart).toHaveBeenCalledWith(startOptions);
});
});

View File

@@ -7,5 +7,5 @@ let worker: ReturnType<typeof setupWorker> | null = null;
export async function startMockWorker(): Promise<void> {
worker ??= setupWorker(...createMockHandlers(createMockStore(), getRuntimeEnv().apiBaseUrl));
await worker.start({ onUnhandledRequest: "error" });
await worker.start({ onUnhandledRequest: "error", quiet: true });
}

View File

@@ -0,0 +1,146 @@
export const mockActiveCharacters = [
{
id: 101,
name: "루나",
imageUrl: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64'%3E%3Crect width='64' height='64' rx='12' fill='%2300BDF7'/%3E%3Ctext x='32' y='39' text-anchor='middle' font-size='24' font-family='sans-serif' fill='%23062B36'%3EL%3C/text%3E%3C/svg%3E",
description: "차분한 상담형 AI 캐릭터",
gender: "여성",
age: 24,
mbti: "INFJ",
speechStyle: "다정함",
speechPattern: "존댓말",
region: "KR",
tags: ["상담", "힐링"],
createdAt: "2026-07-28 10:00:00",
updatedAt: "2026-07-28 11:00:00",
},
{
id: 102,
name: "테오",
imageUrl: null,
description: "명랑한 안내형 AI 캐릭터",
gender: "남성",
age: 28,
mbti: "ENFP",
speechStyle: "경쾌함",
speechPattern: "반말",
region: "KR",
tags: ["안내", "친근함"],
createdAt: "2026-07-27 09:00:00",
updatedAt: "2026-07-28 09:30:00",
},
] as const;
export const mockCharacterDetails = [
{
id: 101,
characterUUID: "character-uuid-101",
name: "루나",
imageUrl: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64'%3E%3Crect width='64' height='64' rx='12' fill='%2300BDF7'/%3E%3Ctext x='32' y='39' text-anchor='middle' font-size='24' font-family='sans-serif' fill='%23062B36'%3EL%3C/text%3E%3C/svg%3E",
description: "차분한 상담형 AI 캐릭터",
systemPrompt: "친절하고 안전하게 답한다.",
characterType: "Character",
age: 24,
gender: "여성",
mbti: "INFJ",
speechPattern: "존댓말",
speechStyle: "다정함",
appearance: "푸른 머리와 밝은 눈",
region: "KR",
isActive: true,
tags: ["상담", "힐링"],
hobbies: [],
values: [],
goals: [],
relationships: [],
personalities: [],
backgrounds: [],
memories: [],
originalWork: { id: 7, imageUrl: null, title: "달빛 상담소" },
},
{
id: 102,
characterUUID: "character-uuid-102",
name: "테오",
imageUrl: null,
description: "명랑한 안내형 AI 캐릭터",
systemPrompt: "짧고 명확하게 안내한다.",
characterType: "Character",
age: 28,
gender: "남성",
mbti: "ENFP",
speechPattern: "반말",
speechStyle: "경쾌함",
appearance: null,
region: "KR",
isActive: true,
tags: ["안내", "친근함"],
hobbies: [],
values: [],
goals: [],
relationships: [],
personalities: [],
backgrounds: [],
memories: [],
originalWork: null,
},
{
id: 202,
characterUUID: "character-uuid-202",
name: "미카",
imageUrl: null,
description: "비활성 검증용 AI 캐릭터",
systemPrompt: "읽기 전용 상태를 검증한다.",
characterType: "Character",
age: null,
gender: null,
mbti: null,
speechPattern: null,
speechStyle: null,
appearance: null,
region: "KR",
isActive: false,
tags: [],
hobbies: [],
values: [],
goals: [],
relationships: [],
personalities: [],
backgrounds: [],
memories: [],
originalWork: null,
},
] as const;
export const mockOriginalWorks = [
{
id: 7,
title: "달빛 상담소",
contentType: "WEBTOON",
category: "힐링",
isAdult: false,
description: "차분한 상담 원작",
originalWork: "Moonlight Office",
originalLink: "https://example.com/moonlight",
writer: "하린",
studio: "소다스튜디오",
originalLinks: ["https://example.com/moonlight"],
tags: ["상담", "힐링"],
imageUrl: null,
},
{
id: 8,
title: "별빛 기록실",
contentType: "NOVEL",
category: "드라마",
isAdult: false,
description: "기록형 원작",
originalWork: null,
originalLink: null,
writer: "이든",
studio: "소다스튜디오",
originalLinks: [],
tags: ["기록"],
imageUrl: null,
},
] as const;

View File

@@ -0,0 +1,278 @@
import { z } from "zod";
import type { AudioContentCreateRequest, AudioContentDeactivateRequest, AudioContentUpdateRequest } from "@/features/audio-contents/schemas/audio-content-schema";
import type { CharacterDetail, CharacterListItem } from "@/features/characters/model/types";
import { AudioContentMockStore } from "@/shared/mocks/audio-content-mock-store";
import { mockActiveCharacters, mockCharacterDetails, mockOriginalWorks } from "@/shared/mocks/character-fixtures";
import { CommunityPostMockStore } from "@/shared/mocks/community-post-mock-store";
import type { CommunityPostCreateMutation, CommunityPostUpdateMutation } from "@/shared/mocks/community-post-mock-store";
import { FanTalkMockStore } from "@/shared/mocks/fan-talk-mock-store";
import { SeriesMockStore } from "@/shared/mocks/series-mock-store";
import type { SeriesCreateMutation, SeriesUpdateMutation } from "@/shared/mocks/series-mock-store";
export { parseAudioContentCreateRequest, parseAudioContentDeactivateRequest, parseAudioContentUpdateRequest } from "@/shared/mocks/audio-content-mock-store";
const characterMutationRequestSchema = z.object({
name: z.string().optional(),
systemPrompt: z.string().optional(),
description: z.string().optional(),
originalWorkId: z.number().int().nullable().optional(),
isActive: z.boolean().optional(),
});
export type CharacterMutationRequest = z.infer<typeof characterMutationRequestSchema>;
export class CharacterMockStore {
readonly #audioStore = new AudioContentMockStore((characterId) => this.getCharacter(characterId));
readonly #communityPostStore = new CommunityPostMockStore((characterId) => this.getCharacter(characterId));
readonly #fanTalkStore = new FanTalkMockStore((characterId) => this.getCharacter(characterId));
readonly #seriesStore = new SeriesMockStore((characterId) => this.getCharacter(characterId));
#characterDetails: CharacterDetail[] = mockCharacterDetails.map(toMutableDetail);
#characters: CharacterListItem[] = mockActiveCharacters.map(toMutableListItem);
#nextCharacterId = 1000;
getCharacter(characterId: string) {
return this.#characterDetails.find((character) => String(character.id) === characterId) ?? null;
}
listCharacters(searchTerm: string | null, page: number, size: number) {
const normalizedSearch = searchTerm?.trim().toLowerCase() ?? "";
const filteredCharacters = normalizedSearch.length === 0
? this.#characters
: this.#characters.filter((character) => `${character.name} ${character.description} ${character.tags.join(" ")}`.toLowerCase().includes(normalizedSearch));
const start = page * size;
return {
totalCount: filteredCharacters.length,
content: filteredCharacters.slice(start, start + size),
};
}
searchOriginalWorks(searchTerm: string) {
const normalizedSearch = searchTerm.trim().toLowerCase();
return mockOriginalWorks.filter((originalWork) => `${originalWork.title} ${originalWork.contentType} ${originalWork.category}`.toLowerCase().includes(normalizedSearch)).map(toMutableOriginalWork);
}
listAudioContents(characterId: string, searchWord: string | null, page: number, size: number) {
return this.#audioStore.listAudioContents(characterId, searchWord, page, size);
}
getAudioContent(characterId: string, contentId: string) {
return this.#audioStore.getAudioContent(characterId, contentId);
}
listSeries(characterId: string, page: number, size: number) {
return this.#seriesStore.listSeries(characterId, page, size);
}
listSeriesGenres() {
return this.#seriesStore.listSeriesGenres();
}
listCommunityPosts(characterId: string, page: number, size: number) {
return this.#communityPostStore.listCommunityPosts(characterId, page, size);
}
listFanTalks(characterId: string, page: number, size: number) {
return this.#fanTalkStore.listFanTalks(characterId, page, size);
}
createFanTalkReply(characterId: string, fanTalkId: string, content: string) {
return this.#fanTalkStore.createFanTalkReply(characterId, fanTalkId, content);
}
updateFanTalkReply(characterId: string, fanTalkId: string, replyId: string, content: string) {
return this.#fanTalkStore.updateFanTalkReply(characterId, fanTalkId, replyId, content);
}
deleteFanTalk(characterId: string, fanTalkId: string) {
return this.#fanTalkStore.deleteFanTalk(characterId, fanTalkId);
}
updateCommunityPost(characterId: string, postId: string, mutation: CommunityPostUpdateMutation): boolean {
return this.#communityPostStore.updateCommunityPost(characterId, postId, mutation);
}
createCommunityPost(characterId: string, mutation: CommunityPostCreateMutation): boolean {
return this.#communityPostStore.createCommunityPost(characterId, mutation);
}
getSeriesDetail(characterId: string, seriesId: string) {
return this.#seriesStore.getSeriesDetail(characterId, seriesId);
}
listSeriesContents(characterId: string, seriesId: string, page: number, size: number) {
return this.#seriesStore.listSeriesContents(characterId, seriesId, page, size);
}
searchUnlinkedSeriesContents(characterId: string, seriesId: string, searchWord: string) {
return this.#seriesStore.searchUnlinkedSeriesContents(characterId, seriesId, searchWord);
}
addSeriesContents(characterId: string, seriesId: string, contentIdList: readonly number[]): boolean {
return this.#seriesStore.addSeriesContents(characterId, seriesId, contentIdList);
}
createSeries(characterId: string, mutation: SeriesCreateMutation): boolean {
return this.#seriesStore.createSeries(characterId, mutation);
}
updateSeries(characterId: string, seriesId: string, mutation: SeriesUpdateMutation): boolean {
return this.#seriesStore.updateSeries(characterId, seriesId, mutation);
}
removeSeriesContent(characterId: string, seriesId: string, contentId: number): boolean {
return this.#seriesStore.removeSeriesContent(characterId, seriesId, contentId);
}
updateSeriesOrder(characterId: string, ids: readonly number[]): boolean {
return this.#seriesStore.updateSeriesOrder(characterId, ids);
}
listAudioContentThemes() {
return this.#audioStore.listAudioContentThemes();
}
createAudioContent(characterId: string, request: AudioContentCreateRequest): number | null {
return this.#audioStore.createAudioContent(characterId, request);
}
updateAudioContent(characterId: string, contentId: string, request: AudioContentUpdateRequest): boolean {
return this.#audioStore.updateAudioContent(characterId, contentId, request);
}
deactivateAudioContent(characterId: string, contentId: string, request: AudioContentDeactivateRequest): boolean {
return this.#audioStore.deactivateAudioContent(characterId, contentId, request);
}
createCharacter(request: CharacterMutationRequest) {
const characterId = this.#nextCharacterId;
this.#nextCharacterId += 1;
const detail = {
id: characterId,
characterUUID: `mock-character-${characterId}`,
name: request.name ?? "새 캐릭터",
imageUrl: null,
description: request.description ?? "",
systemPrompt: request.systemPrompt ?? "",
characterType: "Character",
age: null,
gender: null,
mbti: null,
speechPattern: null,
speechStyle: null,
appearance: null,
region: "KR",
isActive: true,
tags: [],
hobbies: [],
values: [],
goals: [],
relationships: [],
personalities: [],
backgrounds: [],
memories: [],
originalWork: toOriginalWorkPreview(request.originalWorkId),
} satisfies CharacterDetail;
this.#characterDetails = [...this.#characterDetails, detail];
this.#characters = [...this.#characters, toListItem(detail)];
}
updateCharacter(characterId: string, request: CharacterMutationRequest): boolean {
const character = this.getCharacter(characterId);
if (character === null) {
return false;
}
const nextCharacter = {
...character,
name: request.name ?? character.name,
description: request.description ?? character.description,
systemPrompt: request.systemPrompt ?? character.systemPrompt,
originalWork: toOriginalWorkPreview(request.originalWorkId),
isActive: request.isActive ?? character.isActive,
} satisfies CharacterDetail;
this.#characterDetails = this.#characterDetails.map((item) => (item.id === nextCharacter.id ? nextCharacter : item));
this.#characters = nextCharacter.isActive
? this.#characters.map((item) => (item.id === nextCharacter.id ? toListItem(nextCharacter) : item))
: this.#characters.filter((item) => item.id !== nextCharacter.id);
return true;
}
}
function toOriginalWorkPreview(originalWorkId: number | null | undefined): CharacterDetail["originalWork"] {
if (originalWorkId === undefined || originalWorkId === null) {
return null;
}
const originalWork = mockOriginalWorks.find((item) => item.id === originalWorkId);
return originalWork === undefined ? null : { id: originalWork.id, imageUrl: originalWork.imageUrl, title: originalWork.title };
}
function toMutableOriginalWork(originalWork: (typeof mockOriginalWorks)[number]) {
return {
...originalWork,
originalLinks: [...originalWork.originalLinks],
tags: [...originalWork.tags],
};
}
export async function parseCharacterMutationRequest(request: Request): Promise<CharacterMutationRequest | null> {
try {
const body = await request.text();
const match = /name="request"(?:; filename="[^"]*")?\r\nContent-Type: application\/json\r\n\r\n(?<json>.*?)\r\n--/s.exec(body);
const json = match?.groups?.json;
if (json === undefined) {
return null;
}
return characterMutationRequestSchema.parse(JSON.parse(json));
} catch (parseError) {
if (parseError instanceof SyntaxError || parseError instanceof z.ZodError) {
return null;
}
throw parseError;
}
}
function toMutableDetail(character: (typeof mockCharacterDetails)[number]): CharacterDetail {
return {
...character,
tags: [...character.tags],
hobbies: [...character.hobbies],
values: [...character.values],
goals: [...character.goals],
relationships: [...character.relationships],
personalities: [...character.personalities],
backgrounds: [...character.backgrounds],
memories: [...character.memories],
};
}
function toMutableListItem(character: (typeof mockActiveCharacters)[number]): CharacterListItem {
return {
...character,
tags: [...character.tags],
};
}
function toListItem(character: CharacterDetail): CharacterListItem {
return {
id: character.id,
name: character.name,
imageUrl: character.imageUrl,
description: character.description,
gender: character.gender,
age: character.age,
mbti: character.mbti,
speechStyle: character.speechStyle,
speechPattern: character.speechPattern,
region: character.region,
tags: character.tags,
createdAt: null,
updatedAt: null,
};
}

View File

@@ -0,0 +1,134 @@
import type { CommentRecord } from "@/features/comments/model/types";
import { previewCommunityPostImageUrl } from "@/shared/mocks/community-post-fixtures";
const lunaProfileUrl = "https://cdn.example.com/mock/luna.png";
export type MockComment = CommentRecord & {
readonly characterId: string;
readonly parentId: number | null;
readonly targetId: string;
readonly targetKind: "audio" | "community";
};
export const mockComments = [
{
id: 1101,
characterId: "101",
targetKind: "audio",
targetId: "9001",
parentId: null,
writerId: 301,
nickname: "팬",
profileUrl: previewCommunityPostImageUrl,
comment: "오디오 팬 루트 댓글",
isSecret: false,
date: "2026-07-29T00:30:00Z",
replyCount: 2,
languageCode: "ko",
donationCan: 5,
},
{
id: 1102,
characterId: "101",
targetKind: "audio",
targetId: "9001",
parentId: null,
writerId: 101,
nickname: "루나",
profileUrl: lunaProfileUrl,
comment: "오디오 AI 루트 댓글",
isSecret: false,
date: "2026-07-29T01:00:00Z",
replyCount: 0,
languageCode: null,
donationCan: 0,
},
{
id: 1201,
characterId: "101",
targetKind: "audio",
targetId: "9001",
parentId: 1101,
writerId: 301,
nickname: "팬",
profileUrl: previewCommunityPostImageUrl,
comment: "오디오 팬 답글",
isSecret: false,
date: "2026-07-29T01:10:00Z",
replyCount: 0,
languageCode: "ko",
donationCan: 1,
},
{
id: 1202,
characterId: "101",
targetKind: "audio",
targetId: "9001",
parentId: 1101,
writerId: 101,
nickname: "루나",
profileUrl: lunaProfileUrl,
comment: "오디오 AI 답글",
isSecret: false,
date: "2026-07-29T01:20:00Z",
replyCount: 0,
languageCode: null,
donationCan: 0,
},
{
id: 2101,
characterId: "101",
targetKind: "community",
targetId: "7001",
parentId: null,
writerId: 301,
nickname: "팬",
profileUrl: previewCommunityPostImageUrl,
comment: "커뮤니티 팬 루트 댓글",
isSecret: false,
date: "2026-07-29T02:00:00Z",
replyCount: 2,
},
{
id: 2102,
characterId: "101",
targetKind: "community",
targetId: "7001",
parentId: null,
writerId: 101,
nickname: "루나",
profileUrl: lunaProfileUrl,
comment: "커뮤니티 AI 루트 댓글",
isSecret: false,
date: "2026-07-29T02:10:00Z",
replyCount: 0,
},
{
id: 2201,
characterId: "101",
targetKind: "community",
targetId: "7001",
parentId: 2101,
writerId: 301,
nickname: "팬",
profileUrl: previewCommunityPostImageUrl,
comment: "커뮤니티 팬 답글",
isSecret: false,
date: "2026-07-29T02:20:00Z",
replyCount: 0,
},
{
id: 2202,
characterId: "101",
targetKind: "community",
targetId: "7001",
parentId: 2101,
writerId: 101,
nickname: "루나",
profileUrl: lunaProfileUrl,
comment: "커뮤니티 AI 답글",
isSecret: false,
date: "2026-07-29T02:30:00Z",
replyCount: 0,
},
] as const satisfies readonly MockComment[];

View File

@@ -0,0 +1,188 @@
import { http, HttpResponse } from "msw";
import type { RequestHandler } from "msw";
import type { AudioCommentCreateRequest, CommentUpdateRequest, CommunityCommentCreateRequest } from "@/features/comments/model/types";
import type { CommentCreateMutation, CommentTargetRef } from "@/shared/mocks/comment-mock-store";
import { error, ok } from "@/shared/mocks/contract";
type AccessResponse = (request: Request) => Response | null;
type CommentStore = {
readonly createComment: (target: CommentTargetRef, request: CommentCreateMutation) => boolean;
readonly deleteComment: (target: CommentTargetRef, commentId: number) => boolean;
readonly listReplies: (target: CommentTargetRef, commentId: number, page: number, size: number) => unknown | null;
readonly listRootComments: (target: CommentTargetRef, page: number, size: number) => unknown | null;
readonly updateComment: (target: CommentTargetRef, commentId: number, request: CommentUpdateRequest) => boolean;
};
type CommentMockHandlerOptions = {
readonly apiBaseUrl: string;
readonly invalidRequestMessage: string;
readonly parseAudioCreateRequest: (request: Request) => Promise<AudioCommentCreateRequest | null>;
readonly parseCommunityCreateRequest: (request: Request) => Promise<CommunityCommentCreateRequest | null>;
readonly parseUpdateRequest: (request: Request) => Promise<CommentUpdateRequest | null>;
};
function endpointUrl(apiBaseUrl: string, path: string): string {
return new URL(path, apiBaseUrl).toString();
}
function parseListQuery(request: Request): { readonly page: number; readonly size: number } | null {
const url = new URL(request.url);
const page = Number(url.searchParams.get("page") ?? "0");
const size = Number(url.searchParams.get("size") ?? "20");
const queryKeys = [...url.searchParams.keys()];
if (!queryKeys.every((key) => key === "page" || key === "size") || !Number.isInteger(page) || page < 0 || !Number.isInteger(size) || size < 1) {
return null;
}
return { page, size };
}
function parseCommentId(value: string | readonly string[] | undefined): number | null {
if (typeof value !== "string") {
return null;
}
const commentId = Number(value);
return Number.isInteger(commentId) ? commentId : null;
}
function audioTarget(params: { readonly characterId?: string | readonly string[]; readonly contentId?: string | readonly string[] }): CommentTargetRef | null {
if (typeof params.characterId !== "string" || typeof params.contentId !== "string") {
return null;
}
return { kind: "audio", characterId: params.characterId, contentId: params.contentId };
}
function communityTarget(params: { readonly characterId?: string | readonly string[]; readonly postId?: string | readonly string[] }): CommentTargetRef | null {
if (typeof params.characterId !== "string" || typeof params.postId !== "string") {
return null;
}
return { kind: "community", characterId: params.characterId, postId: params.postId };
}
function listResponse(store: CommentStore, target: CommentTargetRef | null, request: Request, commentId?: number): Response {
const query = parseListQuery(request);
if (target === null || query === null) {
return HttpResponse.json(error("잘못된 요청입니다."), { status: 400 });
}
const comments = commentId === undefined
? store.listRootComments(target, query.page, query.size)
: store.listReplies(target, commentId, query.page, query.size);
return comments === null
? HttpResponse.json(error("AI 캐릭터를 찾을 수 없습니다."), { status: 404 })
: HttpResponse.json(ok(comments));
}
export function createCommentMockHandlers(store: CommentStore, accessResponse: AccessResponse, options: CommentMockHandlerOptions): readonly RequestHandler[] {
return [
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/audio-contents/:contentId/comments"), ({ params, request }) => {
const deniedResponse = accessResponse(request);
return deniedResponse ?? listResponse(store, audioTarget(params), request);
}),
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/audio-contents/:contentId/comments/:commentId/replies"), ({ params, request }) => {
const deniedResponse = accessResponse(request);
const commentId = parseCommentId(params.commentId);
if (deniedResponse !== null) {
return deniedResponse;
}
return commentId === null ? HttpResponse.json(error(options.invalidRequestMessage), { status: 400 }) : listResponse(store, audioTarget(params), request, commentId);
}),
http.post(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/audio-contents/:contentId/comments"), async ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const target = audioTarget(params);
const mutation = await options.parseAudioCreateRequest(request);
if (target === null || mutation === null || !store.createComment(target, mutation)) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
return HttpResponse.json(ok(null));
}),
http.put(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/audio-contents/:contentId/comments/:commentId"), async ({ params, request }) => {
const deniedResponse = accessResponse(request);
const commentId = parseCommentId(params.commentId);
if (deniedResponse !== null) {
return deniedResponse;
}
const target = audioTarget(params);
const mutation = await options.parseUpdateRequest(request);
if (target === null || commentId === null || mutation === null || !store.updateComment(target, commentId, mutation)) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
return HttpResponse.json(ok(null));
}),
http.delete(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/audio-contents/:contentId/comments/:commentId"), ({ params, request }) => {
const deniedResponse = accessResponse(request);
const commentId = parseCommentId(params.commentId);
const target = audioTarget(params);
if (deniedResponse !== null) {
return deniedResponse;
}
if (target === null || commentId === null || !store.deleteComment(target, commentId)) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
return HttpResponse.json(ok(null));
}),
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/community-posts/:postId/comments"), ({ params, request }) => {
const deniedResponse = accessResponse(request);
return deniedResponse ?? listResponse(store, communityTarget(params), request);
}),
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/community-posts/:postId/comments/:commentId/replies"), ({ params, request }) => {
const deniedResponse = accessResponse(request);
const commentId = parseCommentId(params.commentId);
if (deniedResponse !== null) {
return deniedResponse;
}
return commentId === null ? HttpResponse.json(error(options.invalidRequestMessage), { status: 400 }) : listResponse(store, communityTarget(params), request, commentId);
}),
http.post(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/community-posts/:postId/comments"), async ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const target = communityTarget(params);
const mutation = await options.parseCommunityCreateRequest(request);
if (target === null || mutation === null || !store.createComment(target, mutation)) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
return HttpResponse.json(ok(null));
}),
http.put(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/community-posts/:postId/comments/:commentId"), async ({ params, request }) => {
const deniedResponse = accessResponse(request);
const commentId = parseCommentId(params.commentId);
if (deniedResponse !== null) {
return deniedResponse;
}
const target = communityTarget(params);
const mutation = await options.parseUpdateRequest(request);
if (target === null || commentId === null || mutation === null || !store.updateComment(target, commentId, mutation)) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
return HttpResponse.json(ok(null));
}),
http.delete(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/community-posts/:postId/comments/:commentId"), ({ params, request }) => {
const deniedResponse = accessResponse(request);
const commentId = parseCommentId(params.commentId);
const target = communityTarget(params);
if (deniedResponse !== null) {
return deniedResponse;
}
if (target === null || commentId === null || !store.deleteComment(target, commentId)) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
return HttpResponse.json(ok(null));
}),
];
}

View File

@@ -0,0 +1,170 @@
import { z } from "zod";
import { audioCommentCreateRequestSchema, commentUpdateRequestSchema, communityCommentCreateRequestSchema } from "@/features/comments/model/types";
import type { AudioCommentCreateRequest, CommentPage, CommentRecord, CommentUpdateRequest, CommunityCommentCreateRequest } from "@/features/comments/model/types";
import type { CharacterDetail } from "@/features/characters/model/types";
import { mockComments } from "@/shared/mocks/comment-fixtures";
import type { MockComment } from "@/shared/mocks/comment-fixtures";
type GetCharacter = (characterId: string) => CharacterDetail | null;
export type CommentTargetRef =
| { readonly characterId: string; readonly contentId: string; readonly kind: "audio" }
| { readonly characterId: string; readonly kind: "community"; readonly postId: string };
export type CommentCreateMutation = AudioCommentCreateRequest | CommunityCommentCreateRequest;
export class CommentMockStore {
#comments: MockComment[] = mockComments.map(toMutableComment);
#nextCommentId = 3000;
readonly #getCharacter: GetCharacter;
constructor(getCharacter: GetCharacter) {
this.#getCharacter = getCharacter;
}
listRootComments(target: CommentTargetRef, page: number, size: number): CommentPage | null {
return this.#listComments(target, null, page, size);
}
listReplies(target: CommentTargetRef, commentId: number, page: number, size: number): CommentPage | null {
return this.#listComments(target, commentId, page, size);
}
createComment(target: CommentTargetRef, request: CommentCreateMutation): boolean {
const character = this.#getCharacter(target.characterId);
const parentId = request.parentId ?? null;
if (character === null || (parentId !== null && !this.#isRootParent(this.#findComment(parentId), target))) {
return false;
}
this.#nextCommentId += 1;
const baseComment = {
id: this.#nextCommentId,
characterId: target.characterId,
targetKind: target.kind,
targetId: getTargetId(target),
parentId,
writerId: character.id,
nickname: character.name,
profileUrl: character.imageUrl ?? "https://cdn.example.com/mock/luna.png",
comment: request.comment,
isSecret: request.isSecret,
date: "2026-07-29T03:00:00Z",
replyCount: 0,
} satisfies MockComment;
const comment = target.kind === "audio"
? { ...baseComment, languageCode: "languageCode" in request ? request.languageCode ?? null : null, donationCan: 0 } satisfies MockComment
: baseComment;
this.#comments = [comment, ...this.#comments];
this.#refreshReplyCounts();
return true;
}
updateComment(target: CommentTargetRef, commentId: number, request: CommentUpdateRequest): boolean {
const character = this.#getCharacter(target.characterId);
if (character === null || !this.#isCreatorOwned(this.#findComment(commentId), target, character.id)) {
return false;
}
this.#comments = this.#comments.map((comment) => this.#matchesTarget(comment, target) && comment.id === commentId ? { ...comment, comment: request.comment } : comment);
return true;
}
deleteComment(target: CommentTargetRef, commentId: number): boolean {
if (!this.#matchesTarget(this.#findComment(commentId), target)) {
return false;
}
this.#comments = this.#comments.filter((comment) => !this.#matchesTarget(comment, target) || comment.id !== commentId);
this.#refreshReplyCounts();
return true;
}
#listComments(target: CommentTargetRef, parentId: number | null, page: number, size: number): CommentPage | null {
if (this.#getCharacter(target.characterId) === null) {
return null;
}
const filtered = this.#comments.filter((comment) => this.#matchesTarget(comment, target) && comment.parentId === parentId);
const start = page * size;
return { totalCount: filtered.length, items: filtered.slice(start, start + size).map(toCommentRecord) };
}
#findComment(commentId: number): MockComment | undefined {
return this.#comments.find((comment) => comment.id === commentId);
}
#matchesTarget(comment: MockComment | undefined, target: CommentTargetRef): boolean {
return comment !== undefined && comment.characterId === target.characterId && comment.targetKind === target.kind && comment.targetId === getTargetId(target);
}
#isRootParent(comment: MockComment | undefined, target: CommentTargetRef): boolean {
return comment !== undefined && this.#matchesTarget(comment, target) && comment.parentId === null;
}
#isCreatorOwned(comment: MockComment | undefined, target: CommentTargetRef, creatorId: number): boolean {
return comment !== undefined && this.#matchesTarget(comment, target) && comment.writerId === creatorId;
}
#refreshReplyCounts(): void {
this.#comments = this.#comments.map((comment) => comment.parentId === null
? { ...comment, replyCount: this.#comments.filter((reply) => reply.parentId === comment.id).length }
: comment);
}
}
export async function parseAudioCommentCreateRequest(request: Request): Promise<AudioCommentCreateRequest | null> {
return parseJsonRequest(request, audioCommentCreateRequestSchema);
}
export async function parseCommunityCommentCreateRequest(request: Request): Promise<CommunityCommentCreateRequest | null> {
return parseJsonRequest(request, communityCommentCreateRequestSchema);
}
export async function parseCommentUpdateRequest(request: Request): Promise<CommentUpdateRequest | null> {
return parseJsonRequest(request, commentUpdateRequestSchema);
}
async function parseJsonRequest<Data>(request: Request, schema: z.ZodType<Data>): Promise<Data | null> {
try {
return schema.parse(await request.json());
} catch (parseError) {
if (parseError instanceof SyntaxError || parseError instanceof z.ZodError) {
return null;
}
throw parseError;
}
}
function getTargetId(target: CommentTargetRef): string {
switch (target.kind) {
case "audio":
return target.contentId;
case "community":
return target.postId;
}
}
function toCommentRecord(comment: MockComment): CommentRecord {
const record = {
id: comment.id,
writerId: comment.writerId,
nickname: comment.nickname,
profileUrl: comment.profileUrl,
comment: comment.comment,
isSecret: comment.isSecret,
date: comment.date,
replyCount: comment.replyCount,
languageCode: comment.languageCode,
donationCan: comment.donationCan,
} satisfies CommentRecord;
return record;
}
function toMutableComment(comment: (typeof mockComments)[number]): MockComment {
return { ...comment };
}

View File

@@ -0,0 +1,77 @@
import type { CommunityPostListItem } from "@/features/community-posts/model/types";
import { previewAudioUrl } from "@/shared/mocks/audio-content-fixtures";
export const previewCommunityPostImageUrl = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='128' height='128' viewBox='0 0 128 128'%3E%3Crect width='128' height='128' rx='20' fill='%23D9F6FF'/%3E%3Cpath d='M34 40h60v10H34zM34 62h42v10H34zM34 84h54v10H34z' fill='%23062B36'/%3E%3C/svg%3E";
export const previewCommunityPostAudioUrl = previewAudioUrl;
export const mockCommunityPostListItems = [
{
postId: 7001,
creatorId: 101,
creatorNickname: "루나",
creatorProfileUrl: previewCommunityPostImageUrl,
imageUrl: null,
audioUrl: null,
content: "오늘의 상담 기록입니다.",
price: 0,
date: "2026-07-28 10:00:00",
dateUtc: "2026-07-28T01:00:00Z",
isCommentAvailable: true,
isAdult: false,
isFixed: true,
isLike: false,
existOrdered: false,
likeCount: 3,
commentCount: 1,
firstComment: {
id: 8001,
writerId: 301,
nickname: "팬",
profileUrl: previewCommunityPostImageUrl,
comment: "좋아요",
isSecret: false,
date: "2026-07-28 11:00:00",
replyCount: 0,
},
},
{
postId: 7002,
creatorId: 101,
creatorNickname: "루나",
creatorProfileUrl: previewCommunityPostImageUrl,
imageUrl: previewCommunityPostImageUrl,
audioUrl: null,
content: "이미지로 남긴 공지입니다.",
price: 0,
date: "2026-07-27 09:00:00",
dateUtc: "2026-07-27T00:00:00Z",
isCommentAvailable: false,
isAdult: false,
isFixed: false,
isLike: false,
existOrdered: false,
likeCount: 1,
commentCount: 0,
firstComment: null,
},
{
postId: 7003,
creatorId: 101,
creatorNickname: "루나",
creatorProfileUrl: previewCommunityPostImageUrl,
imageUrl: null,
audioUrl: previewCommunityPostAudioUrl,
content: "오디오가 포함된 커뮤니티 게시글입니다.",
price: 100,
date: "2026-07-26 08:00:00",
dateUtc: "2026-07-25T23:00:00Z",
isCommentAvailable: true,
isAdult: true,
isFixed: false,
isLike: false,
existOrdered: false,
likeCount: 5,
commentCount: 0,
firstComment: null,
},
] as const satisfies readonly CommunityPostListItem[];

View File

@@ -0,0 +1,91 @@
import { http, HttpResponse } from "msw";
import type { RequestHandler } from "msw";
import type { CommunityPostCreateMutation, CommunityPostUpdateMutation } from "@/shared/mocks/community-post-mock-store";
import { error, ok } from "@/shared/mocks/contract";
type AccessResponse = (request: Request) => Response | null;
type CommunityPostStore = {
readonly createCommunityPost: (characterId: string, mutation: CommunityPostCreateMutation) => boolean;
readonly listCommunityPosts: (characterId: string, page: number, size: number) => unknown | null;
readonly updateCommunityPost: (characterId: string, postId: string, mutation: CommunityPostUpdateMutation) => boolean;
};
type CommunityPostMockHandlerOptions = {
readonly apiBaseUrl: string;
readonly invalidRequestMessage: string;
readonly parseCreateRequest: (request: Request) => Promise<CommunityPostCreateMutation | null>;
readonly parseUpdateRequest: (request: Request) => Promise<CommunityPostUpdateMutation | null>;
};
function endpointUrl(apiBaseUrl: string, path: string): string {
return new URL(path, apiBaseUrl).toString();
}
function parseListQuery(request: Request): { readonly page: number; readonly size: number } | null {
const url = new URL(request.url);
const page = Number(url.searchParams.get("page") ?? "0");
const size = Number(url.searchParams.get("size") ?? "20");
const queryKeys = [...url.searchParams.keys()];
if (!queryKeys.every((key) => key === "page" || key === "size") || !Number.isInteger(page) || page < 0 || !Number.isInteger(size) || size < 1) {
return null;
}
return { page, size };
}
export function createCommunityPostMockHandlers(store: CommunityPostStore, accessResponse: AccessResponse, options: CommunityPostMockHandlerOptions): readonly RequestHandler[] {
return [
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/community-posts"), ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const query = parseListQuery(request);
if (query === null) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const posts = store.listCommunityPosts(characterId, query.page, query.size);
if (posts === null) {
return HttpResponse.json(error("AI 캐릭터를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(posts));
}),
http.put(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/community-posts/:postId"), async ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const mutation = await options.parseUpdateRequest(request);
if (mutation === null) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const postId = typeof params.postId === "string" ? params.postId : "";
if (!store.updateCommunityPost(characterId, postId, mutation)) {
return HttpResponse.json(error("커뮤니티 게시글을 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(null));
}),
http.post(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/community-posts"), async ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const mutation = await options.parseCreateRequest(request);
if (mutation === null) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
if (!store.createCommunityPost(characterId, mutation)) {
return HttpResponse.json(error("AI 캐릭터를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(null));
}),
];
}

View File

@@ -0,0 +1,256 @@
import { z } from "zod";
import type { CharacterDetail } from "@/features/characters/model/types";
import { communityPostCreateRequestSchema, communityPostUpdateRequestSchema } from "@/features/community-posts/model/types";
import type { CommunityPostCreateRequest, CommunityPostListItem, CommunityPostUpdateRequest } from "@/features/community-posts/model/types";
import { mockCommunityPostListItems, previewCommunityPostAudioUrl, previewCommunityPostImageUrl } from "@/shared/mocks/community-post-fixtures";
type GetCharacter = (characterId: string) => CharacterDetail | null;
export type CommunityPostCreateMutation = {
readonly hasAudio: boolean;
readonly hasImage: boolean;
readonly request: CommunityPostCreateRequest;
};
export type CommunityPostUpdateMutation = {
readonly hasImage: boolean;
readonly request: CommunityPostUpdateRequest;
};
export class CommunityPostMockStore {
#posts: CommunityPostListItem[] = mockCommunityPostListItems.map(toMutableCommunityPost);
readonly #getCharacter: GetCharacter;
constructor(getCharacter: GetCharacter) {
this.#getCharacter = getCharacter;
}
listCommunityPosts(characterId: string, page: number, size: number) {
const character = this.#getCharacter(characterId);
if (character === null) {
return null;
}
const start = page * size;
const posts = this.#posts.filter((post) => post.creatorId === character.id);
return { totalCount: posts.length, page, size, hasNext: start + size < posts.length, items: posts.slice(start, start + size) };
}
updateCommunityPost(characterId: string, postId: string, mutation: CommunityPostUpdateMutation): boolean {
const character = this.#getCharacter(characterId);
if (character === null || !this.#posts.some((post) => String(post.postId) === postId && post.creatorId === character.id)) {
return false;
}
const request = mutation.request;
if (request.isActive === false) {
this.#posts = this.#posts.filter((post) => String(post.postId) !== postId || post.creatorId !== character.id);
return true;
}
this.#posts = this.#posts.map((post) => String(post.postId) === postId && post.creatorId === character.id ? {
...post,
content: request.content ?? post.content,
imageUrl: mutation.hasImage ? previewCommunityPostImageUrl : post.imageUrl,
isAdult: request.isAdult ?? post.isAdult,
isCommentAvailable: request.isCommentAvailable ?? post.isCommentAvailable,
isFixed: request.isFixed ?? post.isFixed,
} : post);
return true;
}
createCommunityPost(characterId: string, mutation: CommunityPostCreateMutation): boolean {
const character = this.#getCharacter(characterId);
if (character === null) {
return false;
}
const nextPostId = Math.max(0, ...this.#posts.map((post) => post.postId)) + 1;
this.#posts = [
{
postId: nextPostId,
creatorId: character.id,
creatorNickname: character.name,
creatorProfileUrl: character.imageUrl ?? previewCommunityPostImageUrl,
imageUrl: mutation.hasImage ? previewCommunityPostImageUrl : null,
audioUrl: mutation.hasAudio ? previewCommunityPostAudioUrl : null,
content: mutation.request.content,
price: mutation.request.price ?? 0,
date: "2026-07-28 12:00:00",
dateUtc: "2026-07-28T03:00:00Z",
isCommentAvailable: mutation.request.isCommentAvailable,
isAdult: mutation.request.isAdult,
isFixed: false,
isLike: false,
existOrdered: false,
likeCount: 0,
commentCount: 0,
firstComment: null,
},
...this.#posts,
];
return true;
}
}
export async function parseCommunityPostCreateRequest(request: Request): Promise<CommunityPostCreateMutation | null> {
try {
const body = await parseMultipart(request, ["audioFile", "postImage"]);
if (body === null || body.requestParts.length !== 1 || body.audioFileCount > 1 || body.postImageCount > 1) {
return null;
}
const requestPart = body.requestParts[0];
if (requestPart === undefined) {
return null;
}
const mutation = communityPostCreateRequestSchema.parse(JSON.parse(await readTextPart(requestPart)));
return { hasAudio: body.audioFileCount === 1, hasImage: body.postImageCount === 1, request: mutation };
} catch (parseError) {
if (parseError instanceof Error || parseError instanceof z.ZodError) {
return null;
}
throw parseError;
}
}
export async function parseCommunityPostUpdateRequest(request: Request): Promise<CommunityPostUpdateMutation | null> {
try {
const body = await parseMultipart(request, ["postImage"]);
if (body === null || body.requestParts.length !== 1 || body.postImageCount > 1) {
return null;
}
const requestPart = body.requestParts[0];
if (requestPart === undefined) {
return null;
}
const mutation = communityPostUpdateRequestSchema.parse(JSON.parse(await readTextPart(requestPart)));
return { hasImage: body.postImageCount === 1, request: mutation };
} catch (parseError) {
if (parseError instanceof Error || parseError instanceof z.ZodError) {
return null;
}
throw parseError;
}
}
type MultipartParts = {
readonly audioFileCount: number;
readonly postImageCount: number;
readonly requestParts: readonly MultipartRequestPart[];
};
type MultipartRequestPart = Blob | string;
async function parseMultipart(request: Request, allowedUploads: readonly string[]): Promise<MultipartParts | null> {
const contentType = request.headers.get("content-type");
const boundary = extractMultipartBoundary(contentType);
if (boundary === null) {
return null;
}
const clonedRequest = request.clone();
const formData = await request.formData().catch(() => null);
if (formData === null) {
return parseRawMultipart(await clonedRequest.text(), allowedUploads, boundary);
}
let audioFileCount = 0;
let postImageCount = 0;
const requestParts: MultipartRequestPart[] = [];
for (const [name, value] of formData.entries()) {
if (name !== "request" && !allowedUploads.includes(name)) {
return null;
}
switch (name) {
case "audioFile":
if (!(value instanceof File)) {
return null;
}
audioFileCount += 1;
break;
case "postImage":
if (!(value instanceof File)) {
return null;
}
postImageCount += 1;
break;
case "request":
requestParts.push(value);
break;
default:
return null;
}
}
return { audioFileCount, postImageCount, requestParts };
}
function extractMultipartBoundary(contentType: string | null): string | null {
if (!contentType?.toLowerCase().startsWith("multipart/form-data")) {
return null;
}
const boundary = contentType.match(/boundary=("[^"]+"|[^;]+)/)?.[1]?.replace(/^"|"$/g, "").trim();
return boundary === undefined || boundary.length === 0 ? null : boundary;
}
function parseRawMultipart(body: string, allowedUploads: readonly string[], boundary: string): MultipartParts | null {
if (!body.startsWith(`--${boundary}\r\n`) || (!body.endsWith(`--${boundary}--\r\n`) && !body.endsWith(`--${boundary}--`))) {
return null;
}
const boundaryLines = body.match(/^--[^\r\n]+$/gm) ?? [];
if (boundaryLines.length === 0 || boundaryLines.some((line) => line !== `--${boundary}` && line !== `--${boundary}--`)) {
return null;
}
const parts = [...body.matchAll(/Content-Disposition: form-data; name="([^"]+)"(; filename="[^"]*")?\r\n(?:Content-Type: [^\r\n]+\r\n)?\r\n([\s\S]*?)(?=\r\n--)/g)];
let audioFileCount = 0;
let postImageCount = 0;
const requestParts: string[] = [];
for (const part of parts) {
const name = part[1];
const filename = part[2];
const value = part[3];
if (name === undefined || value === undefined) {
return null;
}
if (name !== "request" && !allowedUploads.includes(name)) {
return null;
}
switch (name) {
case "audioFile":
if (filename === undefined) {
return null;
}
audioFileCount += 1;
break;
case "postImage":
if (filename === undefined) {
return null;
}
postImageCount += 1;
break;
case "request":
requestParts.push(value);
break;
default:
return null;
}
}
return { audioFileCount, postImageCount, requestParts };
}
function readTextPart(part: MultipartRequestPart): Promise<string> | string {
return typeof part === "string" ? part : part.text();
}
function toMutableCommunityPost(post: (typeof mockCommunityPostListItems)[number]): CommunityPostListItem {
return {
...post,
firstComment: post.firstComment === null ? null : { ...post.firstComment },
};
}

View File

@@ -0,0 +1,31 @@
import type { FanTalkListItem } from "@/features/fan-talks/model/types";
export const mockFanTalkListItems = [
{
fanTalkId: 7001,
writerId: 4001,
writerNickname: "달팬",
writerProfileImageUrl: "https://cdn.example.com/fans/moon.png",
content: "첫 번째 응원입니다.",
createdAtUtc: "2026-07-28T01:00:00Z",
creatorReplies: [],
},
{
fanTalkId: 7002,
writerId: 4002,
writerNickname: "별팬",
writerProfileImageUrl: "https://cdn.example.com/fans/star.png",
content: "두 번째로 온 응원입니다.",
createdAtUtc: "2026-07-28T02:00:00Z",
creatorReplies: [
{
fanTalkId: 7102,
writerId: 101,
writerNickname: "루나",
writerProfileImageUrl: "https://cdn.example.com/characters/luna.png",
content: "이미 답변했습니다.",
createdAtUtc: "2026-07-28T03:00:00Z",
},
],
},
] as const satisfies readonly FanTalkListItem[];

View File

@@ -0,0 +1,127 @@
import { http, HttpResponse } from "msw";
import type { RequestHandler } from "msw";
import { z } from "zod";
import { error, ok } from "@/shared/mocks/contract";
type AccessResponse = (request: Request) => Response | null;
type FanTalkStore = {
readonly createFanTalkReply: (characterId: string, fanTalkId: string, content: string) => unknown | null;
readonly deleteFanTalk: (characterId: string, fanTalkId: string) => boolean;
readonly listFanTalks: (characterId: string, page: number, size: number) => unknown | null;
readonly updateFanTalkReply: (characterId: string, fanTalkId: string, replyId: string, content: string) => unknown | null;
};
type FanTalkMockHandlerOptions = {
readonly apiBaseUrl: string;
readonly invalidRequestMessage: string;
};
const fanTalkReplyCreateRequestSchema = z.strictObject({ content: z.string() });
function endpointUrl(apiBaseUrl: string, path: string): string {
return new URL(path, apiBaseUrl).toString();
}
function parseListQuery(request: Request): { readonly page: number; readonly size: number } | null {
const url = new URL(request.url);
const page = Number(url.searchParams.get("page") ?? "0");
const size = Number(url.searchParams.get("size") ?? "20");
const queryKeys = [...url.searchParams.keys()];
if (!queryKeys.every((key) => key === "page" || key === "size") || !Number.isInteger(page) || page < 0 || !Number.isInteger(size) || size < 1) {
return null;
}
return { page, size };
}
async function parseReplyRequest(request: Request): Promise<string | null> {
if (request.headers.get("Content-Type")?.toLowerCase().split(";")[0]?.trim() !== "application/json") {
return null;
}
try {
return fanTalkReplyCreateRequestSchema.parse(await request.json()).content;
} catch (parseError) {
if (parseError instanceof SyntaxError || parseError instanceof z.ZodError) {
return null;
}
throw parseError;
}
}
export function createFanTalkMockHandlers(store: FanTalkStore, accessResponse: AccessResponse, options: FanTalkMockHandlerOptions): readonly RequestHandler[] {
return [
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/fan-talks"), ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const query = parseListQuery(request);
if (query === null) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const fanTalks = store.listFanTalks(characterId, query.page, query.size);
if (fanTalks === null) {
return HttpResponse.json(error("AI 캐릭터를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(fanTalks));
}),
http.post(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/fan-talks/:fanTalkId/replies"), async ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const content = await parseReplyRequest(request);
if (content === null) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const fanTalkId = typeof params.fanTalkId === "string" ? params.fanTalkId : "";
const reply = store.createFanTalkReply(characterId, fanTalkId, content);
if (reply === null) {
return HttpResponse.json(error("FanTalk 답변을 생성할 수 없습니다."), { status: 409 });
}
return HttpResponse.json(ok(reply));
}),
http.put(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/fan-talks/:fanTalkId/replies/:replyId"), async ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const content = await parseReplyRequest(request);
if (content === null) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const fanTalkId = typeof params.fanTalkId === "string" ? params.fanTalkId : "";
const replyId = typeof params.replyId === "string" ? params.replyId : "";
const reply = store.updateFanTalkReply(characterId, fanTalkId, replyId, content);
if (reply === null) {
return HttpResponse.json(error("FanTalk 답변을 수정할 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(reply));
}),
http.delete(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/fan-talks/:fanTalkId"), async ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
if ((await request.text()) !== "") {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const fanTalkId = typeof params.fanTalkId === "string" ? params.fanTalkId : "";
if (!store.deleteFanTalk(characterId, fanTalkId)) {
return HttpResponse.json(error("FanTalk을 삭제할 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(null));
}),
];
}

View File

@@ -0,0 +1,95 @@
import type { CharacterDetail } from "@/features/characters/model/types";
import type { FanTalkCreatorReply, FanTalkListItem } from "@/features/fan-talks/model/types";
import type { FanTalkReplyResponse } from "@/features/fan-talks/schemas/fan-talk-reply-schema";
import { mockFanTalkListItems } from "@/shared/mocks/fan-talk-fixtures";
type GetCharacter = (characterId: string) => CharacterDetail | null;
export class FanTalkMockStore {
#fanTalks: FanTalkListItem[] = mockFanTalkListItems.map(toMutableFanTalk);
#nextReplyId = 9001;
readonly #getCharacter: GetCharacter;
constructor(getCharacter: GetCharacter) {
this.#getCharacter = getCharacter;
}
listFanTalks(characterId: string, page: number, size: number) {
if (this.#getCharacter(characterId) === null) {
return null;
}
const start = page * size;
return {
fanTalkCount: this.#fanTalks.length,
fanTalks: this.#fanTalks.slice(start, start + size),
page,
size,
hasNext: start + size < this.#fanTalks.length,
};
}
createFanTalkReply(characterId: string, fanTalkId: string, content: string): FanTalkReplyResponse | null {
const character = this.#getCharacter(characterId);
const fanTalk = this.#fanTalks.find((item) => String(item.fanTalkId) === fanTalkId);
if (character === null || fanTalk === undefined || fanTalk.creatorReplies.length > 0) {
return null;
}
const reply = {
fanTalkId: fanTalk.fanTalkId,
replyId: this.#nextReplyId,
creatorMemberId: character.id,
content,
createdAtUtc: "2026-07-28T04:00:00Z",
} satisfies FanTalkReplyResponse;
this.#nextReplyId += 1;
this.#fanTalks = this.#fanTalks.map((item) => item.fanTalkId === fanTalk.fanTalkId ? {
...item,
creatorReplies: [{ fanTalkId: reply.replyId, writerId: character.id, writerNickname: character.name, writerProfileImageUrl: character.imageUrl ?? "", content: reply.content, createdAtUtc: reply.createdAtUtc }],
} : item);
return reply;
}
updateFanTalkReply(characterId: string, fanTalkId: string, replyId: string, content: string): FanTalkListItem | null {
const character = this.#getCharacter(characterId);
const fanTalk = this.#fanTalks.find((item) => String(item.fanTalkId) === fanTalkId);
const reply = fanTalk?.creatorReplies[0];
if (character === null || fanTalk === undefined || reply === undefined || String(reply.fanTalkId) !== replyId) {
return null;
}
const updatedReply: FanTalkCreatorReply = { ...reply, content, createdAtUtc: "2026-07-28T05:00:00Z" };
this.#fanTalks = this.#fanTalks.map((item) => item.fanTalkId === fanTalk.fanTalkId ? {
...item,
creatorReplies: [updatedReply],
} : item);
return {
fanTalkId: updatedReply.fanTalkId,
writerId: updatedReply.writerId,
writerNickname: updatedReply.writerNickname,
writerProfileImageUrl: updatedReply.writerProfileImageUrl,
content,
createdAtUtc: updatedReply.createdAtUtc,
creatorReplies: [],
} satisfies FanTalkListItem;
}
deleteFanTalk(characterId: string, fanTalkId: string): boolean {
if (this.#getCharacter(characterId) === null) {
return false;
}
const beforeCount = this.#fanTalks.length;
this.#fanTalks = this.#fanTalks.filter((item) => String(item.fanTalkId) !== fanTalkId);
return this.#fanTalks.length < beforeCount;
}
}
function toMutableFanTalk(fanTalk: (typeof mockFanTalkListItems)[number]): FanTalkListItem {
return {
...fanTalk,
creatorReplies: fanTalk.creatorReplies.map((reply) => ({ ...reply })),
};
}

View File

@@ -2,7 +2,16 @@ import { http, HttpResponse } from "msw";
import type { RequestHandler } from "msw";
import { z } from "zod";
import { CharacterMockStore, parseAudioContentCreateRequest, parseAudioContentDeactivateRequest, parseAudioContentUpdateRequest, parseCharacterMutationRequest } from "@/shared/mocks/character-mock-store";
import { createAudioContentMockHandlers } from "@/shared/mocks/audio-content-handlers";
import { createCommentMockHandlers } from "@/shared/mocks/comment-handlers";
import { CommentMockStore, parseAudioCommentCreateRequest, parseCommentUpdateRequest, parseCommunityCommentCreateRequest } from "@/shared/mocks/comment-mock-store";
import { createCommunityPostMockHandlers } from "@/shared/mocks/community-post-handlers";
import { parseCommunityPostCreateRequest, parseCommunityPostUpdateRequest } from "@/shared/mocks/community-post-mock-store";
import { error, ok } from "@/shared/mocks/contract";
import { createFanTalkMockHandlers } from "@/shared/mocks/fan-talk-handlers";
import { createSeriesMockHandlers } from "@/shared/mocks/series-handlers";
import { parseSeriesCreateRequest, parseSeriesUpdateRequest } from "@/shared/mocks/series-mock-store";
const adminToken = "mock-admin-jwt";
const memberToken = "mock-member-jwt";
@@ -16,15 +25,8 @@ const loginRequestSchema = z.strictObject({
password: z.string().min(1),
});
const aiCharactersPreview = {
totalCount: 0,
page: 0,
size: 20,
hasNext: false,
items: [],
} as const;
class MockStore {
class MockStore extends CharacterMockStore {
readonly #commentStore = new CommentMockStore((characterId) => this.getCharacter(characterId));
readonly #revokedTokens = new Set<string>();
activate(token: string): void {
@@ -45,6 +47,26 @@ class MockStore {
return "unauthorized";
}
listRootComments(target: Parameters<CommentMockStore["listRootComments"]>[0], page: number, size: number) {
return this.#commentStore.listRootComments(target, page, size);
}
listReplies(target: Parameters<CommentMockStore["listReplies"]>[0], commentId: number, page: number, size: number) {
return this.#commentStore.listReplies(target, commentId, page, size);
}
createComment(target: Parameters<CommentMockStore["createComment"]>[0], request: Parameters<CommentMockStore["createComment"]>[1]): boolean {
return this.#commentStore.createComment(target, request);
}
updateComment(target: Parameters<CommentMockStore["updateComment"]>[0], commentId: number, request: Parameters<CommentMockStore["updateComment"]>[2]): boolean {
return this.#commentStore.updateComment(target, commentId, request);
}
deleteComment(target: Parameters<CommentMockStore["deleteComment"]>[0], commentId: number): boolean {
return this.#commentStore.deleteComment(target, commentId);
}
}
export type MockFixtureStore = MockStore;
@@ -142,11 +164,75 @@ export function createMockHandlers(
}
const url = new URL(request.url);
if (url.searchParams.get("page") !== "0" || url.searchParams.get("size") !== "20") {
const page = Number(url.searchParams.get("page") ?? "0");
const size = Number(url.searchParams.get("size") ?? "20");
if (!Number.isInteger(page) || page < 0 || !Number.isInteger(size) || size < 1) {
return HttpResponse.json(error(invalidRequestMessage), { status: 400 });
}
return HttpResponse.json(ok(aiCharactersPreview));
return HttpResponse.json(ok(store.listCharacters(url.searchParams.get("searchTerm"), page, size)));
}),
http.get(endpointUrl(apiBaseUrl, "/api/v2/admin/ai-characters/original-works/search"), ({ request }) => {
const deniedResponse = accessResponse(store, request);
if (deniedResponse !== null) {
return deniedResponse;
}
const searchTerm = new URL(request.url).searchParams.get("searchTerm");
if (searchTerm === null || searchTerm.trim().length === 0) {
return HttpResponse.json(error(invalidRequestMessage), { status: 400 });
}
return HttpResponse.json(ok(store.searchOriginalWorks(searchTerm)));
}),
http.post(endpointUrl(apiBaseUrl, "/api/v2/admin/ai-characters"), async ({ request }) => {
const deniedResponse = accessResponse(store, request);
if (deniedResponse !== null) {
return deniedResponse;
}
const mutation = await parseCharacterMutationRequest(request);
if (mutation === null) {
return HttpResponse.json(error(invalidRequestMessage), { status: 400 });
}
store.createCharacter(mutation);
return HttpResponse.json(ok(null));
}),
...createAudioContentMockHandlers(store, (request) => accessResponse(store, request), { apiBaseUrl, invalidRequestMessage, parseCreateRequest: parseAudioContentCreateRequest, parseDeactivateRequest: parseAudioContentDeactivateRequest, parseUpdateRequest: parseAudioContentUpdateRequest }),
...createCommunityPostMockHandlers(store, (request) => accessResponse(store, request), { apiBaseUrl, invalidRequestMessage, parseCreateRequest: parseCommunityPostCreateRequest, parseUpdateRequest: parseCommunityPostUpdateRequest }),
...createCommentMockHandlers(store, (request) => accessResponse(store, request), { apiBaseUrl, invalidRequestMessage, parseAudioCreateRequest: parseAudioCommentCreateRequest, parseCommunityCreateRequest: parseCommunityCommentCreateRequest, parseUpdateRequest: parseCommentUpdateRequest }),
...createFanTalkMockHandlers(store, (request) => accessResponse(store, request), { apiBaseUrl, invalidRequestMessage }),
...createSeriesMockHandlers(store, (request) => accessResponse(store, request), { apiBaseUrl, invalidRequestMessage, parseCreateRequest: parseSeriesCreateRequest, parseUpdateRequest: parseSeriesUpdateRequest }),
http.get(endpointUrl(apiBaseUrl, "/api/v2/admin/ai-characters/:characterId"), ({ params, request }) => {
const deniedResponse = accessResponse(store, request);
if (deniedResponse !== null) {
return deniedResponse;
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const character = store.getCharacter(characterId);
if (character === null) {
return HttpResponse.json(error("AI 캐릭터를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(character));
}),
http.put(endpointUrl(apiBaseUrl, "/api/v2/admin/ai-characters/:characterId"), async ({ params, request }) => {
const deniedResponse = accessResponse(store, request);
if (deniedResponse !== null) {
return deniedResponse;
}
const mutation = await parseCharacterMutationRequest(request);
if (mutation === null) {
return HttpResponse.json(error(invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
if (!store.updateCharacter(characterId, mutation)) {
return HttpResponse.json(error("AI 캐릭터를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(null));
}),
];
}

View File

@@ -0,0 +1,40 @@
import type { SeriesGenreItem, SeriesListItem } from "@/features/series/model/types";
const previewCoverImageUrl = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='128' height='128' viewBox='0 0 128 128'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop stop-color='%23D9F6FF'/%3E%3Cstop offset='1' stop-color='%2300BDF7'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='128' height='128' rx='20' fill='url(%23g)'/%3E%3Cpath d='M38 36h52v10H38zM38 59h52v10H38zM38 82h34v10H38z' fill='%23062B36'/%3E%3C/svg%3E";
export const mockSeriesListItems = [
{
seriesId: 5001,
title: "달빛 상담 시리즈",
introduction: "밤마다 이어지는 상담 에피소드",
coverImageUrl: previewCoverImageUrl,
publishedDaysOfWeek: ["SUN", "WED"],
genreId: 77,
isAdult: false,
state: "PROCEEDING",
isActive: true,
writer: "스튜디오 루나",
studio: "소다랩",
},
{
seriesId: 5002,
title: "아침 루틴 시리즈",
introduction: "하루 시작을 돕는 짧은 안내",
coverImageUrl: previewCoverImageUrl,
publishedDaysOfWeek: ["RANDOM"],
genreId: 88,
isAdult: false,
state: "SUSPEND",
isActive: true,
writer: null,
studio: "소다랩",
},
] as const satisfies readonly SeriesListItem[];
export const mockSeriesGenres = [
{ id: 77, genre: "로맨스", isAdult: false },
{ id: 88, genre: "일상", isAdult: false },
{ id: 99, genre: "성인 로맨스", isAdult: true },
] as const satisfies readonly SeriesGenreItem[];
export const mockSeriesCoverImageUrl = previewCoverImageUrl;

View File

@@ -0,0 +1,231 @@
import { http, HttpResponse } from "msw";
import type { RequestHandler } from "msw";
import { z } from "zod";
import { error, ok } from "@/shared/mocks/contract";
import type { SeriesCreateMutation, SeriesUpdateMutation } from "@/shared/mocks/series-mock-store";
type AccessResponse = (request: Request) => Response | null;
type SeriesStore = {
readonly addSeriesContents: (characterId: string, seriesId: string, contentIdList: readonly number[]) => boolean;
readonly createSeries: (characterId: string, mutation: SeriesCreateMutation) => boolean;
readonly getSeriesDetail: (characterId: string, seriesId: string) => unknown | null;
readonly listSeriesGenres: () => unknown;
readonly listSeriesContents: (characterId: string, seriesId: string, page: number, size: number) => unknown | null;
readonly listSeries: (characterId: string, page: number, size: number) => unknown | null;
readonly removeSeriesContent: (characterId: string, seriesId: string, contentId: number) => boolean;
readonly searchUnlinkedSeriesContents: (characterId: string, seriesId: string, searchWord: string) => unknown | null;
readonly updateSeries: (characterId: string, seriesId: string, mutation: SeriesUpdateMutation) => boolean;
readonly updateSeriesOrder: (characterId: string, ids: readonly number[]) => boolean;
};
type SeriesMockHandlerOptions = {
readonly apiBaseUrl: string;
readonly invalidRequestMessage: string;
readonly parseCreateRequest: (request: Request) => Promise<SeriesCreateMutation | null>;
readonly parseUpdateRequest: (request: Request) => Promise<SeriesUpdateMutation | null>;
};
function endpointUrl(apiBaseUrl: string, path: string): string {
return new URL(path, apiBaseUrl).toString();
}
function parseListQuery(request: Request): { readonly page: number; readonly size: number } | null {
const url = new URL(request.url);
const page = Number(url.searchParams.get("page") ?? "0");
const size = Number(url.searchParams.get("size") ?? "20");
const queryKeys = [...url.searchParams.keys()];
if (!queryKeys.every((key) => key === "page" || key === "size") || !Number.isInteger(page) || page < 0 || !Number.isInteger(size) || size < 1) {
return null;
}
return { page, size };
}
const seriesContentAddRequestSchema = z.strictObject({ contentIdList: z.array(z.number().int()) });
const seriesOrderUpdateRequestSchema = z.strictObject({ ids: z.array(z.number().int()) });
async function parseJsonRequest<T>(request: Request, schema: z.ZodType<T>): Promise<T | null> {
if (request.headers.get("Content-Type")?.toLowerCase().split(";")[0]?.trim() !== "application/json") {
return null;
}
try {
return schema.parse(await request.json());
} catch (parseError) {
if (parseError instanceof SyntaxError || parseError instanceof z.ZodError) {
return null;
}
throw parseError;
}
}
export function createSeriesMockHandlers(store: SeriesStore, accessResponse: AccessResponse, options: SeriesMockHandlerOptions): readonly RequestHandler[] {
return [
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/series-genres"), ({ request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
if (new URL(request.url).search !== "") {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
return HttpResponse.json(ok(store.listSeriesGenres()));
}),
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/series"), ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const query = parseListQuery(request);
if (query === null) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const series = store.listSeries(characterId, query.page, query.size);
if (series === null) {
return HttpResponse.json(error("AI 캐릭터를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(series));
}),
http.post(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/series"), async ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const body = await options.parseCreateRequest(request);
if (body === null) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
if (!store.createSeries(characterId, body)) {
return HttpResponse.json(error("AI 캐릭터를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(null));
}),
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/series/:seriesId"), ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const seriesId = typeof params.seriesId === "string" ? params.seriesId : "";
const series = store.getSeriesDetail(characterId, seriesId);
if (series === null) {
return HttpResponse.json(error("시리즈를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(series));
}),
http.put(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/series/orders"), async ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const body = await parseJsonRequest(request, seriesOrderUpdateRequestSchema);
if (body === null) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
if (!store.updateSeriesOrder(characterId, body.ids)) {
return HttpResponse.json(error("시리즈 순서가 최신 목록과 맞지 않습니다."), { status: 409 });
}
return HttpResponse.json(ok(null));
}),
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/series/:seriesId/contents"), ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const query = parseListQuery(request);
if (query === null) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const seriesId = typeof params.seriesId === "string" ? params.seriesId : "";
const contents = store.listSeriesContents(characterId, seriesId, query.page, query.size);
if (contents === null) {
return HttpResponse.json(error("시리즈를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(contents));
}),
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/series/:seriesId/contents/search"), ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const url = new URL(request.url);
const searchWord = url.searchParams.get("search_word");
if (searchWord === null || [...url.searchParams.keys()].some((key) => key !== "search_word")) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const seriesId = typeof params.seriesId === "string" ? params.seriesId : "";
const contents = store.searchUnlinkedSeriesContents(characterId, seriesId, searchWord);
if (contents === null) {
return HttpResponse.json(error("시리즈를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(contents));
}),
http.post(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/series/:seriesId/contents"), async ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const body = await parseJsonRequest(request, seriesContentAddRequestSchema);
if (body === null) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const seriesId = typeof params.seriesId === "string" ? params.seriesId : "";
if (!store.addSeriesContents(characterId, seriesId, body.contentIdList)) {
return HttpResponse.json(error("시리즈 또는 오디오 콘텐츠를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(null));
}),
http.delete(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/series/:seriesId/contents/:contentId"), async ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
if ((await request.text()) !== "") {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const seriesId = typeof params.seriesId === "string" ? params.seriesId : "";
const contentId = Number(typeof params.contentId === "string" ? params.contentId : "");
if (!Number.isInteger(contentId) || !store.removeSeriesContent(characterId, seriesId, contentId)) {
return HttpResponse.json(error("시리즈 또는 오디오 콘텐츠를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(null));
}),
http.put(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/series/:seriesId"), async ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const body = await options.parseUpdateRequest(request);
if (body === null) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const seriesId = typeof params.seriesId === "string" ? params.seriesId : "";
if (!store.updateSeries(characterId, seriesId, body)) {
return HttpResponse.json(error("시리즈를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(null));
}),
];
}

View File

@@ -0,0 +1,189 @@
import { z } from "zod";
import type { CharacterDetail } from "@/features/characters/model/types";
import type { SeriesContentListItem, SeriesContentSearchItem, SeriesListItem } from "@/features/series/model/types";
import type { SeriesCreateRequest, SeriesDeactivateRequest, SeriesUpdateRequest } from "@/features/series/schemas/series-schema";
import { seriesCreateRequestSchema, seriesDeactivateRequestSchema, seriesUpdateRequestSchema } from "@/features/series/schemas/series-schema";
import { mockAudioContentListItems } from "@/shared/mocks/audio-content-fixtures";
import { mockSeriesCoverImageUrl, mockSeriesGenres, mockSeriesListItems } from "@/shared/mocks/series-fixtures";
type GetCharacter = (characterId: string) => CharacterDetail | null;
export type SeriesCreateMutation = { readonly image: File | null; readonly request: SeriesCreateRequest };
export type SeriesUpdateMutation = { readonly image: File | null; readonly request: SeriesUpdateRequest | SeriesDeactivateRequest };
export class SeriesMockStore {
#seriesListItems: SeriesListItem[] = mockSeriesListItems.map(toMutableSeriesListItem);
#seriesContentIds = new Map<string, readonly number[]>([["5001", [9001]]]);
readonly #getCharacter: GetCharacter;
#nextSeriesId = 6000;
constructor(getCharacter: GetCharacter) {
this.#getCharacter = getCharacter;
}
listSeries(characterId: string, page: number, size: number) {
if (this.#getCharacter(characterId) === null) {
return null;
}
const start = page * size;
return {
totalCount: this.#seriesListItems.length,
items: this.#seriesListItems.slice(start, start + size),
};
}
listSeriesGenres() {
return mockSeriesGenres.map((genre) => ({ ...genre }));
}
getSeriesDetail(characterId: string, seriesId: string) {
if (this.#getCharacter(characterId) === null) {
return null;
}
return this.#seriesListItems.find((series) => String(series.seriesId) === seriesId) ?? null;
}
createSeries(characterId: string, mutation: SeriesCreateMutation): boolean {
if (this.#getCharacter(characterId) === null || mutation.image === null) {
return false;
}
const seriesId = this.#nextSeriesId;
this.#nextSeriesId += 1;
this.#seriesListItems = [...this.#seriesListItems, { seriesId, title: mutation.request.title, introduction: mutation.request.introduction, coverImageUrl: mockSeriesCoverImageUrl, publishedDaysOfWeek: [...mutation.request.publishedDaysOfWeek], genreId: mutation.request.genreId, isAdult: mutation.request.isAdult ?? false, state: "PROCEEDING", isActive: true, writer: mutation.request.writer ?? null, studio: mutation.request.studio ?? null }];
return true;
}
updateSeries(characterId: string, seriesId: string, mutation: SeriesUpdateMutation): boolean {
if (this.#getCharacter(characterId) === null) {
return false;
}
const current = this.getSeriesDetail(characterId, seriesId);
if (current === null) {
return false;
}
if ("isActive" in mutation.request) {
this.#seriesListItems = this.#seriesListItems.filter((item) => item.seriesId !== current.seriesId);
return true;
}
const next = { ...current, title: mutation.request.title ?? current.title, introduction: mutation.request.introduction ?? current.introduction, coverImageUrl: mutation.image === null ? current.coverImageUrl : mockSeriesCoverImageUrl, publishedDaysOfWeek: mutation.request.publishedDaysOfWeek ?? current.publishedDaysOfWeek, genreId: mutation.request.genreId ?? current.genreId, isAdult: mutation.request.isAdult ?? current.isAdult, state: mutation.request.state ?? current.state, writer: mutation.request.writer ?? current.writer, studio: mutation.request.studio ?? current.studio };
this.#seriesListItems = this.#seriesListItems.map((item) => (item.seriesId === next.seriesId ? next : item));
return true;
}
listSeriesContents(characterId: string, seriesId: string, page: number, size: number) {
if (this.getSeriesDetail(characterId, seriesId) === null) {
return null;
}
const linkedIds = this.#seriesContentIds.get(seriesId) ?? [];
const items = linkedIds.map(toSeriesContentItem).filter((item) => item !== null);
const start = page * size;
return { totalCount: items.length, items: items.slice(start, start + size) };
}
searchUnlinkedSeriesContents(characterId: string, seriesId: string, searchWord: string) {
if (this.getSeriesDetail(characterId, seriesId) === null) {
return null;
}
const linkedIds = new Set(this.#seriesContentIds.get(seriesId) ?? []);
const normalizedSearch = searchWord.trim().toLowerCase();
return mockAudioContentListItems
.filter((item) => !linkedIds.has(item.audioContentId))
.filter((item) => `${item.title} ${item.detail} ${item.theme} ${item.tags}`.toLowerCase().includes(normalizedSearch))
.map(toSeriesContentSearchItemFromAudio);
}
addSeriesContents(characterId: string, seriesId: string, contentIdList: readonly number[]): boolean {
if (this.getSeriesDetail(characterId, seriesId) === null) {
return false;
}
const contentIds = new Set<number>(mockAudioContentListItems.map((item) => item.audioContentId));
if (!contentIdList.every((contentId) => contentIds.has(contentId))) {
return false;
}
const currentIds = this.#seriesContentIds.get(seriesId) ?? [];
const nextIds = [...currentIds, ...contentIdList.filter((contentId) => !currentIds.includes(contentId))];
this.#seriesContentIds.set(seriesId, nextIds);
return true;
}
removeSeriesContent(characterId: string, seriesId: string, contentId: number): boolean {
if (this.getSeriesDetail(characterId, seriesId) === null) {
return false;
}
const currentIds = this.#seriesContentIds.get(seriesId) ?? [];
this.#seriesContentIds.set(seriesId, currentIds.filter((id) => id !== contentId));
return true;
}
updateSeriesOrder(characterId: string, ids: readonly number[]): boolean {
if (this.#getCharacter(characterId) === null || ids.length !== this.#seriesListItems.length) {
return false;
}
const currentIds = new Set(this.#seriesListItems.map((series) => series.seriesId));
if (!ids.every((id) => currentIds.has(id))) {
return false;
}
this.#seriesListItems = ids.map((id) => this.#seriesListItems.find((series) => series.seriesId === id)).filter((series) => series !== undefined);
return true;
}
}
function toSeriesContentItem(contentId: number): SeriesContentListItem | null {
const audio = mockAudioContentListItems.find((item) => item.audioContentId === contentId);
return audio === undefined ? null : toSeriesContentListItemFromAudio(audio);
}
function toSeriesContentListItemFromAudio(audio: (typeof mockAudioContentListItems)[number]): SeriesContentListItem {
return { contentId: audio.audioContentId, title: audio.title, coverImage: audio.coverImageUrl, isAdult: audio.isAdult };
}
function toSeriesContentSearchItemFromAudio(audio: (typeof mockAudioContentListItems)[number]): SeriesContentSearchItem {
return { contentId: audio.audioContentId, title: audio.title, coverImage: audio.coverImageUrl };
}
function toMutableSeriesListItem(series: (typeof mockSeriesListItems)[number]): SeriesListItem {
return {
...series,
publishedDaysOfWeek: [...series.publishedDaysOfWeek],
};
}
export async function parseSeriesCreateRequest(request: Request): Promise<SeriesCreateMutation | null> {
return parseSeriesMutationRequest(request, seriesCreateRequestSchema);
}
export async function parseSeriesUpdateRequest(request: Request): Promise<SeriesUpdateMutation | null> {
return parseSeriesMutationRequest(request, z.union([seriesDeactivateRequestSchema, seriesUpdateRequestSchema]));
}
async function parseSeriesMutationRequest<MutationRequest extends object>(request: Request, schema: z.ZodType<MutationRequest>): Promise<{ readonly image: File | null; readonly request: MutationRequest } | null> {
try {
const formData = await request.formData();
const requestPart = formData.get("request");
if (!(requestPart instanceof Blob)) {
return null;
}
const image = formData.get("image");
if (image !== null && !(image instanceof File)) {
return null;
}
return { image, request: schema.parse(JSON.parse(await requestPart.text())) };
} catch (parseError) {
if (parseError instanceof SyntaxError || parseError instanceof z.ZodError) {
return null;
}
throw parseError;
}
}

View File

@@ -29,7 +29,7 @@ test("AdminAudioPlayer wraps native audio with controls and no download or autop
expect(audio).toHaveAttribute("controlsList", "nodownload");
expect(screen.getByRole("button", { name: "재생" })).toBeInTheDocument();
expect(screen.getByRole("slider", { name: "재생 위치" })).toBeInTheDocument();
expect(screen.getByRole("slider", { name: "볼륨" })).toBeInTheDocument();
expect(screen.getByRole("slider", { name: "볼륨" })).toHaveValue("1");
expect(screen.getByRole("combobox", { name: "재생 속도" })).toBeInTheDocument();
});

View File

@@ -19,7 +19,9 @@ test("ConfirmDeactivateDialog confirms deactivation with target and impact copy,
);
expect(screen.getByRole("alertdialog", { name: "루나 비활성화 확인" })).toHaveTextContent("사용자는 이 캐릭터를 더 이상 선택할 수 없습니다.");
expect(screen.getByRole("alertdialog", { name: "루나 비활성화 확인" })).not.toHaveTextContent("완전 삭제");
expect(screen.queryByRole("switch")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "비활성화" })).toHaveClass("bg-destructive");
fireEvent.click(screen.getByRole("button", { name: "취소" }));
fireEvent.click(screen.getByRole("button", { name: "비활성화" }));
@@ -28,6 +30,40 @@ test("ConfirmDeactivateDialog confirms deactivation with target and impact copy,
expect(onConfirm).toHaveBeenCalledTimes(1);
});
test("ConfirmDeactivateDialog exposes pending and error state while keeping retry available", () => {
const onConfirm = vi.fn();
const { rerender } = render(
<ConfirmDeactivateDialog
errorMessage="비활성화하지 못했습니다. 다시 시도하세요."
impactDescription="사용자는 이 캐릭터를 더 이상 선택할 수 없습니다."
isPending
onCancel={vi.fn()}
onConfirm={onConfirm}
open
targetName="루나"
/>,
);
expect(screen.getByRole("alertdialog", { name: "루나 비활성화 확인" })).toHaveAttribute("aria-busy", "true");
expect(screen.getByRole("alert")).toHaveTextContent("비활성화하지 못했습니다. 다시 시도하세요.");
fireEvent.click(screen.getByRole("button", { name: "처리 중" }));
expect(onConfirm).not.toHaveBeenCalled();
rerender(
<ConfirmDeactivateDialog
errorMessage="비활성화하지 못했습니다. 다시 시도하세요."
impactDescription="사용자는 이 캐릭터를 더 이상 선택할 수 없습니다."
onCancel={vi.fn()}
onConfirm={onConfirm}
open
targetName="루나"
/>,
);
fireEvent.click(screen.getByRole("button", { name: "비활성화" }));
expect(onConfirm).toHaveBeenCalledTimes(1);
});
test("ConfirmDeactivateDialog traps focus and returns it to the trigger after cancel", async () => {
function Harness() {
const [open, setOpen] = useState(false);

View File

@@ -23,6 +23,7 @@ test("FileField exposes label, description, error, accept guidance, keyboard fil
expect(input).toHaveAttribute("accept", "image/png");
expect(input).toHaveAttribute("aria-invalid", "true");
expect(input).toHaveAccessibleDescription("프로필 이미지를 선택하세요. PNG만 업로드할 수 있습니다. 파일이 너무 큽니다.");
expect(screen.getByRole("button", { name: "대표 이미지 파일 선택" })).toBeInTheDocument();
expect(screen.getByText("profile.png")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "선택 취소" })).toBeInTheDocument();
});
@@ -39,3 +40,16 @@ test("FileField emits File or null and clear selection without owning upload pol
fireEvent.click(screen.getByRole("button", { name: "선택 취소" }));
expect(onChange).toHaveBeenCalledWith(null);
});
test("FileField gives same visible file buttons field-specific accessible names", () => {
render(
<>
<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오 파일" onChange={vi.fn()} value={null} />
<FileField accept="image/png" acceptDescription="PNG" label="커버 이미지" onChange={vi.fn()} value={null} />
</>,
);
expect(screen.getAllByText("파일 선택")).toHaveLength(2);
expect(screen.getByRole("button", { name: "오디오 파일 파일 선택" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "커버 이미지 파일 선택" })).toBeInTheDocument();
});

View File

@@ -11,6 +11,51 @@ const image = {
width: 600,
};
function setPreviewFrameSize(width: number, height: number): void {
Object.defineProperty(HTMLImageElement.prototype, "getBoundingClientRect", {
configurable: true,
value: () => ({ bottom: height, height, left: 0, right: width, top: 0, width, x: 0, y: 0, toJSON: () => ({}) }),
});
}
function rect(width: number, height: number): DOMRect {
return { bottom: height, height, left: 0, right: width, top: 0, width, x: 0, y: 0, toJSON: () => ({}) };
}
async function withElementRects(testBody: () => Promise<void>): Promise<void> {
const originalElementRect = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "getBoundingClientRect");
const originalImageRect = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, "getBoundingClientRect");
Object.defineProperty(HTMLElement.prototype, "getBoundingClientRect", {
configurable: true,
value(this: HTMLElement) {
if (this.getAttribute("aria-label") === "이미지 crop viewport") {
return rect(181, 256);
}
return rect(0, 0);
},
});
Object.defineProperty(HTMLImageElement.prototype, "getBoundingClientRect", {
configurable: true,
value: () => rect(384, 384),
});
return testBody().finally(() => {
if (originalElementRect === undefined) {
Reflect.deleteProperty(HTMLElement.prototype, "getBoundingClientRect");
} else {
Object.defineProperty(HTMLElement.prototype, "getBoundingClientRect", originalElementRect);
}
if (originalImageRect === undefined) {
Reflect.deleteProperty(HTMLImageElement.prototype, "getBoundingClientRect");
} else {
Object.defineProperty(HTMLImageElement.prototype, "getBoundingClientRect", originalImageRect);
}
});
}
test("ImageCropDialog provides move, zoom, reset, preview, cancel, and apply controls", async () => {
const onApply = vi.fn();
const onCancel = vi.fn();
@@ -20,7 +65,7 @@ test("ImageCropDialog provides move, zoom, reset, preview, cancel, and apply con
fireEvent.click(screen.getByRole("button", { name: "오른쪽으로 이동" }));
fireEvent.change(screen.getByRole("slider", { name: "확대 비율" }), { target: { value: "1.5" } });
expect(screen.getByText("예상 결과 600 × 600px")).toBeInTheDocument();
expect(screen.getByText("예상 결과 400 × 400px")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "초기화" }));
fireEvent.click(screen.getByRole("button", { name: "적용" }));
@@ -42,8 +87,118 @@ test("ImageCropDialog supports keyboard movement and no-upscale sizing", async (
fireEvent.keyDown(preview, { key: "+" });
fireEvent.click(screen.getByRole("button", { name: "적용" }));
expect(await screen.findByText("예상 결과 600 × 300px")).toBeInTheDocument();
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetX: 10, outputHeight: 300, outputWidth: 600, zoom: 1.1 }));
expect(await screen.findByText("예상 결과 545 × 272px")).toBeInTheDocument();
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetX: 10, outputHeight: 272, outputWidth: 545, zoom: 1.1 }));
});
test("ImageCropDialog sends preview frame dimensions with crop offsets", async () => {
setPreviewFrameSize(256, 256);
const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([`${request.offsetX}`], "crop.png", { type: "image/png" })));
render(<ImageCropDialog image={{ ...image, height: 3000, width: 4000 }} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: 1, maxWidth: 1000, noUpscale: true }} renderCrop={renderCrop} />);
fireEvent.click(screen.getByRole("button", { name: "오른쪽으로 이동" }));
fireEvent.click(screen.getByRole("button", { name: "적용" }));
await screen.findByText("예상 결과 1000 × 1000px");
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetX: 10, previewFrameHeight: 256, previewFrameWidth: 256, sourceHeight: 3000, sourceWidth: 4000 }));
});
test("ImageCropDialog measures the visible crop viewport instead of the transformed image", async () => {
await withElementRects(async () => {
const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([`${request.offsetX}`], "crop.png", { type: "image/png" })));
render(<ImageCropDialog image={{ ...image, height: 3000, width: 4000 }} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: 210 / 297, maxWidth: 1000, noUpscale: true }} renderCrop={renderCrop} />);
fireEvent.click(screen.getByRole("button", { name: "오른쪽으로 이동" }));
fireEvent.change(screen.getByRole("slider", { name: "확대 비율" }), { target: { value: "1.5" } });
fireEvent.click(screen.getByRole("button", { name: "적용" }));
await screen.findByText("예상 결과 1000 × 1414px");
expect(screen.getByLabelText("이미지 crop viewport")).toHaveStyle({ aspectRatio: `${210 / 297}` });
expect(screen.getByAltText("선택한 이미지 미리보기")).toHaveClass("h-full", "w-auto", "max-w-none");
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ previewFrameHeight: 256, previewFrameWidth: 181, zoom: 1.5 }));
});
});
test("ImageCropDialog clamps movement on axes without crop overhang", async () => {
await withElementRects(async () => {
const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([`${request.offsetY}`], "crop.png", { type: "image/png" })));
render(<ImageCropDialog image={{ ...image, height: 3000, width: 4000 }} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: 210 / 297, maxWidth: 1000, noUpscale: true }} renderCrop={renderCrop} />);
fireEvent.click(screen.getByRole("button", { name: "아래로 이동" }));
fireEvent.click(screen.getByRole("button", { name: "적용" }));
expect(screen.getByAltText("선택한 이미지 미리보기")).toHaveStyle({ transform: "translate(-50%, -50%) translate(0px, 0px) scale(1)" });
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetY: 0 }));
});
});
test("ImageCropDialog keeps apply single-flight and allows retry after render failure", async () => {
const onApply = vi.fn();
let rejectCrop: (error: Error) => void = () => undefined;
const renderCrop = vi.fn(() => new Promise<File>((_resolve, reject) => {
rejectCrop = reject;
}));
render(<ImageCropDialog image={image} onApply={onApply} onCancel={vi.fn()} open policy={{ aspect: 1, maxWidth: 800, noUpscale: true }} renderCrop={renderCrop} />);
fireEvent.click(screen.getByRole("button", { name: "적용" }));
fireEvent.click(screen.getByRole("button", { name: "적용" }));
await screen.findByRole("status");
expect(renderCrop).toHaveBeenCalledTimes(1);
rejectCrop(new Error("render failed"));
expect(await screen.findByRole("alert")).toHaveTextContent("이미지 crop을 적용하지 못했습니다.");
fireEvent.click(screen.getByRole("button", { name: "적용" }));
expect(renderCrop).toHaveBeenCalledTimes(2);
expect(onApply).not.toHaveBeenCalled();
});
test("ImageCropDialog changes zoom with a two pointer pinch", async () => {
const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([String(request.zoom)], "crop.png", { type: "image/png" })));
render(<ImageCropDialog image={image} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: 1, maxWidth: 800, noUpscale: true }} renderCrop={renderCrop} />);
const preview = screen.getByRole("application", { name: "이미지 crop 미리보기" });
fireEvent.pointerDown(preview, { clientX: 100, clientY: 100, pointerId: 1 });
fireEvent.pointerDown(preview, { clientX: 200, clientY: 100, pointerId: 2 });
fireEvent.pointerMove(preview, { clientX: 250, clientY: 100, pointerId: 2 });
fireEvent.pointerUp(preview, { pointerId: 1 });
fireEvent.pointerUp(preview, { pointerId: 2 });
fireEvent.click(screen.getByRole("button", { name: "적용" }));
expect(await screen.findByText("예상 결과 400 × 400px")).toBeInTheDocument();
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ outputHeight: 400, outputWidth: 400, zoom: 1.5 }));
expect(screen.getByRole("slider", { name: "확대 비율" })).toHaveValue("1.5");
});
test("ImageCropDialog disables native touch gestures on the crop preview", () => {
render(<ImageCropDialog image={image} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: 1, maxWidth: 800, noUpscale: true }} />);
expect(screen.getByRole("application", { name: "이미지 crop 미리보기" })).toHaveStyle({ touchAction: "none" });
});
test("ImageCropDialog closes itself on Escape without bubbling to parent dialogs", () => {
const onCancel = vi.fn();
const onParentEscape = vi.fn();
render(
<div onKeyDown={(event) => {
if (event.key === "Escape") {
onParentEscape();
}
}}>
<ImageCropDialog image={image} onApply={vi.fn()} onCancel={onCancel} open policy={{ aspect: 1, maxWidth: 800, noUpscale: true }} />
</div>,
);
fireEvent.keyDown(screen.getByRole("dialog", { name: "이미지 crop" }), { key: "Escape" });
expect(onCancel).toHaveBeenCalledTimes(1);
expect(onParentEscape).not.toHaveBeenCalled();
});
test("ImageCropDialog supports free ratio output and pointer drag movement", async () => {
@@ -52,6 +207,7 @@ test("ImageCropDialog supports free ratio output and pointer drag movement", asy
render(<ImageCropDialog image={{ ...image, height: 600, width: 1200 }} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: "free", maxWidth: 800, noUpscale: true }} renderCrop={renderCrop} />);
const preview = screen.getByRole("application", { name: "이미지 crop 미리보기" });
fireEvent.change(screen.getByRole("slider", { name: "확대 비율" }), { target: { value: "1.5" } });
fireEvent.pointerDown(preview, { clientX: 100, clientY: 100, pointerId: 1 });
fireEvent.pointerMove(preview, { clientX: 130, clientY: 115, pointerId: 1 });
fireEvent.pointerUp(preview, { pointerId: 1 });

View File

@@ -9,7 +9,9 @@ describe("MockModeBanner", () => {
render(<MockModeBanner apiMode="mock" />);
// Then
expect(screen.getByRole("status", { name: "Mock Preview" })).toHaveTextContent("Mock Preview");
const banner = screen.getByRole("status", { name: "Mock Preview" });
expect(banner).toHaveTextContent("Mock Preview");
expect(banner).toHaveClass("break-keep");
});
test("does not render in server mode", () => {

View File

@@ -40,3 +40,19 @@ test("ResourcePagination disables unavailable previous and next actions", () =>
expect(screen.getByRole("button", { name: "이전 페이지" })).toBeDisabled();
expect(screen.getByRole("button", { name: "다음 페이지" })).toBeDisabled();
});
test("ResourcePagination connects each page size label to a unique select", () => {
const { container } = render(
<>
<ResourcePagination data={pageData} onPageChange={vi.fn()} onSizeChange={vi.fn()} />
<ResourcePagination data={{ ...pageData, page: 0 }} onPageChange={vi.fn()} onSizeChange={vi.fn()} />
</>,
);
const selects = screen.getAllByLabelText("페이지 크기");
const selectIds = selects.map((select) => select.id);
const labels = Array.from(container.querySelectorAll("label"));
expect(new Set(selectIds).size).toBe(selectIds.length);
expect(labels.map((label) => label.control)).toEqual(selects);
});

View File

@@ -7,33 +7,33 @@ afterEach(() => {
vi.useRealTimers();
});
test("SearchToolbar does not emit a query before user input", () => {
vi.useFakeTimers();
const onQueryChange = vi.fn();
render(<SearchToolbar onQueryChange={onQueryChange} search="루나" />);
act(() => vi.advanceTimersByTime(300));
expect(onQueryChange).not.toHaveBeenCalled();
});
test("SearchToolbar keeps search controlled, renders filters, and emits only a debounced generic query", () => {
vi.useFakeTimers();
const onQueryChange = vi.fn();
const onSearchChange = vi.fn();
const { rerender } = render(
render(
<SearchToolbar
filters={<select aria-label="상태 필터"><option></option></select>}
onQueryChange={onQueryChange}
onSearchChange={onSearchChange}
search=""
/>,
);
fireEvent.change(screen.getByRole("searchbox", { name: "검색어" }), { target: { value: "루나" } });
expect(onSearchChange).toHaveBeenCalledWith("루나");
expect(screen.getByRole("searchbox", { name: "검색어" })).toHaveValue("루나");
expect(screen.getByLabelText("상태 필터")).toBeInTheDocument();
expect(onQueryChange).not.toHaveBeenCalled();
rerender(
<SearchToolbar
filters={<select aria-label="상태 필터"><option></option></select>}
onQueryChange={onQueryChange}
onSearchChange={onSearchChange}
search="루나"
/>,
);
act(() => vi.advanceTimersByTime(299));
expect(onQueryChange).not.toHaveBeenCalled();

View File

@@ -87,7 +87,7 @@ export function AdminAudioPlayer({ playerId, src, title }: AdminAudioPlayerProps
}
return (
<section aria-label={`${title} 오디오 플레이어`} className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4" onKeyDown={handleKeyDown} role="group" tabIndex={0}>
<section aria-label={`${title} 오디오 플레이어`} className="flex min-w-0 flex-col gap-3 rounded-lg border border-border bg-card p-4" onKeyDown={handleKeyDown} role="group" tabIndex={0}>
<audio
controlsList="nodownload"
onDurationChange={(event) => setDuration(event.currentTarget.duration)}
@@ -118,7 +118,7 @@ export function AdminAudioPlayer({ playerId, src, title }: AdminAudioPlayerProps
</label>
<label className="flex flex-col gap-2 text-sm font-semibold">
<input aria-label="볼륨" max="1" min="0" onChange={(event) => changeVolume(Number(event.currentTarget.value))} step="0.05" type="range" />
<input aria-label="볼륨" defaultValue="1" max="1" min="0" onChange={(event) => changeVolume(Number(event.currentTarget.value))} step="0.05" type="range" />
</label>
<label className="flex flex-col gap-2 text-sm font-semibold">
@@ -131,8 +131,8 @@ export function AdminAudioPlayer({ playerId, src, title }: AdminAudioPlayerProps
</select>
</label>
{hasError ? (
<div className="flex flex-col gap-2 rounded-md border border-destructive bg-card p-3 text-sm text-destructive" role="alert">
<p> . .</p>
<div className="flex min-w-0 flex-col gap-2 rounded-md border border-destructive bg-card p-3 text-sm text-destructive" role="alert">
<p className="break-words"> . .</p>
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={retry} type="button"> </button>
</div>
) : null}

View File

@@ -1,14 +1,17 @@
import { useModalFocus } from "@/shared/ui/use-modal-focus";
export type ConfirmDeactivateDialogProps = {
readonly confirmLabel?: string;
readonly errorMessage?: string;
readonly impactDescription: string;
readonly isPending?: boolean;
readonly onCancel: () => void;
readonly onConfirm: () => void;
readonly open: boolean;
readonly targetName: string;
};
export function ConfirmDeactivateDialog({ impactDescription, onCancel, onConfirm, open, targetName }: ConfirmDeactivateDialogProps) {
export function ConfirmDeactivateDialog({ confirmLabel = "비활성화", errorMessage, impactDescription, isPending = false, onCancel, onConfirm, open, targetName }: ConfirmDeactivateDialogProps) {
const { dialogRef, trapFocus } = useModalFocus<HTMLElement>(open);
if (!open) {
@@ -19,17 +22,18 @@ export function ConfirmDeactivateDialog({ impactDescription, onCancel, onConfirm
return (
<div className="fixed inset-0 z-modal grid place-items-center bg-background/80 p-4">
<section aria-modal="true" className="flex w-full max-w-sm flex-col gap-4 rounded-lg border border-border bg-card p-6" onKeyDown={trapFocus} ref={dialogRef} role="alertdialog" aria-label={title}>
<section aria-busy={isPending ? true : undefined} aria-modal="true" className="flex w-full max-w-sm flex-col gap-4 rounded-lg border border-border bg-card p-6" onKeyDown={trapFocus} ref={dialogRef} role="alertdialog" aria-label={title}>
<div className="flex flex-col gap-2">
<h2 className="text-xl font-semibold">{title}</h2>
<p className="text-sm text-muted-foreground">{impactDescription}</p>
</div>
{errorMessage === undefined ? null : <p className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" role="alert">{errorMessage}</p>}
<div className="flex justify-end gap-2">
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={onCancel} type="button">
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isPending} onClick={onCancel} type="button">
</button>
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={onConfirm} type="button">
<button className="rounded-md border border-destructive bg-destructive px-4 py-2 font-semibold text-white hover:bg-destructive/90 active:bg-destructive disabled:opacity-60" disabled={isPending} onClick={onConfirm} type="button">
{isPending ? "처리 중" : confirmLabel}
</button>
</div>
</section>

View File

@@ -35,7 +35,8 @@ export function FileField({ accept, acceptDescription, description, error, label
<label className="text-sm font-semibold" htmlFor={inputId}>{label}</label>
{description === undefined ? null : <p className="text-sm text-muted-foreground" id={descriptionId}>{description}</p>}
<p className="text-sm text-muted-foreground" id={acceptId}>{acceptDescription}</p>
<input accept={accept} aria-describedby={describedBy} aria-invalid={error === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base text-foreground" id={inputId} onChange={handleChange} ref={inputRef} type="file" />
<input accept={accept} aria-describedby={describedBy} aria-invalid={error === undefined ? undefined : true} className="sr-only" id={inputId} onChange={handleChange} ref={inputRef} type="file" />
<button aria-label={`${label} 파일 선택`} className="w-fit rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => inputRef.current?.click()} type="button"> </button>
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted-foreground">{value === null ? "선택된 파일 없음" : value.name}</span>
{value === null ? null : (

View File

@@ -1,4 +1,4 @@
import { useRef, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { calculateCropOutputSize, createCroppedImageFile } from "@/shared/lib/crop-image";
import type { CropRenderRequest } from "@/shared/lib/crop-image";
@@ -8,6 +8,7 @@ export type CropSourceImage = {
readonly file: File;
readonly height: number;
readonly previewUrl: string;
readonly release?: () => void;
readonly width: number;
};
@@ -29,13 +30,54 @@ export type ImageCropDialogProps = {
const MOVE_STEP = 10;
const ZOOM_STEP = 0.1;
type PointerPoint = {
readonly x: number;
readonly y: number;
};
type PinchState = {
readonly distance: number;
readonly zoom: number;
};
type CropOffset = {
readonly x: number;
readonly y: number;
};
type CropFrameSize = {
readonly height: number;
readonly width: number;
};
export function ImageCropDialog({ image, onApply, onCancel, open, policy, renderCrop = createCroppedImageFile }: ImageCropDialogProps) {
const [offsetX, setOffsetX] = useState(0);
const [offsetY, setOffsetY] = useState(0);
const [applyError, setApplyError] = useState<string | null>(null);
const [isApplying, setIsApplying] = useState(false);
const [viewportSize, setViewportSize] = useState<CropFrameSize | null>(null);
const [zoom, setZoom] = useState(1);
const dragPointRef = useRef<{ readonly x: number; readonly y: number } | null>(null);
const pinchRef = useRef<PinchState | null>(null);
const cropViewportRef = useRef<HTMLDivElement>(null);
const previewImageRef = useRef<HTMLImageElement>(null);
const pointersRef = useRef(new Map<number, PointerPoint>());
const { dialogRef, trapFocus } = useModalFocus<HTMLElement>(open);
const outputSize = calculateCropOutputSize({ aspect: policy.aspect, maxWidth: policy.maxWidth, noUpscale: policy.noUpscale, sourceHeight: image.height, sourceWidth: image.width });
const outputSize = calculateCropOutputSize({ aspect: policy.aspect, maxWidth: policy.maxWidth, noUpscale: policy.noUpscale, sourceHeight: image.height, sourceWidth: image.width, zoom });
const cropFrameAspect = policy.aspect === "free" ? image.width / image.height : policy.aspect;
const sourceAspect = image.width / image.height;
const coverImageClass = sourceAspect > cropFrameAspect ? "h-full w-auto max-w-none" : "h-auto w-full max-w-none";
const clampedOffset = clampOffset({ x: offsetX, y: offsetY }, viewportSize);
const setCropViewportNode = useCallback((node: HTMLDivElement | null) => {
cropViewportRef.current = node;
if (node === null) {
setViewportSize(null);
return;
}
const rect = node.getBoundingClientRect();
setViewportSize({ height: rect.height, width: rect.width });
}, []);
if (!open) {
return null;
@@ -56,7 +98,45 @@ export function ImageCropDialog({ image, onApply, onCancel, open, policy, render
setZoom(Math.min(3, Math.max(1, Number(nextZoom.toFixed(1)))));
}
function handleKeyDown(event: React.KeyboardEvent<HTMLElement>) {
function clampOffset(offset: CropOffset, frameSize: CropFrameSize | null): CropOffset {
if (frameSize === null || frameSize.width <= 0 || frameSize.height <= 0) {
return offset;
}
const coverWidthRatio = sourceAspect > cropFrameAspect ? sourceAspect / cropFrameAspect : 1;
const coverHeightRatio = sourceAspect > cropFrameAspect ? 1 : cropFrameAspect / sourceAspect;
const maxX = Math.max(0, (frameSize.width * coverWidthRatio * zoom - frameSize.width) / 2);
const maxY = Math.max(0, (frameSize.height * coverHeightRatio * zoom - frameSize.height) / 2);
return {
x: Math.min(Math.max(offset.x, -maxX), maxX),
y: Math.min(Math.max(offset.y, -maxY), maxY),
};
}
function getPinchDistance() {
const points = Array.from(pointersRef.current.values());
const first = points[0];
const second = points[1];
if (first === undefined || second === undefined) {
return null;
}
return Math.hypot(second.x - first.x, second.y - first.y);
}
function handleDialogKeyDown(event: React.KeyboardEvent<HTMLElement>) {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
onCancel();
return;
}
trapFocus(event);
}
function handlePreviewKeyDown(event: React.KeyboardEvent<HTMLElement>) {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
@@ -87,11 +167,32 @@ export function ImageCropDialog({ image, onApply, onCancel, open, policy, render
}
function startDrag(event: React.PointerEvent<HTMLElement>) {
dragPointRef.current = { x: event.clientX, y: event.clientY };
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
event.currentTarget.setPointerCapture?.(event.pointerId);
if (pointersRef.current.size === 1) {
dragPointRef.current = { x: event.clientX, y: event.clientY };
return;
}
const distance = getPinchDistance();
if (distance !== null) {
pinchRef.current = { distance, zoom };
dragPointRef.current = null;
}
}
function drag(event: React.PointerEvent<HTMLElement>) {
if (pointersRef.current.has(event.pointerId)) {
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
}
const pinch = pinchRef.current;
const distance = getPinchDistance();
if (pinch !== null && distance !== null) {
changeZoom(pinch.zoom * (distance / pinch.distance));
return;
}
const dragPoint = dragPointRef.current;
if (dragPoint === null) {
return;
@@ -101,51 +202,78 @@ export function ImageCropDialog({ image, onApply, onCancel, open, policy, render
dragPointRef.current = { x: event.clientX, y: event.clientY };
}
function stopDrag() {
function stopDrag(event: React.PointerEvent<HTMLElement>) {
pointersRef.current.delete(event.pointerId);
event.currentTarget.releasePointerCapture?.(event.pointerId);
pinchRef.current = null;
dragPointRef.current = null;
}
async function applyCrop() {
const file = await renderCrop({
aspect: policy.aspect,
file: image.file,
offsetX,
offsetY,
outputHeight: outputSize.height,
outputWidth: outputSize.width,
previewUrl: image.previewUrl,
sourceHeight: image.height,
sourceWidth: image.width,
zoom,
});
onApply(file);
if (isApplying) {
return;
}
setApplyError(null);
setIsApplying(true);
const currentViewportRect = cropViewportRef.current?.getBoundingClientRect();
const previewRect = previewImageRef.current?.getBoundingClientRect();
const frameRect = currentViewportRect !== undefined && currentViewportRect.width > 0 && currentViewportRect.height > 0 ? currentViewportRect : previewRect;
const cropOffset = clampOffset({ x: offsetX, y: offsetY }, frameRect === undefined ? null : { height: frameRect.height, width: frameRect.width });
try {
const file = await renderCrop({
aspect: policy.aspect,
file: image.file,
offsetX: cropOffset.x,
offsetY: cropOffset.y,
outputHeight: outputSize.height,
outputWidth: outputSize.width,
previewFrameHeight: frameRect?.height,
previewFrameWidth: frameRect?.width,
previewUrl: image.previewUrl,
sourceHeight: image.height,
sourceWidth: image.width,
zoom,
});
onApply(file);
} catch (error: unknown) {
if (!(error instanceof Error)) {
throw error;
}
setApplyError("이미지 crop을 적용하지 못했습니다.");
} finally {
setIsApplying(false);
}
}
return (
<div className="fixed inset-0 z-modal grid place-items-center bg-background/80 p-4">
<section aria-label="이미지 crop" aria-modal="true" className="flex w-full max-w-lg flex-col gap-4 rounded-lg border border-border bg-card p-6" onKeyDown={trapFocus} ref={dialogRef} role="dialog">
<section aria-label="이미지 crop" aria-modal="true" className="flex w-full max-w-lg flex-col gap-4 rounded-lg border border-border bg-card p-6" onKeyDown={handleDialogKeyDown} ref={dialogRef} role="dialog">
<div className="flex flex-col gap-2">
<h2 className="text-xl font-semibold"> crop</h2>
<p className="text-sm text-muted-foreground">, , .</p>
</div>
<div aria-label="이미지 crop 미리보기" className="overflow-hidden rounded-lg border border-border bg-muted p-4" onKeyDown={handleKeyDown} onPointerDown={startDrag} onPointerLeave={stopDrag} onPointerMove={drag} onPointerUp={stopDrag} role="application" tabIndex={0}>
<img alt="선택한 이미지 미리보기" className="mx-auto max-h-64 max-w-full" src={image.previewUrl} style={{ transform: `translate(${offsetX}px, ${offsetY}px) scale(${zoom})` }} />
<div aria-label="이미지 crop 미리보기" className="overflow-hidden rounded-lg border border-border bg-muted p-4" onKeyDown={handlePreviewKeyDown} onPointerCancel={stopDrag} onPointerDown={startDrag} onPointerLeave={stopDrag} onPointerMove={drag} onPointerUp={stopDrag} role="application" style={{ touchAction: "none" }} tabIndex={0}>
<div aria-label="이미지 crop viewport" className="relative mx-auto overflow-hidden rounded-md border border-info/70 bg-background" ref={setCropViewportNode} style={{ aspectRatio: String(cropFrameAspect), width: `${Math.min(16 * cropFrameAspect, 32)}rem`, maxWidth: "100%" }}>
<img alt="선택한 이미지 미리보기" className={`absolute left-1/2 top-1/2 ${coverImageClass}`} ref={previewImageRef} src={image.previewUrl} style={{ transform: `translate(-50%, -50%) translate(${clampedOffset.x}px, ${clampedOffset.y}px) scale(${zoom})` }} />
</div>
</div>
<p className="text-sm font-semibold text-info"> {outputSize.width} × {outputSize.height}px</p>
<div className="grid gap-2 sm:grid-cols-2">
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => move(0, -MOVE_STEP)} type="button"> </button>
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => move(0, MOVE_STEP)} type="button"> </button>
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => move(-MOVE_STEP, 0)} type="button"> </button>
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => move(MOVE_STEP, 0)} type="button"> </button>
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isApplying} onClick={() => move(0, -MOVE_STEP)} type="button"> </button>
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isApplying} onClick={() => move(0, MOVE_STEP)} type="button"> </button>
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isApplying} onClick={() => move(-MOVE_STEP, 0)} type="button"> </button>
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isApplying} onClick={() => move(MOVE_STEP, 0)} type="button"> </button>
</div>
<label className="flex flex-col gap-2 text-sm font-semibold">
<input aria-label="확대 비율" max="3" min="1" onChange={(event) => changeZoom(Number(event.currentTarget.value))} step="0.1" type="range" value={zoom} />
<input aria-label="확대 비율" disabled={isApplying} max="3" min="1" onChange={(event) => changeZoom(Number(event.currentTarget.value))} step="0.1" type="range" value={zoom} />
</label>
{applyError === null ? null : <p className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" role="alert">{applyError}</p>}
{isApplying ? <p className="rounded-md border border-border bg-card p-3 text-sm font-semibold" role="status"> crop을 </p> : null}
<div className="flex justify-end gap-2">
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={resetCrop} type="button"></button>
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={onCancel} type="button"></button>
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={() => void applyCrop()} type="button"></button>
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isApplying} onClick={resetCrop} type="button"></button>
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isApplying} onClick={onCancel} type="button"></button>
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60" disabled={isApplying} onClick={() => void applyCrop()} type="button"></button>
</div>
</section>
</div>

View File

@@ -8,7 +8,7 @@ export function MockModeBanner({ apiMode }: { readonly apiMode: ApiMode }) {
return (
<aside
aria-label="Mock Preview"
className="sticky top-0 z-sticky border-b border-border bg-warning-surface px-4 py-2 text-sm font-semibold text-warning"
className="sticky top-0 z-sticky break-keep border-b border-border bg-warning-surface px-4 py-2 text-sm font-semibold text-warning"
role="status"
>
<span className="mr-2">Mock Preview</span>

View File

@@ -1,4 +1,5 @@
import type { PageData } from "@/shared/api/pagination";
import { useId } from "react";
export type ResourcePaginationProps = {
readonly data: PageData<unknown>;
@@ -8,14 +9,16 @@ export type ResourcePaginationProps = {
};
export function ResourcePagination({ data, onPageChange, onSizeChange, sizeOptions = [20, 50] }: ResourcePaginationProps) {
const pageSizeId = useId();
return (
<nav aria-label="페이지" className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm font-semibold text-muted-foreground"> {data.totalCount.toLocaleString("ko-KR")} · {data.page + 1}</p>
<div className="flex flex-wrap items-center gap-2">
<label className="text-sm font-semibold" htmlFor="resource-page-size">
<label className="text-sm font-semibold" htmlFor={pageSizeId}>
</label>
<select className="rounded-md border border-input bg-card px-3 py-2 text-base" id="resource-page-size" onChange={(event) => onSizeChange(Number(event.currentTarget.value))} value={data.size}>
<select className="rounded-md border border-input bg-card px-3 py-2 text-base" id={pageSizeId} onChange={(event) => onSizeChange(Number(event.currentTarget.value))} value={data.size}>
{sizeOptions.map((size) => (
<option key={size} value={size}>{size}</option>
))}

View File

@@ -1,27 +1,26 @@
import { useEffect, useId, useRef } from "react";
import { useEffect, useId, useRef, useState } from "react";
import type { ReactNode } from "react";
export type SearchToolbarProps = {
readonly filters?: ReactNode;
readonly onQueryChange: (query: string) => void;
readonly onSearchChange: (search: string) => void;
readonly search: string;
};
export function SearchToolbar({ filters, onQueryChange, onSearchChange, search }: SearchToolbarProps) {
export function SearchToolbar({ filters, onQueryChange, search }: SearchToolbarProps) {
const searchId = useId();
const didMountRef = useRef(false);
const hasUserInputRef = useRef(false);
const [draftSearch, setDraftSearch] = useState(search);
useEffect(() => {
if (!didMountRef.current) {
didMountRef.current = true;
if (!hasUserInputRef.current) {
return undefined;
}
const timeoutId = window.setTimeout(() => onQueryChange(search), 300);
const timeoutId = window.setTimeout(() => onQueryChange(draftSearch), 300);
return () => window.clearTimeout(timeoutId);
}, [onQueryChange, search]);
}, [draftSearch, onQueryChange]);
return (
<section aria-label="검색 도구" className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4 sm:flex-row sm:items-end sm:justify-between">
@@ -29,7 +28,16 @@ export function SearchToolbar({ filters, onQueryChange, onSearchChange, search }
<label className="text-sm font-semibold" htmlFor={searchId}>
</label>
<input className="rounded-md border border-input bg-card px-3 py-2 text-base text-foreground" id={searchId} onChange={(event) => onSearchChange(event.currentTarget.value)} type="search" value={search} />
<input
className="rounded-md border border-input bg-card px-3 py-2 text-base text-foreground"
id={searchId}
onChange={(event) => {
hasUserInputRef.current = true;
setDraftSearch(event.currentTarget.value);
}}
type="search"
value={draftSearch}
/>
</div>
{filters === undefined ? null : <div className="flex flex-col gap-2 sm:min-w-48">{filters}</div>}
</section>

View File

@@ -22,8 +22,21 @@ export function validateAudioFile(file: File): AudioFileValidationResult {
}
const extension = getFileExtension(file.name);
const allowedMimeTypes = allowedMimeByExtension[extension as keyof typeof allowedMimeByExtension];
if (allowedMimeTypes === undefined || !allowedMimeTypes.includes(file.type)) {
let allowedMimeTypes: readonly string[];
switch (extension) {
case ".aac":
allowedMimeTypes = allowedMimeByExtension[".aac"];
break;
case ".m4a":
allowedMimeTypes = allowedMimeByExtension[".m4a"];
break;
case ".mp3":
allowedMimeTypes = allowedMimeByExtension[".mp3"];
break;
default:
return { ok: false, reason: "mimeExtensionCombination" };
}
if (!allowedMimeTypes.includes(file.type)) {
return { ok: false, reason: "mimeExtensionCombination" };
}

View File

@@ -0,0 +1,18 @@
import { describe, expect, test } from "vitest";
import { CAN_PRICE_MAX, canPriceSchema, formatCanPriceInput, parseCanPriceInput } from "@/shared/validation/can-price";
describe("canPriceSchema", () => {
test.each([0, CAN_PRICE_MAX])("accepts CAN price boundary %i", (price) => {
expect(canPriceSchema.parse(price)).toBe(price);
});
test.each([-1, CAN_PRICE_MAX + 1, 1.5])("rejects invalid CAN price %i", (price) => {
expect(() => canPriceSchema.parse(price)).toThrow();
});
test.each([["", null, ""], ["0", 0, "0캔"], ["99,999캔", CAN_PRICE_MAX, "99,999캔"], ["-1", null, "-1"], ["1.5", null, "1.5"]] as const)("parses raw CAN price input %s", (raw, parsed, formatted) => {
expect(parseCanPriceInput(raw)).toBe(parsed);
expect(formatCanPriceInput(raw)).toBe(formatted);
});
});

View File

@@ -0,0 +1,20 @@
import { z } from "zod";
export const CAN_PRICE_MAX = 99_999;
export const canPriceSchema = z.number().int().min(0).max(CAN_PRICE_MAX);
export function parseCanPriceInput(value: string): number | null {
const withoutUnit = value.trim().replace(/캔$/, "").trim();
if (withoutUnit.length === 0 || !/^\d+$|^\d{1,3}(,\d{3})+$/.test(withoutUnit)) {
return null;
}
return Number(withoutUnit.replace(/,/g, ""));
}
export function formatCanPriceInput(value: string): string {
const price = parseCanPriceInput(value);
return price === null ? value : `${price.toLocaleString("ko-KR")}`;
}

View File

@@ -36,12 +36,13 @@ test("validateFile lets callers inject a 10MB byte boundary without owning image
expect(validateFile(fileWithSize("profile.jpg", "image/jpeg", tenMegabytes + 1), policy)).toEqual({ ok: false, reason: "size" });
});
test("validateAudioFile accepts MP3, AAC, M4A including audio/x-m4a at 1,024,000,000 bytes", () => {
test("validateAudioFile accepts MP3, AAC, and both M4A MIME variants at 1,024,000,000 bytes", () => {
expect(AUDIO_FILE_POLICY.maxBytes).toBe(1_024_000_000);
expect(validateAudioFile(fileWithSize("voice.mp3", "audio/mpeg", 1_024_000_000))).toEqual({ ok: true });
expect(validateAudioFile(fileWithSize("voice.aac", "audio/aac", 1_024_000_000))).toEqual({ ok: true });
expect(validateAudioFile(fileWithSize("voice.m4a", "audio/mp4", 1_024_000_000))).toEqual({ ok: true });
expect(validateAudioFile(fileWithSize("voice.m4a", "audio/x-m4a", 1_024_000_000))).toEqual({ ok: true });
expect(validateAudioFile(fileWithSize("voice.m4a", "audio/mp4", 1_024_000_000))).toEqual({ ok: true });
expect(validateAudioFile(fileWithSize("VOICE.M4A", "audio/x-m4a", 1_024_000_000))).toEqual({ ok: true });
});
test("validateAudioFile rejects WAV, oversized files, and audio/x-m4a without .m4a without sniffing codecs", async () => {
@@ -55,9 +56,10 @@ test("validateAudioFile rejects WAV, oversized files, and audio/x-m4a without .m
});
test("validateAudioFile rejects mismatched canonical MIME and extension combinations", () => {
expect(validateAudioFile(fileWithSize("voice.mp3", "audio/mp4", 10))).toEqual({ ok: false, reason: "mimeExtensionCombination" });
expect(validateAudioFile(fileWithSize("voice.mp3", "audio/aac", 10))).toEqual({ ok: false, reason: "mimeExtensionCombination" });
expect(validateAudioFile(fileWithSize("voice.aac", "audio/mpeg", 10))).toEqual({ ok: false, reason: "mimeExtensionCombination" });
expect(validateAudioFile(fileWithSize("voice.m4a", "audio/aac", 10))).toEqual({ ok: false, reason: "mimeExtensionCombination" });
expect(validateAudioFile(fileWithSize("voice.mp3", "audio/mp4", 10))).toEqual({ ok: false, reason: "mimeExtensionCombination" });
});
test("createImagePolicy records only a domain-neutral crop contract", () => {

View File

@@ -91,8 +91,8 @@
--border: var(--color-border);
--input: var(--color-input);
--ring: var(--color-brand-800);
--link: var(--color-brand-800);
--link-hover: var(--color-brand-900);
--link: var(--color-brand-900);
--link-hover: var(--color-brand-950);
--info: var(--color-brand-900);
--success: var(--color-success);
--success-surface: var(--color-success-surface);

View File

@@ -28,6 +28,7 @@ export default defineConfig(({ mode }) => {
setupFiles: ["./src/shared/test/setup.ts"],
globals: true,
exclude: [...configDefaults.exclude, "tests/e2e/**"],
fileParallelism: false,
},
};
});