diff --git a/.gitignore b/.gitignore index a70dc92..4526435 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ .idea .codex .opencode +.DS_Store mise.toml diff --git a/package.json b/package.json index 843b17e..90f22f3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.config.ts b/playwright.config.ts index 18462c0..c9337af 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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"] } }, ], }); diff --git a/src/app/App.logout.test.tsx b/src/app/App.logout.test.tsx new file mode 100644 index 0000000..02a2b2f --- /dev/null +++ b/src/app/App.logout.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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"); +}); diff --git a/src/app/App.protected-errors.test.tsx b/src/app/App.protected-errors.test.tsx new file mode 100644 index 0000000..c2afdf7 --- /dev/null +++ b/src/app/App.protected-errors.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + 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(); + 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(); + + 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(); + + 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(); +}); diff --git a/src/app/App.protected-shell.test.tsx b/src/app/App.protected-shell.test.tsx new file mode 100644 index 0000000..13b002c --- /dev/null +++ b/src/app/App.protected-shell.test.tsx @@ -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(); + + 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(); + 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(); + 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(); + 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(); + + 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(); + 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(); + 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(); + + 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(); + + 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(); +}); diff --git a/src/app/App.test.tsx b/src/app/App.test.tsx index 3924488..3048337 100644 --- a/src/app/App.test.tsx +++ b/src/app/App.test.tsx @@ -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(); - 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(); - // 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(); - // 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(); + + 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(); + + 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(); - 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(); - 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(); - - // 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(); - 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(); - 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(); - - 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(); - - 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(); - - 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(); - - // 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(); - - 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(); - - // 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(); - - 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(); - 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(); - 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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"); }); diff --git a/src/app/App.tsx b/src/app/App.tsx index 793063d..e7e7825 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -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(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 ( - + ); } diff --git a/src/app/admin-pages.tsx b/src/app/admin-pages.tsx index fe7454f..b7ce1ba 100644 --- a/src/app/admin-pages.tsx +++ b/src/app/admin-pages.tsx @@ -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() { ); } - -export function AiCharactersPage({ routeError }: { readonly routeError: string | null }) { - return ( - - - AI CHARACTER ADMIN - - AI 캐릭터 - - 캐릭터 목록과 생성 흐름은 Phase 3에서 연결합니다. - - {routeError === null ? null : ( - - {routeError} - - )} - - - ); -} diff --git a/src/app/app-test-support.ts b/src/app/app-test-support.ts new file mode 100644 index 0000000..2637b19 --- /dev/null +++ b/src/app/app-test-support.ts @@ -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; +} diff --git a/src/app/browser-location.test.ts b/src/app/browser-location.test.ts new file mode 100644 index 0000000..a6aaf15 --- /dev/null +++ b/src/app/browser-location.test.ts @@ -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); + } +}); diff --git a/src/app/browser-location.ts b/src/app/browser-location.ts index e7c4333..b28f3d2 100644 --- a/src/app/browser-location.ts +++ b/src/app/browser-location.ts @@ -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\/(?[^/]+)\/series\/(?[^/]+)$/.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\/(?[^/]+)\/series\/(?[^/]+)\/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\/(?[^/]+)\/audio-contents\/(?[^/]+)$/.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\/(?[^/]+)\/audio-contents\/(?[^/]+)\/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")); } diff --git a/src/app/protected-admin-shell.tsx b/src/app/protected-admin-shell.tsx index 2575f57..8b1adf6 100644 --- a/src/app/protected-admin-shell.tsx +++ b/src/app/protected-admin-shell.tsx @@ -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(null); const closeButtonRef = useRef(null); const mobileMenuRef = useRef(null); + const mainRef = useRef(null); const shouldRestoreMenuFocusRef = useRef(false); useEffect(() => { @@ -117,7 +154,10 @@ export function ProtectedAdminShell({ apiMode, routeError }: { readonly apiMode: - + { + event.preventDefault(); + mainRef.current?.focus(); + }}> 본문으로 건너뛰기 diff --git a/src/app/route-paths.ts b/src/app/route-paths.ts index cd9e54a..349a085 100644 --- a/src/app/route-paths.ts +++ b/src/app/route-paths.ts @@ -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; diff --git a/src/features/auth/tests/auth-api.test.ts b/src/features/auth/tests/auth-api.test.ts index 754c1c9..c14fbe2 100644 --- a/src/features/auth/tests/auth-api.test.ts +++ b/src/features/auth/tests/auth-api.test.ts @@ -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 () => { diff --git a/src/layouts/CharacterWorkspaceLayout.test.tsx b/src/layouts/CharacterWorkspaceLayout.test.tsx new file mode 100644 index 0000000..197c4b8 --- /dev/null +++ b/src/layouts/CharacterWorkspaceLayout.test.tsx @@ -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(); + + // 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(); + + // 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(); + + // Then + await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent("상세 조회 실패")); + expect(screen.queryByText("비활성 캐릭터는 읽기 전용입니다.")).not.toBeInTheDocument(); +}); diff --git a/src/layouts/CharacterWorkspaceLayout.tsx b/src/layouts/CharacterWorkspaceLayout.tsx new file mode 100644 index 0000000..fa5c7fd --- /dev/null +++ b/src/layouts/CharacterWorkspaceLayout.tsx @@ -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 ( + + + + AI 캐릭터 + / + {character.name} + + + + + {character.imageUrl === null ? ( + {character.name.slice(0, 1)} + ) : ( + + )} + + CHARACTER WORKSPACE + + {character.name} + + + characterId: {character.id} + + + + {character.isActive ? null : ( + + 비활성 캐릭터는 읽기 전용입니다. + 비활성 상태에서는 조회만 가능하며 생성, 수정, 비활성화 같은 쓰기 작업은 실행되지 않습니다. 현재 화면에서는 복원 기능을 지원하지 않습니다. 영구 삭제와 삭제 후 복구도 제공하지 않습니다. + + )} + + {workspaceNavigationItems.map((item) => ( + { event.preventDefault(); navigateTo(item.path); }}>{item.label} + ))} + + + {children} + + + ); +} diff --git a/src/shared/api/__tests__/client-auth.test.ts b/src/shared/api/__tests__/client-auth.test.ts index 288136a..ebf1770 100644 --- a/src/shared/api/__tests__/client-auth.test.ts +++ b/src/shared/api/__tests__/client-auth.test.ts @@ -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); diff --git a/src/shared/api/__tests__/client.test.ts b/src/shared/api/__tests__/client.test.ts index ee25080..4c841e3 100644 --- a/src/shared/api/__tests__/client.test.ts +++ b/src/shared/api/__tests__/client.test.ts @@ -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 }); }); }); diff --git a/src/shared/api/__tests__/pagination.test.ts b/src/shared/api/__tests__/pagination.test.ts index 29ae289..1e880e7 100644 --- a/src/shared/api/__tests__/pagination.test.ts +++ b/src/shared/api/__tests__/pagination.test.ts @@ -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 = { diff --git a/src/shared/api/api-error.ts b/src/shared/api/api-error.ts index c81f2cc..a1c2673 100644 --- a/src/shared/api/api-error.ts +++ b/src/shared/api/api-error.ts @@ -1,3 +1,5 @@ +export const UNKNOWN_API_ERROR_MESSAGE = "알 수 없는 오류가 발생했습니다."; + export type ApiErrorOptions = { readonly status: number; readonly message: string; diff --git a/src/shared/api/client.ts b/src/shared/api/client.ts index fb73c82..43ad46d 100644 --- a/src/shared/api/client.ts +++ b/src/shared/api/client.ts @@ -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; diff --git a/src/shared/api/pagination.ts b/src/shared/api/pagination.ts index f749d5d..a32184f 100644 --- a/src/shared/api/pagination.ts +++ b/src/shared/api/pagination.ts @@ -5,20 +5,3 @@ export type PageData = { 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), - }; -} diff --git a/src/shared/lib/create-image-crop-source.test.ts b/src/shared/lib/create-image-crop-source.test.ts new file mode 100644 index 0000000..032185e --- /dev/null +++ b/src/shared/lib/create-image-crop-source.test.ts @@ -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"); +}); diff --git a/src/shared/lib/create-image-crop-source.ts b/src/shared/lib/create-image-crop-source.ts new file mode 100644 index 0000000..91439ed --- /dev/null +++ b/src/shared/lib/create-image-crop-source.ts @@ -0,0 +1,22 @@ +import type { CropSourceImage } from "@/shared/ui/image-crop-dialog"; + +export function createImageCropSource(file: File): Promise { + 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; + }); +} diff --git a/src/shared/lib/crop-image.test.ts b/src/shared/lib/crop-image.test.ts index 3318dc8..c6b4ae0 100644 --- a/src/shared/lib/crop-image.test.ts +++ b/src/shared/lib/crop-image.test.ts @@ -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"); diff --git a/src/shared/lib/crop-image.ts b/src/shared/lib/crop-image.ts index 83d1427..19941f9 100644 --- a/src/shared/lib/crop-image.ts +++ b/src/shared/lib/crop-image.ts @@ -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 { diff --git a/src/shared/lib/focus-first-invalid-control.test.ts b/src/shared/lib/focus-first-invalid-control.test.ts new file mode 100644 index 0000000..14f35ff --- /dev/null +++ b/src/shared/lib/focus-first-invalid-control.test.ts @@ -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 = ` + + + + + + `; + + const form = document.querySelector("form"); + + // When + focusFirstInvalidControl(form); + + // Then + expect(document.activeElement).toBe(document.querySelectorAll("input, textarea")[1]); +}); diff --git a/src/shared/lib/focus-first-invalid-control.ts b/src/shared/lib/focus-first-invalid-control.ts new file mode 100644 index 0000000..29b6cb0 --- /dev/null +++ b/src/shared/lib/focus-first-invalid-control.ts @@ -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; + } +} diff --git a/src/shared/mocks/__tests__/auth-handlers.test.ts b/src/shared/mocks/__tests__/auth-handlers.test.ts index 4a72aed..ea8bf7b 100644 --- a/src/shared/mocks/__tests__/auth-handlers.test.ts +++ b/src/shared/mocks/__tests__/auth-handlers.test.ts @@ -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, }); }); diff --git a/src/shared/mocks/__tests__/character-handlers.test.ts b/src/shared/mocks/__tests__/character-handlers.test.ts new file mode 100644 index 0000000..d62cf75 --- /dev/null +++ b/src/shared/mocks/__tests__/character-handlers.test.ts @@ -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 | 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" }); + }); +}); diff --git a/src/shared/mocks/__tests__/mock-preview-docs.test.ts b/src/shared/mocks/__tests__/mock-preview-docs.test.ts index 852573e..a2810ea 100644 --- a/src/shared/mocks/__tests__/mock-preview-docs.test.ts +++ b/src/shared/mocks/__tests__/mock-preview-docs.test.ts @@ -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"); diff --git a/src/shared/mocks/__tests__/mode-boundary.test.ts b/src/shared/mocks/__tests__/mode-boundary.test.ts index 7684e0a..0e6f192 100644 --- a/src/shared/mocks/__tests__/mode-boundary.test.ts +++ b/src/shared/mocks/__tests__/mode-boundary.test.ts @@ -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); diff --git a/src/shared/mocks/audio-content-fixtures.ts b/src/shared/mocks/audio-content-fixtures.ts new file mode 100644 index 0000000..1946136 --- /dev/null +++ b/src/shared/mocks/audio-content-fixtures.ts @@ -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[]; diff --git a/src/shared/mocks/audio-content-handlers.ts b/src/shared/mocks/audio-content-handlers.ts new file mode 100644 index 0000000..d01b0d3 --- /dev/null +++ b/src/shared/mocks/audio-content-handlers.ts @@ -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; + readonly parseDeactivateRequest: (request: Request) => Promise; + readonly parseUpdateRequest: (request: Request) => Promise; +}; + +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)); + }), + ]; +} diff --git a/src/shared/mocks/audio-content-mock-store.ts b/src/shared/mocks/audio-content-mock-store.ts new file mode 100644 index 0000000..c38e9a0 --- /dev/null +++ b/src/shared/mocks/audio-content-mock-store.ts @@ -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 { + 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 { + 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 { + 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(?.*?)\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], + }; +} diff --git a/src/shared/mocks/browser.test.ts b/src/shared/mocks/browser.test.ts index b7d458b..eb3d8e1 100644 --- a/src/shared/mocks/browser.test.ts +++ b/src/shared/mocks/browser.test.ts @@ -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); }); }); diff --git a/src/shared/mocks/browser.ts b/src/shared/mocks/browser.ts index 6ec6df9..b9f4fcf 100644 --- a/src/shared/mocks/browser.ts +++ b/src/shared/mocks/browser.ts @@ -7,5 +7,5 @@ let worker: ReturnType | null = null; export async function startMockWorker(): Promise { worker ??= setupWorker(...createMockHandlers(createMockStore(), getRuntimeEnv().apiBaseUrl)); - await worker.start({ onUnhandledRequest: "error" }); + await worker.start({ onUnhandledRequest: "error", quiet: true }); } diff --git a/src/shared/mocks/character-fixtures.ts b/src/shared/mocks/character-fixtures.ts new file mode 100644 index 0000000..4f38f09 --- /dev/null +++ b/src/shared/mocks/character-fixtures.ts @@ -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; diff --git a/src/shared/mocks/character-mock-store.ts b/src/shared/mocks/character-mock-store.ts new file mode 100644 index 0000000..e2f13a3 --- /dev/null +++ b/src/shared/mocks/character-mock-store.ts @@ -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; + +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 { + try { + const body = await request.text(); + const match = /name="request"(?:; filename="[^"]*")?\r\nContent-Type: application\/json\r\n\r\n(?.*?)\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, + }; +} diff --git a/src/shared/mocks/comment-fixtures.ts b/src/shared/mocks/comment-fixtures.ts new file mode 100644 index 0000000..06ebe2b --- /dev/null +++ b/src/shared/mocks/comment-fixtures.ts @@ -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[]; diff --git a/src/shared/mocks/comment-handlers.ts b/src/shared/mocks/comment-handlers.ts new file mode 100644 index 0000000..5071ac2 --- /dev/null +++ b/src/shared/mocks/comment-handlers.ts @@ -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; + readonly parseCommunityCreateRequest: (request: Request) => Promise; + readonly parseUpdateRequest: (request: Request) => Promise; +}; + +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)); + }), + ]; +} diff --git a/src/shared/mocks/comment-mock-store.ts b/src/shared/mocks/comment-mock-store.ts new file mode 100644 index 0000000..2c1825a --- /dev/null +++ b/src/shared/mocks/comment-mock-store.ts @@ -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 { + return parseJsonRequest(request, audioCommentCreateRequestSchema); +} + +export async function parseCommunityCommentCreateRequest(request: Request): Promise { + return parseJsonRequest(request, communityCommentCreateRequestSchema); +} + +export async function parseCommentUpdateRequest(request: Request): Promise { + return parseJsonRequest(request, commentUpdateRequestSchema); +} + +async function parseJsonRequest(request: Request, schema: z.ZodType): Promise { + 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 }; +} diff --git a/src/shared/mocks/community-post-fixtures.ts b/src/shared/mocks/community-post-fixtures.ts new file mode 100644 index 0000000..7acf612 --- /dev/null +++ b/src/shared/mocks/community-post-fixtures.ts @@ -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[]; diff --git a/src/shared/mocks/community-post-handlers.ts b/src/shared/mocks/community-post-handlers.ts new file mode 100644 index 0000000..42a0180 --- /dev/null +++ b/src/shared/mocks/community-post-handlers.ts @@ -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; + readonly parseUpdateRequest: (request: Request) => Promise; +}; + +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)); + }), + ]; +} diff --git a/src/shared/mocks/community-post-mock-store.ts b/src/shared/mocks/community-post-mock-store.ts new file mode 100644 index 0000000..e911d3b --- /dev/null +++ b/src/shared/mocks/community-post-mock-store.ts @@ -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 { + 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 { + 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 { + 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 { + return typeof part === "string" ? part : part.text(); +} + +function toMutableCommunityPost(post: (typeof mockCommunityPostListItems)[number]): CommunityPostListItem { + return { + ...post, + firstComment: post.firstComment === null ? null : { ...post.firstComment }, + }; +} diff --git a/src/shared/mocks/fan-talk-fixtures.ts b/src/shared/mocks/fan-talk-fixtures.ts new file mode 100644 index 0000000..d489466 --- /dev/null +++ b/src/shared/mocks/fan-talk-fixtures.ts @@ -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[]; diff --git a/src/shared/mocks/fan-talk-handlers.ts b/src/shared/mocks/fan-talk-handlers.ts new file mode 100644 index 0000000..a78f25d --- /dev/null +++ b/src/shared/mocks/fan-talk-handlers.ts @@ -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 { + 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)); + }), + ]; +} diff --git a/src/shared/mocks/fan-talk-mock-store.ts b/src/shared/mocks/fan-talk-mock-store.ts new file mode 100644 index 0000000..1578c3e --- /dev/null +++ b/src/shared/mocks/fan-talk-mock-store.ts @@ -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 })), + }; +} diff --git a/src/shared/mocks/handlers.ts b/src/shared/mocks/handlers.ts index 4aa05e4..6dbfb3d 100644 --- a/src/shared/mocks/handlers.ts +++ b/src/shared/mocks/handlers.ts @@ -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(); activate(token: string): void { @@ -45,6 +47,26 @@ class MockStore { return "unauthorized"; } + + listRootComments(target: Parameters[0], page: number, size: number) { + return this.#commentStore.listRootComments(target, page, size); + } + + listReplies(target: Parameters[0], commentId: number, page: number, size: number) { + return this.#commentStore.listReplies(target, commentId, page, size); + } + + createComment(target: Parameters[0], request: Parameters[1]): boolean { + return this.#commentStore.createComment(target, request); + } + + updateComment(target: Parameters[0], commentId: number, request: Parameters[2]): boolean { + return this.#commentStore.updateComment(target, commentId, request); + } + + deleteComment(target: Parameters[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)); }), ]; } diff --git a/src/shared/mocks/series-fixtures.ts b/src/shared/mocks/series-fixtures.ts new file mode 100644 index 0000000..5e31efd --- /dev/null +++ b/src/shared/mocks/series-fixtures.ts @@ -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; diff --git a/src/shared/mocks/series-handlers.ts b/src/shared/mocks/series-handlers.ts new file mode 100644 index 0000000..d19eb11 --- /dev/null +++ b/src/shared/mocks/series-handlers.ts @@ -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; + readonly parseUpdateRequest: (request: Request) => Promise; +}; + +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(request: Request, schema: z.ZodType): Promise { + 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)); + }), + ]; +} diff --git a/src/shared/mocks/series-mock-store.ts b/src/shared/mocks/series-mock-store.ts new file mode 100644 index 0000000..8428454 --- /dev/null +++ b/src/shared/mocks/series-mock-store.ts @@ -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([["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(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 { + return parseSeriesMutationRequest(request, seriesCreateRequestSchema); +} + +export async function parseSeriesUpdateRequest(request: Request): Promise { + return parseSeriesMutationRequest(request, z.union([seriesDeactivateRequestSchema, seriesUpdateRequestSchema])); +} + +async function parseSeriesMutationRequest(request: Request, schema: z.ZodType): 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; + } +} diff --git a/src/shared/ui/__tests__/admin-audio-player.test.tsx b/src/shared/ui/__tests__/admin-audio-player.test.tsx index 02f68df..7d561be 100644 --- a/src/shared/ui/__tests__/admin-audio-player.test.tsx +++ b/src/shared/ui/__tests__/admin-audio-player.test.tsx @@ -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(); }); diff --git a/src/shared/ui/__tests__/confirm-deactivate-dialog.test.tsx b/src/shared/ui/__tests__/confirm-deactivate-dialog.test.tsx index 6d32351..1e4ed08 100644 --- a/src/shared/ui/__tests__/confirm-deactivate-dialog.test.tsx +++ b/src/shared/ui/__tests__/confirm-deactivate-dialog.test.tsx @@ -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( + , + ); + + 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( + , + ); + 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); diff --git a/src/shared/ui/__tests__/file-field.test.tsx b/src/shared/ui/__tests__/file-field.test.tsx index 2bdf5ca..585f895 100644 --- a/src/shared/ui/__tests__/file-field.test.tsx +++ b/src/shared/ui/__tests__/file-field.test.tsx @@ -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( + <> + + + >, + ); + + expect(screen.getAllByText("파일 선택")).toHaveLength(2); + expect(screen.getByRole("button", { name: "오디오 파일 파일 선택" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "커버 이미지 파일 선택" })).toBeInTheDocument(); +}); diff --git a/src/shared/ui/__tests__/image-crop-dialog.test.tsx b/src/shared/ui/__tests__/image-crop-dialog.test.tsx index 8b82b86..0437b68 100644 --- a/src/shared/ui/__tests__/image-crop-dialog.test.tsx +++ b/src/shared/ui/__tests__/image-crop-dialog.test.tsx @@ -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): Promise { + 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(); + + 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(); + + 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(); + + 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((_resolve, reject) => { + rejectCrop = reject; + })); + + render(); + + 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(); + + 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(); + + 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( + { + if (event.key === "Escape") { + onParentEscape(); + } + }}> + + , + ); + + 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(); 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 }); diff --git a/src/shared/ui/__tests__/mock-mode-banner.test.tsx b/src/shared/ui/__tests__/mock-mode-banner.test.tsx index 9a633d2..17b028b 100644 --- a/src/shared/ui/__tests__/mock-mode-banner.test.tsx +++ b/src/shared/ui/__tests__/mock-mode-banner.test.tsx @@ -9,7 +9,9 @@ describe("MockModeBanner", () => { render(); // 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", () => { diff --git a/src/shared/ui/__tests__/resource-pagination.test.tsx b/src/shared/ui/__tests__/resource-pagination.test.tsx index e58e5d2..14e444b 100644 --- a/src/shared/ui/__tests__/resource-pagination.test.tsx +++ b/src/shared/ui/__tests__/resource-pagination.test.tsx @@ -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( + <> + + + >, + ); + + 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); +}); diff --git a/src/shared/ui/__tests__/search-toolbar.test.tsx b/src/shared/ui/__tests__/search-toolbar.test.tsx index 48e4a51..8b0f8af 100644 --- a/src/shared/ui/__tests__/search-toolbar.test.tsx +++ b/src/shared/ui/__tests__/search-toolbar.test.tsx @@ -7,33 +7,33 @@ afterEach(() => { vi.useRealTimers(); }); +test("SearchToolbar does not emit a query before user input", () => { + vi.useFakeTimers(); + const onQueryChange = vi.fn(); + render(); + + 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( 전체} 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( - 전체} - onQueryChange={onQueryChange} - onSearchChange={onSearchChange} - search="루나" - />, - ); act(() => vi.advanceTimersByTime(299)); expect(onQueryChange).not.toHaveBeenCalled(); diff --git a/src/shared/ui/admin-audio-player.tsx b/src/shared/ui/admin-audio-player.tsx index 20ed21a..8fa7471 100644 --- a/src/shared/ui/admin-audio-player.tsx +++ b/src/shared/ui/admin-audio-player.tsx @@ -87,7 +87,7 @@ export function AdminAudioPlayer({ playerId, src, title }: AdminAudioPlayerProps } return ( - + setDuration(event.currentTarget.duration)} @@ -118,7 +118,7 @@ export function AdminAudioPlayer({ playerId, src, title }: AdminAudioPlayerProps 볼륨 - changeVolume(Number(event.currentTarget.value))} step="0.05" type="range" /> + changeVolume(Number(event.currentTarget.value))} step="0.05" type="range" /> 재생 속도 @@ -131,8 +131,8 @@ export function AdminAudioPlayer({ playerId, src, title }: AdminAudioPlayerProps {hasError ? ( - - 오디오를 재생할 수 없습니다. 페이지 새로고침 후 다시 시도하세요. + + 오디오를 재생할 수 없습니다. 페이지 새로고침 후 다시 시도하세요. 오디오 다시 시도 ) : null} diff --git a/src/shared/ui/confirm-deactivate-dialog.tsx b/src/shared/ui/confirm-deactivate-dialog.tsx index 5196bd3..a3eb50b 100644 --- a/src/shared/ui/confirm-deactivate-dialog.tsx +++ b/src/shared/ui/confirm-deactivate-dialog.tsx @@ -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(open); if (!open) { @@ -19,17 +22,18 @@ export function ConfirmDeactivateDialog({ impactDescription, onCancel, onConfirm return ( - + {title} {impactDescription} + {errorMessage === undefined ? null : {errorMessage}} - + 취소 - - 비활성화 + + {isPending ? "처리 중" : confirmLabel} diff --git a/src/shared/ui/file-field.tsx b/src/shared/ui/file-field.tsx index 63572e6..e4c1aa2 100644 --- a/src/shared/ui/file-field.tsx +++ b/src/shared/ui/file-field.tsx @@ -35,7 +35,8 @@ export function FileField({ accept, acceptDescription, description, error, label {label} {description === undefined ? null : {description}} {acceptDescription} - + + inputRef.current?.click()} type="button">파일 선택 {value === null ? "선택된 파일 없음" : value.name} {value === null ? null : ( diff --git a/src/shared/ui/image-crop-dialog.tsx b/src/shared/ui/image-crop-dialog.tsx index 3680eeb..a79f61f 100644 --- a/src/shared/ui/image-crop-dialog.tsx +++ b/src/shared/ui/image-crop-dialog.tsx @@ -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(null); + const [isApplying, setIsApplying] = useState(false); + const [viewportSize, setViewportSize] = useState(null); const [zoom, setZoom] = useState(1); const dragPointRef = useRef<{ readonly x: number; readonly y: number } | null>(null); + const pinchRef = useRef(null); + const cropViewportRef = useRef(null); + const previewImageRef = useRef(null); + const pointersRef = useRef(new Map()); const { dialogRef, trapFocus } = useModalFocus(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) { + 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) { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + onCancel(); + return; + } + + trapFocus(event); + } + + function handlePreviewKeyDown(event: React.KeyboardEvent) { 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) { - 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) { + 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) { + 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 ( - + 이미지 crop 버튼, 범위 입력, 방향키로 위치와 확대를 조정한 뒤 적용합니다. - - + + + + 예상 결과 {outputSize.width} × {outputSize.height}px - move(0, -MOVE_STEP)} type="button">위로 이동 - move(0, MOVE_STEP)} type="button">아래로 이동 - move(-MOVE_STEP, 0)} type="button">왼쪽으로 이동 - move(MOVE_STEP, 0)} type="button">오른쪽으로 이동 + move(0, -MOVE_STEP)} type="button">위로 이동 + move(0, MOVE_STEP)} type="button">아래로 이동 + move(-MOVE_STEP, 0)} type="button">왼쪽으로 이동 + move(MOVE_STEP, 0)} type="button">오른쪽으로 이동 확대 비율 - changeZoom(Number(event.currentTarget.value))} step="0.1" type="range" value={zoom} /> + changeZoom(Number(event.currentTarget.value))} step="0.1" type="range" value={zoom} /> + {applyError === null ? null : {applyError}} + {isApplying ? 이미지 crop을 적용하는 중 : null} - 초기화 - 취소 - void applyCrop()} type="button">적용 + 초기화 + 취소 + void applyCrop()} type="button">적용 diff --git a/src/shared/ui/mock-mode-banner.tsx b/src/shared/ui/mock-mode-banner.tsx index e5814b9..eaf9499 100644 --- a/src/shared/ui/mock-mode-banner.tsx +++ b/src/shared/ui/mock-mode-banner.tsx @@ -8,7 +8,7 @@ export function MockModeBanner({ apiMode }: { readonly apiMode: ApiMode }) { return ( diff --git a/src/shared/validation/audio-file-policy.ts b/src/shared/validation/audio-file-policy.ts index d44600d..d81cbca 100644 --- a/src/shared/validation/audio-file-policy.ts +++ b/src/shared/validation/audio-file-policy.ts @@ -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" }; } diff --git a/src/shared/validation/can-price.test.ts b/src/shared/validation/can-price.test.ts new file mode 100644 index 0000000..a50da84 --- /dev/null +++ b/src/shared/validation/can-price.test.ts @@ -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); + }); +}); diff --git a/src/shared/validation/can-price.ts b/src/shared/validation/can-price.ts new file mode 100644 index 0000000..1a52d8c --- /dev/null +++ b/src/shared/validation/can-price.ts @@ -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")}캔`; +} diff --git a/src/shared/validation/file-media-policy.test.ts b/src/shared/validation/file-media-policy.test.ts index 0910608..8caa3c4 100644 --- a/src/shared/validation/file-media-policy.test.ts +++ b/src/shared/validation/file-media-policy.test.ts @@ -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", () => { diff --git a/src/styles/globals.css b/src/styles/globals.css index 209bfb9..c38e5cb 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -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); diff --git a/vite.config.ts b/vite.config.ts index f794826..2f35b07 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -28,6 +28,7 @@ export default defineConfig(({ mode }) => { setupFiles: ["./src/shared/test/setup.ts"], globals: true, exclude: [...configDefaults.exclude, "tests/e2e/**"], + fileParallelism: false, }, }; });
AI CHARACTER ADMIN
캐릭터 목록과 생성 흐름은 Phase 3에서 연결합니다.
- {routeError} -
CHARACTER WORKSPACE
characterId: {character.id}
비활성 상태에서는 조회만 가능하며 생성, 수정, 비활성화 같은 쓰기 작업은 실행되지 않습니다. 현재 화면에서는 복원 기능을 지원하지 않습니다. 영구 삭제와 삭제 후 복구도 제공하지 않습니다.
오디오를 재생할 수 없습니다. 페이지 새로고침 후 다시 시도하세요.
{impactDescription}
{errorMessage}
{description}
{acceptDescription}
버튼, 범위 입력, 방향키로 위치와 확대를 조정한 뒤 적용합니다.
예상 결과 {outputSize.width} × {outputSize.height}px
{applyError}
이미지 crop을 적용하는 중