feat(ai-character): 리소스 관리 기반 정비
This commit is contained in:
101
src/app/App.logout.test.tsx
Normal file
101
src/app/App.logout.test.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved } from "@testing-library/react";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { App } from "./App";
|
||||
import { apiBaseUrl, saveAdminSession, useAiCharactersFailure, useAiCharactersResponse } from "./app-test-support";
|
||||
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
|
||||
import { server } from "@/shared/test/server";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
window.history.replaceState({}, "", "/");
|
||||
});
|
||||
|
||||
test("clears a previous protected route verification before reusing the same token after login", async () => {
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse();
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
server.use(
|
||||
http.post(`${apiBaseUrl}/member/logout`, () => HttpResponse.json({ success: true, message: null, data: {}, errorProperty: null })),
|
||||
http.post(`${apiBaseUrl}/admin/member/login`, () => HttpResponse.json({ success: true, message: null, data: { token: "admin-token", role: "ADMIN" }, errorProperty: null })),
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "로그아웃" }));
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/login"));
|
||||
useAiCharactersFailure(404);
|
||||
fireEvent.change(screen.getByLabelText("이메일"), { target: { value: "admin@test.com" } });
|
||||
fireEvent.change(screen.getByLabelText("비밀번호"), { target: { value: "password" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "로그인" }));
|
||||
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters"));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("없습니다.");
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows a login warning when server logout confirmation fails", async () => {
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse();
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
server.use(http.post(`${apiBaseUrl}/member/logout`, () => HttpResponse.json({ success: false, message: "로그아웃 확인 실패", data: null, errorProperty: null }, { status: 500 })));
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "로그아웃" }));
|
||||
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/login"));
|
||||
expect(authSessionStorage.read()).toBeNull();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("서버 로그아웃 확인에 실패했습니다.");
|
||||
});
|
||||
|
||||
test("logs out from the protected shell and routes to login", async () => {
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse();
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
server.use(http.post(`${apiBaseUrl}/member/logout`, ({ request }) => {
|
||||
expect(request.headers.get("Authorization")).toBe("Bearer admin-token");
|
||||
return HttpResponse.json({ success: true, message: null, data: {}, errorProperty: null });
|
||||
}));
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "로그아웃" }));
|
||||
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/login"));
|
||||
expect(authSessionStorage.read()).toBeNull();
|
||||
});
|
||||
|
||||
test("sends only one logout request while the first logout is in flight", async () => {
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse();
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
let logoutCount = 0;
|
||||
let resolveLogoutReady: (finishLogout: () => void) => void = () => undefined;
|
||||
const logoutReady = new Promise<() => void>((resolve) => {
|
||||
resolveLogoutReady = resolve;
|
||||
});
|
||||
server.use(http.post(`${apiBaseUrl}/member/logout`, () => {
|
||||
logoutCount += 1;
|
||||
return new Promise((resolve) => {
|
||||
resolveLogoutReady(() => resolve(HttpResponse.json({ success: true, message: null, data: {}, errorProperty: null })));
|
||||
});
|
||||
}));
|
||||
|
||||
render(<App />);
|
||||
|
||||
const logoutButton = await screen.findByRole("button", { name: "로그아웃" });
|
||||
fireEvent.click(logoutButton);
|
||||
fireEvent.click(logoutButton);
|
||||
await waitFor(() => expect(logoutCount).toBe(1));
|
||||
const finishLogout = await logoutReady;
|
||||
finishLogout();
|
||||
|
||||
await waitForElementToBeRemoved(logoutButton);
|
||||
expect(window.location.pathname).toBe("/login");
|
||||
});
|
||||
229
src/app/App.protected-errors.test.tsx
Normal file
229
src/app/App.protected-errors.test.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { App } from "./App";
|
||||
import { apiBaseUrl, saveAdminSession, useAiCharactersFailure, useAiCharactersResponse } from "./app-test-support";
|
||||
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
|
||||
import { UNKNOWN_API_ERROR_MESSAGE } from "@/shared/api/api-error";
|
||||
import { server } from "@/shared/test/server";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
window.history.replaceState({}, "", "/");
|
||||
});
|
||||
|
||||
test("clears the session and routes to login when the protected route request returns 401", async () => {
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse(401);
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/login"));
|
||||
expect(authSessionStorage.read()).toBeNull();
|
||||
expect(screen.getByRole("heading", { name: "관리자 로그인" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("세션이 만료되었습니다. 다시 로그인하세요.");
|
||||
});
|
||||
|
||||
test("routes to access denied without clearing the session when the protected route request returns 403", async () => {
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse(403);
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/access-denied"));
|
||||
expect(authSessionStorage.read()).toEqual({ token: "admin-token", role: "ADMIN" });
|
||||
expect(screen.getByRole("heading", { name: "접근 권한이 없습니다" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("루나")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test.each(["/ai-characters/new", "/ai-characters/101/edit"])("routes %s to access denied before rendering the form when the protected route request returns 403", async (path) => {
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse(403);
|
||||
window.history.pushState({}, "", path);
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/access-denied"));
|
||||
expect(authSessionStorage.read()).toEqual({ token: "admin-token", role: "ADMIN" });
|
||||
expect(screen.getByRole("heading", { name: "접근 권한이 없습니다" })).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("이름")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test.each(["/ai-characters/new", "/ai-characters/101/edit"])("routes %s to login before rendering the form when the protected route request returns 401", async (path) => {
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse(401);
|
||||
window.history.pushState({}, "", path);
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/login"));
|
||||
expect(authSessionStorage.read()).toBeNull();
|
||||
expect(screen.getByRole("heading", { name: "관리자 로그인" })).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("이름")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("keeps the mock mode banner visible on the access denied page", async () => {
|
||||
vi.stubEnv("VITE_API_MODE", "mock");
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse(403);
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/access-denied"));
|
||||
expect(screen.getByRole("heading", { name: "접근 권한이 없습니다" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("status", { name: "Mock Preview" })).toHaveTextContent("Mock Preview");
|
||||
});
|
||||
|
||||
test("keeps the protected shell hidden when the protected route request returns 404", async () => {
|
||||
saveAdminSession();
|
||||
useAiCharactersFailure(404);
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("없습니다.");
|
||||
expect(screen.queryByRole("banner")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("keeps the mock mode banner visible on protected route errors", async () => {
|
||||
vi.stubEnv("VITE_API_MODE", "mock");
|
||||
saveAdminSession();
|
||||
useAiCharactersFailure(404);
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("없습니다.");
|
||||
expect(screen.getByRole("status", { name: "Mock Preview" })).toHaveTextContent("Mock Preview");
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("keeps the protected shell hidden when the protected route request has a network error", async () => {
|
||||
saveAdminSession();
|
||||
useAiCharactersFailure("network");
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(UNKNOWN_API_ERROR_MESSAGE);
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("retries a protected route 404 and reveals the shell only after the current retry succeeds", async () => {
|
||||
saveAdminSession();
|
||||
let requestCount = 0;
|
||||
let resolveRetryReady: (finishRetry: () => void) => void = () => undefined;
|
||||
const retryReady = new Promise<() => void>((resolve) => {
|
||||
resolveRetryReady = resolve;
|
||||
});
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) {
|
||||
return HttpResponse.json({ success: false, message: "없습니다.", data: null, errorProperty: null }, { status: 404 });
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
resolveRetryReady(() => resolve(HttpResponse.json({ success: true, message: null, data: { totalCount: 0, content: [] }, errorProperty: null })));
|
||||
});
|
||||
}),
|
||||
);
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
render(<App />);
|
||||
const alert = await screen.findByRole("alert");
|
||||
const retryButton = screen.getByRole("button", { name: "보호 route 다시 시도" });
|
||||
retryButton.focus();
|
||||
expect(alert).toHaveTextContent("없습니다.");
|
||||
expect(retryButton).toHaveFocus();
|
||||
expect(requestCount).toBe(1);
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(retryButton);
|
||||
const finishRetry = await retryReady;
|
||||
|
||||
expect(requestCount).toBe(2);
|
||||
expect(screen.getByRole("status")).toHaveTextContent("보호 route 확인 중");
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "로그아웃" })).not.toBeInTheDocument();
|
||||
finishRetry();
|
||||
expect(await screen.findByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "로그아웃" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("keeps retry available and the protected shell hidden when a network retry fails", async () => {
|
||||
saveAdminSession();
|
||||
let requestCount = 0;
|
||||
server.use(http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () => {
|
||||
requestCount += 1;
|
||||
return HttpResponse.error();
|
||||
}));
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
render(<App />);
|
||||
const retryButton = await screen.findByRole("button", { name: "보호 route 다시 시도" });
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(UNKNOWN_API_ERROR_MESSAGE);
|
||||
expect(requestCount).toBe(1);
|
||||
|
||||
fireEvent.click(retryButton);
|
||||
|
||||
await waitFor(() => expect(requestCount).toBe(2));
|
||||
expect(await screen.findByRole("button", { name: "보호 route 다시 시도" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(UNKNOWN_API_ERROR_MESSAGE);
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "로그아웃" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("clears a previous protected route verification before the same session re-enters the route", async () => {
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse();
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
|
||||
window.history.pushState({}, "", "/login");
|
||||
fireEvent.popState(window);
|
||||
await waitFor(() => expect(screen.getByRole("heading", { name: "관리자 로그인" })).toBeInTheDocument());
|
||||
useAiCharactersFailure(404);
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
fireEvent.popState(window);
|
||||
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("없습니다.");
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("keeps the protected shell hidden while a stale ADMIN probe is pending and then denied", async () => {
|
||||
saveAdminSession();
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
let resolveDenyProbeReady: (denyProbe: () => void) => void = () => undefined;
|
||||
const denyProbeReady = new Promise<() => void>((resolve) => {
|
||||
resolveDenyProbeReady = resolve;
|
||||
});
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () =>
|
||||
new Promise((resolve) => {
|
||||
resolveDenyProbeReady(() => resolve(HttpResponse.json({ success: false, message: "접근 권한이 없습니다.", data: null, errorProperty: null }, { status: 403 })));
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
|
||||
const triggerDenyProbe = await denyProbeReady;
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
triggerDenyProbe();
|
||||
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/access-denied"));
|
||||
expect(screen.getByRole("heading", { name: "접근 권한이 없습니다" })).toBeInTheDocument();
|
||||
});
|
||||
219
src/app/App.protected-shell.test.tsx
Normal file
219
src/app/App.protected-shell.test.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { App } from "./App";
|
||||
import { apiBaseUrl, installDesktopMediaQuery, requireElement, saveAdminSession, useAiCharactersResponse } from "./app-test-support";
|
||||
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
|
||||
import { server } from "@/shared/test/server";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
window.history.replaceState({}, "", "/");
|
||||
});
|
||||
|
||||
test("renders the protected admin shell for an existing ADMIN session", async () => {
|
||||
saveAdminSession();
|
||||
const requests: Request[] = [];
|
||||
useAiCharactersResponse(200, (request) => requests.push(request));
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("heading", { name: "AI 캐릭터" })).toBeInTheDocument();
|
||||
expect(requests[0]?.url).toContain("size=20");
|
||||
expect(screen.getByRole("link", { name: "본문으로 건너뛰기" })).toHaveAttribute("href", "#app-main");
|
||||
expect(screen.getByRole("banner")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "모바일 메뉴 열기" })).toHaveClass("whitespace-nowrap");
|
||||
expect(screen.getByRole("navigation", { name: "브레드크럼" })).toHaveClass("whitespace-nowrap");
|
||||
expect(screen.getByRole("button", { name: "로그아웃" })).toHaveClass("whitespace-nowrap");
|
||||
expect(screen.getByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("navigation", { name: "데스크톱 주 메뉴" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("navigation", { name: "브레드크럼" })).toHaveTextContent("AI 캐릭터");
|
||||
expect(await screen.findByText("검색 조건에 맞는 AI 캐릭터가 없습니다.")).toBeInTheDocument();
|
||||
expect(screen.queryByText("루나")).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(requests).toHaveLength(2));
|
||||
});
|
||||
|
||||
test("shows an accessible status while the initial protected route probe is pending", async () => {
|
||||
saveAdminSession();
|
||||
let resolveProbeReady: (finishProbe: () => void) => void = () => undefined;
|
||||
const probeReady = new Promise<() => void>((resolve) => {
|
||||
resolveProbeReady = resolve;
|
||||
});
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () =>
|
||||
new Promise((resolve) => {
|
||||
resolveProbeReady(() => resolve(HttpResponse.json({ success: true, message: null, data: { totalCount: 0, content: [] }, errorProperty: null })));
|
||||
}),
|
||||
),
|
||||
);
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
render(<App />);
|
||||
const finishProbe = await probeReady;
|
||||
|
||||
expect(screen.getByRole("status")).toHaveTextContent("보호 route 확인 중");
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "로그아웃" })).not.toBeInTheDocument();
|
||||
finishProbe();
|
||||
expect(await screen.findByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("keeps malformed protected routes behind the ADMIN probe", async () => {
|
||||
saveAdminSession();
|
||||
let resolveProbeReady: (finishProbe: () => void) => void = () => undefined;
|
||||
const probeReady = new Promise<() => void>((resolve) => {
|
||||
resolveProbeReady = resolve;
|
||||
});
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () =>
|
||||
new Promise((resolve) => {
|
||||
resolveProbeReady(() => resolve(HttpResponse.json({ success: false, message: "권한이 없습니다.", data: null, errorProperty: null }, { status: 403 })));
|
||||
}),
|
||||
),
|
||||
);
|
||||
window.history.pushState({}, "", "/ai-characters/%");
|
||||
|
||||
render(<App />);
|
||||
const finishProbe = await probeReady;
|
||||
|
||||
expect(screen.getByRole("status")).toHaveTextContent("보호 route 확인 중");
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
finishProbe();
|
||||
expect(await screen.findByRole("heading", { name: "접근 권한이 없습니다" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ path: "/ai-characters/%", status: 401, finalPath: "/login", finalHeading: "관리자 로그인", sessionCleared: true },
|
||||
{ path: "/ai-characters/%", status: 403, finalPath: "/access-denied", finalHeading: "접근 권한이 없습니다", sessionCleared: false },
|
||||
{ path: "/ai-characters/%E0%A4%A/audio-contents/1", status: 401, finalPath: "/login", finalHeading: "관리자 로그인", sessionCleared: true },
|
||||
{ path: "/ai-characters/%E0%A4%A/audio-contents/1", status: 403, finalPath: "/access-denied", finalHeading: "접근 권한이 없습니다", sessionCleared: false },
|
||||
{ path: "/ai-characters/101/series/%E0%A4%A", status: 401, finalPath: "/login", finalHeading: "관리자 로그인", sessionCleared: true },
|
||||
{ path: "/ai-characters/101/series/%E0%A4%A", status: 403, finalPath: "/access-denied", finalHeading: "접근 권한이 없습니다", sessionCleared: false },
|
||||
] as const)("keeps malformed route $path behind the ADMIN probe before resolving $status", async ({ path, status, finalPath, finalHeading, sessionCleared }) => {
|
||||
saveAdminSession();
|
||||
let resolveProbeReady: (finishProbe: () => void) => void = () => undefined;
|
||||
const probeReady = new Promise<() => void>((resolve) => {
|
||||
resolveProbeReady = resolve;
|
||||
});
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () =>
|
||||
new Promise((resolve) => {
|
||||
resolveProbeReady(() =>
|
||||
resolve(
|
||||
HttpResponse.json(
|
||||
{ success: false, message: status === 401 ? "인증 정보가 없습니다." : "접근 권한이 없습니다.", data: null, errorProperty: null },
|
||||
{ status },
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
window.history.pushState({}, "", path);
|
||||
|
||||
render(<App />);
|
||||
const finishProbe = await probeReady;
|
||||
|
||||
expect(screen.getByRole("status")).toHaveTextContent("보호 route 확인 중");
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "로그아웃" })).not.toBeInTheDocument();
|
||||
finishProbe();
|
||||
|
||||
await waitFor(() => expect(window.location.pathname).toBe(finalPath));
|
||||
expect(screen.getByRole("heading", { name: finalHeading })).toBeInTheDocument();
|
||||
if (sessionCleared) {
|
||||
expect(authSessionStorage.read()).toBeNull();
|
||||
} else {
|
||||
expect(authSessionStorage.read()).toEqual({ token: "admin-token", role: "ADMIN" });
|
||||
}
|
||||
});
|
||||
|
||||
test("shows the mock mode banner in the protected admin shell only in mock mode", async () => {
|
||||
vi.stubEnv("VITE_API_MODE", "mock");
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse();
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("status", { name: "Mock Preview" })).toHaveTextContent("Mock Preview");
|
||||
});
|
||||
|
||||
test("keeps the mock mode banner inside the inert background while the mobile menu is open", async () => {
|
||||
vi.stubEnv("VITE_API_MODE", "mock");
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse();
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
render(<App />);
|
||||
await screen.findByRole("main", { name: "AI 캐릭터 관리" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "모바일 메뉴 열기" }));
|
||||
|
||||
const banner = requireElement(screen.getByText("Mock Preview").closest("[role='status']"), "mock banner");
|
||||
const inertBackground = requireElement(banner.closest("[inert]"), "mock banner inert background");
|
||||
expect(inertBackground).toHaveAttribute("aria-hidden", "true");
|
||||
expect(screen.queryByRole("status", { name: "Mock Preview" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("closes the mobile menu at the lg breakpoint without restoring focus to the hidden trigger", async () => {
|
||||
const desktopMediaQuery = installDesktopMediaQuery();
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse();
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
render(<App />);
|
||||
await screen.findByRole("main", { name: "AI 캐릭터 관리" });
|
||||
const menuButton = screen.getByRole("button", { name: "모바일 메뉴 열기" });
|
||||
fireEvent.click(menuButton);
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "모바일 메뉴 닫기" })).toHaveFocus());
|
||||
desktopMediaQuery.setDesktopMatch();
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("navigation", { name: "모바일 주 메뉴" })).not.toBeInTheDocument());
|
||||
expect(menuButton).not.toHaveFocus();
|
||||
expect(screen.getByRole("navigation", { name: "데스크톱 주 메뉴" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "로그아웃" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("composes Task 1.5 shared empty state in the real admin shell without domain list data", async () => {
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse();
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
|
||||
expect(await screen.findByText("검색 조건에 맞는 AI 캐릭터가 없습니다.")).toBeInTheDocument();
|
||||
expect(screen.queryByText("루나")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("keeps keyboard focus inside the mobile menu and returns focus to the trigger", async () => {
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse();
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
render(<App />);
|
||||
|
||||
await screen.findByRole("heading", { name: "AI 캐릭터" });
|
||||
const menuButton = screen.getByRole("button", { name: "모바일 메뉴 열기" });
|
||||
fireEvent.click(menuButton);
|
||||
const menu = screen.getByRole("navigation", { name: "모바일 주 메뉴" });
|
||||
const closeButton = screen.getByRole("button", { name: "모바일 메뉴 닫기" });
|
||||
await waitFor(() => expect(closeButton).toHaveFocus());
|
||||
fireEvent.keyDown(menu, { key: "Tab", shiftKey: true });
|
||||
expect(screen.getByRole("link", { name: "AI 캐릭터" })).toHaveFocus();
|
||||
fireEvent.keyDown(menu, { key: "Tab" });
|
||||
expect(closeButton).toHaveFocus();
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
|
||||
expect(screen.queryByRole("navigation", { name: "모바일 주 메뉴" })).not.toBeInTheDocument();
|
||||
expect(menuButton).toHaveFocus();
|
||||
});
|
||||
@@ -1,108 +1,11 @@
|
||||
import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { App } from "./App";
|
||||
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
|
||||
import { apiBaseUrl, saveAdminSession, useAiCharacterDetailResponse, useAiCharactersResponse } from "./app-test-support";
|
||||
import { server } from "@/shared/test/server";
|
||||
|
||||
const apiBaseUrl = "https://api.example.com";
|
||||
|
||||
function saveAdminSession() {
|
||||
authSessionStorage.save({ token: "admin-token", role: "ADMIN" });
|
||||
}
|
||||
|
||||
function useAiCharactersResponse(status: 200 | 401 | 403 = 200, onRequest: (request: Request) => void = () => undefined) {
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, ({ request }) => {
|
||||
onRequest(request);
|
||||
if (status === 401) {
|
||||
return HttpResponse.json(
|
||||
{ success: false, message: "인증 정보가 없습니다.", data: null, errorProperty: null },
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 403) {
|
||||
return HttpResponse.json(
|
||||
{ success: false, message: "접근 권한이 없습니다.", data: null, errorProperty: null },
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
|
||||
return HttpResponse.json({ success: true, message: null, data: null, errorProperty: null });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function useAiCharactersFailure(status: 404 | "network") {
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () => {
|
||||
if (status === "network") {
|
||||
return HttpResponse.error();
|
||||
}
|
||||
|
||||
return HttpResponse.json({ success: false, message: "없습니다.", data: null, errorProperty: null }, { status });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function installDesktopMediaQuery() {
|
||||
const desktopQuery = "(min-width: 1024px)";
|
||||
let matches = false;
|
||||
const listeners = new Set<(event: Event) => void>();
|
||||
const mediaQueryList = {
|
||||
get matches() {
|
||||
return matches;
|
||||
},
|
||||
media: desktopQuery,
|
||||
onchange: null,
|
||||
addEventListener: (type: string, listener: EventListenerOrEventListenerObject | null) => {
|
||||
if (type !== "change" || listener === null || typeof listener !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
listeners.add(listener);
|
||||
},
|
||||
removeEventListener: (type: string, listener: EventListenerOrEventListenerObject | null) => {
|
||||
if (type !== "change" || listener === null || typeof listener !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
listeners.delete(listener);
|
||||
},
|
||||
dispatchEvent: (event: Event) => {
|
||||
listeners.forEach((listener) => listener(event));
|
||||
return true;
|
||||
},
|
||||
addListener: (listener: (event: Event) => void) => {
|
||||
listeners.add(listener);
|
||||
},
|
||||
removeListener: (listener: (event: Event) => void) => {
|
||||
listeners.delete(listener);
|
||||
},
|
||||
} satisfies MediaQueryList;
|
||||
vi.stubGlobal("matchMedia", (query: string) => {
|
||||
expect(query).toBe(desktopQuery);
|
||||
return mediaQueryList;
|
||||
});
|
||||
|
||||
return {
|
||||
setDesktopMatch: () => {
|
||||
matches = true;
|
||||
mediaQueryList.dispatchEvent(new Event("change"));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function requireElement(element: Element | null, name: string): Element {
|
||||
if (element === null) {
|
||||
throw new Error(`${name} not found`);
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
});
|
||||
@@ -124,10 +27,8 @@ test("redirects an unauthenticated direct visit to /ai-characters without exposi
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(screen.queryByText("Phase 3에서 AI 캐릭터 목록이 연결됩니다.")).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/login"));
|
||||
expect(screen.getByRole("heading", { name: "관리자 로그인" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("Phase 3에서 AI 캐릭터 목록이 연결됩니다.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders the existing login page at /login", () => {
|
||||
@@ -140,26 +41,20 @@ test("renders the existing login page at /login", () => {
|
||||
});
|
||||
|
||||
test("shows the mock mode banner on the login page only in mock mode", () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_MODE", "mock");
|
||||
window.history.pushState({}, "", "/login");
|
||||
|
||||
// When
|
||||
render(<App />);
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("status", { name: "Mock Preview" })).toHaveTextContent("Mock Preview");
|
||||
});
|
||||
|
||||
test("does not show the mock mode banner on the login page in server mode", () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_MODE", "server");
|
||||
window.history.pushState({}, "", "/login");
|
||||
|
||||
// When
|
||||
render(<App />);
|
||||
|
||||
// Then
|
||||
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -168,12 +63,7 @@ test("navigates to /ai-characters after a successful login", async () => {
|
||||
useAiCharactersResponse();
|
||||
server.use(
|
||||
http.post(`${apiBaseUrl}/admin/member/login`, () =>
|
||||
HttpResponse.json({
|
||||
success: true,
|
||||
message: null,
|
||||
data: { token: "jwt-token", role: "ADMIN" },
|
||||
errorProperty: null,
|
||||
}),
|
||||
HttpResponse.json({ success: true, message: null, data: { token: "jwt-token", role: "ADMIN" }, errorProperty: null }),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -187,456 +77,42 @@ test("navigates to /ai-characters after a successful login", async () => {
|
||||
expect(screen.getByRole("heading", { name: "AI 캐릭터" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders the protected admin shell for an existing ADMIN session", async () => {
|
||||
test("routes /ai-characters/new to the character create form", async () => {
|
||||
saveAdminSession();
|
||||
const requests: Request[] = [];
|
||||
useAiCharactersResponse(200, (request) => requests.push(request));
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
useAiCharactersResponse();
|
||||
window.history.pushState({}, "", "/ai-characters/new");
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "AI 캐릭터 생성" })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("이름")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("시스템 프롬프트")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("설명")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("프로필 이미지")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("routes /ai-characters/:characterId/edit to the character edit form", async () => {
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse();
|
||||
useAiCharacterDetailResponse("101");
|
||||
window.history.pushState({}, "", "/ai-characters/101/edit");
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "AI 캐릭터 수정" })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("이름")).toHaveValue("루나");
|
||||
expect(screen.getByLabelText("시스템 프롬프트")).toHaveValue("친절하게 답한다.");
|
||||
expect(screen.getByLabelText("설명")).toHaveValue("차분한 상담형 캐릭터");
|
||||
expect(screen.getByLabelText("지역")).toHaveValue("KR");
|
||||
expect(screen.getByLabelText("지역")).toBeDisabled();
|
||||
});
|
||||
|
||||
test.each(["/ai-characters/%", "/ai-characters/%E0%A4%A/audio-contents/1", "/ai-characters/101/series/%E0%A4%A"])("falls back to the character list for malformed route %s", async (path) => {
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse();
|
||||
window.history.pushState({}, "", path);
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("heading", { name: "AI 캐릭터" })).toBeInTheDocument();
|
||||
expect(requests[0]?.url).toContain("size=20");
|
||||
expect(screen.getByRole("link", { name: "본문으로 건너뛰기" })).toHaveAttribute("href", "#app-main");
|
||||
expect(screen.getByRole("banner")).toBeInTheDocument();
|
||||
expect(screen.getByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("navigation", { name: "데스크톱 주 메뉴" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("navigation", { name: "브레드크럼" })).toHaveTextContent("AI 캐릭터");
|
||||
expect(screen.getByText("Phase 3에서 AI 캐릭터 목록이 연결됩니다.")).toBeInTheDocument();
|
||||
expect(screen.queryByText("루나")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows an accessible status while the initial protected route probe is pending", async () => {
|
||||
// Given
|
||||
saveAdminSession();
|
||||
let resolveProbeReady: (finishProbe: () => void) => void = () => undefined;
|
||||
const probeReady = new Promise<() => void>((resolve) => {
|
||||
resolveProbeReady = resolve;
|
||||
});
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () =>
|
||||
new Promise((resolve) => {
|
||||
resolveProbeReady(() => resolve(HttpResponse.json({ success: true, message: null, data: null, errorProperty: null })));
|
||||
}),
|
||||
),
|
||||
);
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
// When
|
||||
render(<App />);
|
||||
const finishProbe = await probeReady;
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("status")).toHaveTextContent("보호 route 확인 중");
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "로그아웃" })).not.toBeInTheDocument();
|
||||
finishProbe();
|
||||
expect(await screen.findByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows the mock mode banner in the protected admin shell only in mock mode", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_MODE", "mock");
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse();
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
// When
|
||||
render(<App />);
|
||||
|
||||
// Then
|
||||
expect(await screen.findByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("status", { name: "Mock Preview" })).toHaveTextContent("Mock Preview");
|
||||
});
|
||||
|
||||
test("keeps the mock mode banner inside the inert background while the mobile menu is open", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_MODE", "mock");
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse();
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
// When
|
||||
render(<App />);
|
||||
await screen.findByRole("main", { name: "AI 캐릭터 관리" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "모바일 메뉴 열기" }));
|
||||
|
||||
// Then
|
||||
const banner = requireElement(screen.getByText("Mock Preview").closest("[role='status']"), "mock banner");
|
||||
const inertBackground = requireElement(banner.closest("[inert]"), "mock banner inert background");
|
||||
expect(inertBackground).toHaveAttribute("aria-hidden", "true");
|
||||
expect(screen.queryByRole("status", { name: "Mock Preview" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("closes the mobile menu at the lg breakpoint without restoring focus to the hidden trigger", async () => {
|
||||
// Given
|
||||
const desktopMediaQuery = installDesktopMediaQuery();
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse();
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
// When
|
||||
render(<App />);
|
||||
await screen.findByRole("main", { name: "AI 캐릭터 관리" });
|
||||
const menuButton = screen.getByRole("button", { name: "모바일 메뉴 열기" });
|
||||
fireEvent.click(menuButton);
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "모바일 메뉴 닫기" })).toHaveFocus());
|
||||
desktopMediaQuery.setDesktopMatch();
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(screen.queryByRole("navigation", { name: "모바일 주 메뉴" })).not.toBeInTheDocument());
|
||||
expect(menuButton).not.toHaveFocus();
|
||||
expect(screen.getByRole("navigation", { name: "데스크톱 주 메뉴" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "로그아웃" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("composes Task 1.5 shared empty state in the real admin shell without domain list data", async () => {
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse();
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Phase 3에서 AI 캐릭터 목록이 연결됩니다.");
|
||||
expect(screen.queryByText("루나")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("clears the session and routes to login when the protected route request returns 401", async () => {
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse(401);
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/login"));
|
||||
expect(authSessionStorage.read()).toBeNull();
|
||||
expect(screen.getByRole("heading", { name: "관리자 로그인" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("세션이 만료되었습니다. 다시 로그인하세요.");
|
||||
expect(screen.queryByText("Phase 3에서 AI 캐릭터 목록이 연결됩니다.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("routes to access denied without clearing the session when the protected route request returns 403", async () => {
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse(403);
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/access-denied"));
|
||||
expect(authSessionStorage.read()).toEqual({ token: "admin-token", role: "ADMIN" });
|
||||
expect(screen.getByRole("heading", { name: "접근 권한이 없습니다" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("루나")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("keeps the mock mode banner visible on the access denied page", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_MODE", "mock");
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse(403);
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
// When
|
||||
render(<App />);
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/access-denied"));
|
||||
expect(screen.getByRole("heading", { name: "접근 권한이 없습니다" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("status", { name: "Mock Preview" })).toHaveTextContent("Mock Preview");
|
||||
});
|
||||
|
||||
test("keeps the protected shell hidden when the protected route request returns 404", async () => {
|
||||
saveAdminSession();
|
||||
useAiCharactersFailure(404);
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("없습니다.");
|
||||
expect(screen.queryByRole("banner")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("keeps the mock mode banner visible on protected route errors", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_MODE", "mock");
|
||||
saveAdminSession();
|
||||
useAiCharactersFailure(404);
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
// When
|
||||
render(<App />);
|
||||
|
||||
// Then
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("없습니다.");
|
||||
expect(screen.getByRole("status", { name: "Mock Preview" })).toHaveTextContent("Mock Preview");
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("keeps the protected shell hidden when the protected route request has a network error", async () => {
|
||||
saveAdminSession();
|
||||
useAiCharactersFailure("network");
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("보호 route 확인에 실패했습니다.");
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("retries a protected route 404 and reveals the shell only after the current retry succeeds", async () => {
|
||||
// Given
|
||||
saveAdminSession();
|
||||
let requestCount = 0;
|
||||
let resolveRetryReady: (finishRetry: () => void) => void = () => undefined;
|
||||
const retryReady = new Promise<() => void>((resolve) => {
|
||||
resolveRetryReady = resolve;
|
||||
});
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) {
|
||||
return HttpResponse.json({ success: false, message: "없습니다.", data: null, errorProperty: null }, { status: 404 });
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
resolveRetryReady(() => resolve(HttpResponse.json({ success: true, message: null, data: null, errorProperty: null })));
|
||||
});
|
||||
}),
|
||||
);
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
render(<App />);
|
||||
const alert = await screen.findByRole("alert");
|
||||
const retryButton = screen.getByRole("button", { name: "보호 route 다시 시도" });
|
||||
retryButton.focus();
|
||||
expect(alert).toHaveTextContent("없습니다.");
|
||||
expect(retryButton).toHaveFocus();
|
||||
expect(requestCount).toBe(1);
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
|
||||
// When
|
||||
fireEvent.click(retryButton);
|
||||
const finishRetry = await retryReady;
|
||||
|
||||
// Then
|
||||
expect(requestCount).toBe(2);
|
||||
expect(screen.getByRole("status")).toHaveTextContent("보호 route 확인 중");
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "로그아웃" })).not.toBeInTheDocument();
|
||||
finishRetry();
|
||||
expect(await screen.findByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "로그아웃" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("keeps retry available and the protected shell hidden when a network retry fails", async () => {
|
||||
// Given
|
||||
saveAdminSession();
|
||||
let requestCount = 0;
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () => {
|
||||
requestCount += 1;
|
||||
return HttpResponse.error();
|
||||
}),
|
||||
);
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
render(<App />);
|
||||
const retryButton = await screen.findByRole("button", { name: "보호 route 다시 시도" });
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("보호 route 확인에 실패했습니다.");
|
||||
expect(requestCount).toBe(1);
|
||||
|
||||
// When
|
||||
fireEvent.click(retryButton);
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(requestCount).toBe(2));
|
||||
expect(await screen.findByRole("button", { name: "보호 route 다시 시도" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("보호 route 확인에 실패했습니다.");
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "로그아웃" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("clears a previous protected route verification before reusing the same token after login", async () => {
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse();
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
server.use(
|
||||
http.post(`${apiBaseUrl}/member/logout`, () => HttpResponse.json({ success: true, message: null, data: {}, errorProperty: null })),
|
||||
http.post(`${apiBaseUrl}/admin/member/login`, () =>
|
||||
HttpResponse.json({
|
||||
success: true,
|
||||
message: null,
|
||||
data: { token: "admin-token", role: "ADMIN" },
|
||||
errorProperty: null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "로그아웃" }));
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/login"));
|
||||
useAiCharactersFailure(404);
|
||||
fireEvent.change(screen.getByLabelText("이메일"), { target: { value: "admin@test.com" } });
|
||||
fireEvent.change(screen.getByLabelText("비밀번호"), { target: { value: "password" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "로그인" }));
|
||||
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters"));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("없습니다.");
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("clears a previous protected route verification before the same session re-enters the route", async () => {
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse();
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
|
||||
window.history.pushState({}, "", "/login");
|
||||
fireEvent.popState(window);
|
||||
await waitFor(() => expect(screen.getByRole("heading", { name: "관리자 로그인" })).toBeInTheDocument());
|
||||
useAiCharactersFailure(404);
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
fireEvent.popState(window);
|
||||
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("없습니다.");
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("keeps the protected shell hidden while a stale ADMIN probe is pending and then denied", async () => {
|
||||
saveAdminSession();
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
let resolveDenyProbeReady: (denyProbe: () => void) => void = () => undefined;
|
||||
const denyProbeReady = new Promise<() => void>((resolve) => {
|
||||
resolveDenyProbeReady = resolve;
|
||||
});
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () =>
|
||||
new Promise((resolve) => {
|
||||
resolveDenyProbeReady(() =>
|
||||
resolve(
|
||||
HttpResponse.json(
|
||||
{ success: false, message: "접근 권한이 없습니다.", data: null, errorProperty: null },
|
||||
{ status: 403 },
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
|
||||
const triggerDenyProbe = await denyProbeReady;
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Phase 3에서 AI 캐릭터 목록이 연결됩니다.")).not.toBeInTheDocument();
|
||||
triggerDenyProbe();
|
||||
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/access-denied"));
|
||||
expect(screen.getByRole("heading", { name: "접근 권한이 없습니다" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("keeps keyboard focus inside the mobile menu and returns focus to the trigger", async () => {
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse();
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
render(<App />);
|
||||
|
||||
await screen.findByRole("heading", { name: "AI 캐릭터" });
|
||||
const menuButton = screen.getByRole("button", { name: "모바일 메뉴 열기" });
|
||||
fireEvent.click(menuButton);
|
||||
const menu = screen.getByRole("navigation", { name: "모바일 주 메뉴" });
|
||||
const closeButton = screen.getByRole("button", { name: "모바일 메뉴 닫기" });
|
||||
await waitFor(() => expect(closeButton).toHaveFocus());
|
||||
|
||||
fireEvent.keyDown(menu, { key: "Tab", shiftKey: true });
|
||||
expect(screen.getByRole("link", { name: "AI 캐릭터" })).toHaveFocus();
|
||||
|
||||
fireEvent.keyDown(menu, { key: "Tab" });
|
||||
expect(closeButton).toHaveFocus();
|
||||
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
|
||||
expect(screen.queryByRole("navigation", { name: "모바일 주 메뉴" })).not.toBeInTheDocument();
|
||||
expect(menuButton).toHaveFocus();
|
||||
});
|
||||
|
||||
test("shows a login warning when server logout confirmation fails", async () => {
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse();
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
server.use(
|
||||
http.post(`${apiBaseUrl}/member/logout`, () =>
|
||||
HttpResponse.json(
|
||||
{ success: false, message: "로그아웃 확인 실패", data: null, errorProperty: null },
|
||||
{ status: 500 },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "로그아웃" }));
|
||||
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/login"));
|
||||
expect(authSessionStorage.read()).toBeNull();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("서버 로그아웃 확인에 실패했습니다.");
|
||||
});
|
||||
|
||||
test("logs out from the protected shell and routes to login", async () => {
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse();
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
server.use(
|
||||
http.post(`${apiBaseUrl}/member/logout`, ({ request }) => {
|
||||
expect(request.headers.get("Authorization")).toBe("Bearer admin-token");
|
||||
|
||||
return HttpResponse.json({ success: true, message: null, data: {}, errorProperty: null });
|
||||
}),
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "로그아웃" }));
|
||||
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/login"));
|
||||
expect(authSessionStorage.read()).toBeNull();
|
||||
});
|
||||
|
||||
test("sends only one logout request while the first logout is in flight", async () => {
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse();
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
let logoutCount = 0;
|
||||
let resolveLogoutReady: (finishLogout: () => void) => void = () => undefined;
|
||||
const logoutReady = new Promise<() => void>((resolve) => {
|
||||
resolveLogoutReady = resolve;
|
||||
});
|
||||
server.use(
|
||||
http.post(`${apiBaseUrl}/member/logout`, () => {
|
||||
logoutCount += 1;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
resolveLogoutReady(() => resolve(HttpResponse.json({ success: true, message: null, data: {}, errorProperty: null })));
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
|
||||
const logoutButton = await screen.findByRole("button", { name: "로그아웃" });
|
||||
fireEvent.click(logoutButton);
|
||||
fireEvent.click(logoutButton);
|
||||
await waitFor(() => expect(logoutCount).toBe(1));
|
||||
const finishLogout = await logoutReady;
|
||||
finishLogout();
|
||||
|
||||
await waitForElementToBeRemoved(logoutButton);
|
||||
expect(window.location.pathname).toBe("/login");
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useAuthSession } from "@/features/auth/model/auth-session-context";
|
||||
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
|
||||
import { AccessDeniedPage } from "@/app/admin-pages";
|
||||
import { routePaths } from "@/app/route-paths";
|
||||
import { navigateTo, replaceWith, useBrowserLocation } from "@/app/browser-location";
|
||||
import { isAiCharactersRoute, navigateTo, replaceWith, useBrowserLocation } from "@/app/browser-location";
|
||||
import { ProtectedAdminShell } from "@/app/protected-admin-shell";
|
||||
import { AccessDeniedError, ApiError } from "@/shared/api/api-error";
|
||||
import { createApiClient } from "@/shared/api/client";
|
||||
@@ -62,14 +62,15 @@ function RouteFrame({ apiMode, children }: { readonly apiMode: ApiMode; readonly
|
||||
|
||||
function AppShell({ apiMode }: { readonly apiMode: ApiMode }) {
|
||||
const auth = useAuthSession();
|
||||
const clearAuthSession = auth.clearSession;
|
||||
const protectedRouteApiClient = useMemo(
|
||||
() =>
|
||||
createApiClient({
|
||||
getToken: () => authSessionStorage.read()?.token ?? null,
|
||||
clearSession: () => auth.clearSession(sessionExpiredNotice),
|
||||
clearSession: () => clearAuthSession(sessionExpiredNotice),
|
||||
onAuthExpired: () => replaceWith(routePaths.login),
|
||||
}),
|
||||
[auth],
|
||||
[clearAuthSession],
|
||||
);
|
||||
const location = useBrowserLocation();
|
||||
const [routeError, setRouteError] = useState<ProtectedRouteError | null>(null);
|
||||
@@ -83,7 +84,7 @@ function AppShell({ apiMode }: { readonly apiMode: ApiMode }) {
|
||||
}, [auth.session, location.path]);
|
||||
|
||||
useEffect(() => {
|
||||
if (location.path !== routePaths.aiCharacters || auth.session === null) {
|
||||
if (!isAiCharactersRoute(location.path) || auth.session === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -160,7 +161,7 @@ function AppShell({ apiMode }: { readonly apiMode: ApiMode }) {
|
||||
: null;
|
||||
|
||||
if (
|
||||
location.path === routePaths.aiCharacters &&
|
||||
isAiCharactersRoute(location.path) &&
|
||||
(verifiedProtectedRouteSession?.session !== auth.session ||
|
||||
verifiedProtectedRouteSession.routeVisitKey !== location.visitKey ||
|
||||
verifiedProtectedRouteSession.protectedRouteRetryKey !== protectedRouteRetryKey)
|
||||
@@ -185,7 +186,7 @@ function AppShell({ apiMode }: { readonly apiMode: ApiMode }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedAdminShell apiMode={apiMode} routeError={currentRouteError} />
|
||||
<ProtectedAdminShell apiClient={protectedRouteApiClient} apiMode={apiMode} routeError={currentRouteError} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { routePaths } from "@/app/route-paths";
|
||||
import { PageState } from "@/shared/ui/page-state";
|
||||
|
||||
export function AccessDeniedPage() {
|
||||
return (
|
||||
@@ -15,23 +14,3 @@ export function AccessDeniedPage() {
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function AiCharactersPage({ routeError }: { readonly routeError: string | null }) {
|
||||
return (
|
||||
<section className="flex flex-col gap-4" aria-labelledby="ai-characters-title">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs font-semibold text-info">AI CHARACTER ADMIN</p>
|
||||
<h1 className="text-2xl font-bold leading-tight" id="ai-characters-title">
|
||||
AI 캐릭터
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">캐릭터 목록과 생성 흐름은 Phase 3에서 연결합니다.</p>
|
||||
</div>
|
||||
{routeError === null ? null : (
|
||||
<p className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" role="alert">
|
||||
{routeError}
|
||||
</p>
|
||||
)}
|
||||
<PageState description="현재 route는 보호 shell과 권한 처리를 검증하는 명시적 빈 상태입니다." state="empty" title="Phase 3에서 AI 캐릭터 목록이 연결됩니다." />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
125
src/app/app-test-support.ts
Normal file
125
src/app/app-test-support.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { expect, vi } from "vitest";
|
||||
|
||||
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
|
||||
import { server } from "@/shared/test/server";
|
||||
|
||||
export const apiBaseUrl = "https://api.example.com";
|
||||
|
||||
export function saveAdminSession() {
|
||||
authSessionStorage.save({ token: "admin-token", role: "ADMIN" });
|
||||
}
|
||||
|
||||
export function useAiCharactersResponse(status: 200 | 401 | 403 = 200, onRequest: (request: Request) => void = () => undefined) {
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, ({ request }) => {
|
||||
onRequest(request);
|
||||
if (status === 401) {
|
||||
return HttpResponse.json({ success: false, message: "인증 정보가 없습니다.", data: null, errorProperty: null }, { status });
|
||||
}
|
||||
if (status === 403) {
|
||||
return HttpResponse.json({ success: false, message: "접근 권한이 없습니다.", data: null, errorProperty: null }, { status });
|
||||
}
|
||||
|
||||
return HttpResponse.json({ success: true, message: null, data: { totalCount: 0, content: [] }, errorProperty: null });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function useAiCharacterDetailResponse(characterId: string) {
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/${characterId}`, () =>
|
||||
HttpResponse.json({
|
||||
success: true,
|
||||
message: null,
|
||||
data: {
|
||||
id: Number(characterId),
|
||||
characterUUID: `character-${characterId}`,
|
||||
name: "루나",
|
||||
imageUrl: null,
|
||||
description: "차분한 상담형 캐릭터",
|
||||
systemPrompt: "친절하게 답한다.",
|
||||
characterType: "Character",
|
||||
age: null,
|
||||
gender: null,
|
||||
mbti: null,
|
||||
speechPattern: null,
|
||||
speechStyle: null,
|
||||
appearance: null,
|
||||
region: "KR",
|
||||
isActive: true,
|
||||
tags: [],
|
||||
hobbies: [],
|
||||
values: [],
|
||||
goals: [],
|
||||
relationships: [],
|
||||
personalities: [],
|
||||
backgrounds: [],
|
||||
memories: [],
|
||||
originalWork: null,
|
||||
},
|
||||
errorProperty: null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function useAiCharactersFailure(status: 404 | "network") {
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () => {
|
||||
if (status === "network") {
|
||||
return HttpResponse.error();
|
||||
}
|
||||
|
||||
return HttpResponse.json({ success: false, message: "없습니다.", data: null, errorProperty: null }, { status });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function installDesktopMediaQuery() {
|
||||
const desktopQuery = "(min-width: 1024px)";
|
||||
let matches = false;
|
||||
const listeners = new Set<(event: Event) => void>();
|
||||
const mediaQueryList = {
|
||||
get matches() {
|
||||
return matches;
|
||||
},
|
||||
media: desktopQuery,
|
||||
onchange: null,
|
||||
addEventListener: (type: string, listener: EventListenerOrEventListenerObject | null) => {
|
||||
if (type === "change" && typeof listener === "function") {
|
||||
listeners.add(listener);
|
||||
}
|
||||
},
|
||||
removeEventListener: (type: string, listener: EventListenerOrEventListenerObject | null) => {
|
||||
if (type === "change" && typeof listener === "function") {
|
||||
listeners.delete(listener);
|
||||
}
|
||||
},
|
||||
dispatchEvent: (event: Event) => {
|
||||
listeners.forEach((listener) => listener(event));
|
||||
return true;
|
||||
},
|
||||
addListener: (listener: (event: Event) => void) => listeners.add(listener),
|
||||
removeListener: (listener: (event: Event) => void) => listeners.delete(listener),
|
||||
} satisfies MediaQueryList;
|
||||
vi.stubGlobal("matchMedia", (query: string) => {
|
||||
expect(query).toBe(desktopQuery);
|
||||
return mediaQueryList;
|
||||
});
|
||||
|
||||
return {
|
||||
setDesktopMatch: () => {
|
||||
matches = true;
|
||||
mediaQueryList.dispatchEvent(new Event("change"));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function requireElement(element: Element | null, name: string): Element {
|
||||
if (element === null) {
|
||||
throw new Error(`${name} not found`);
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
17
src/app/browser-location.test.ts
Normal file
17
src/app/browser-location.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { isAiCharactersRoute } from "@/app/browser-location";
|
||||
import { routePaths, type RoutePath } from "@/app/route-paths";
|
||||
|
||||
const protectedAiCharacterRoutes = [
|
||||
routePaths.aiCharacters,
|
||||
routePaths.aiCharacterCreate,
|
||||
routePaths.aiCharacterDetail("101"),
|
||||
routePaths.aiCharacterEdit("101"),
|
||||
] as const satisfies readonly RoutePath[];
|
||||
|
||||
test("isAiCharactersRoute includes character list create detail and edit routes", () => {
|
||||
for (const path of protectedAiCharacterRoutes) {
|
||||
expect(isAiCharactersRoute(path)).toBe(true);
|
||||
}
|
||||
});
|
||||
@@ -1,17 +1,36 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
import { routePaths, type RoutePath } from "@/app/route-paths";
|
||||
import { routePaths, type CharacterAudioContentCreateRoutePath, type CharacterAudioContentDetailRoutePath, type CharacterAudioContentEditRoutePath, type CharacterAudioContentsRoutePath, type CharacterCommunityPostCreateRoutePath, type CharacterCommunityPostsRoutePath, type CharacterEditRoutePath, type CharacterFanTalksRoutePath, type CharacterRoutePath, type CharacterSeriesCreateRoutePath, type CharacterSeriesDetailRoutePath, type CharacterSeriesEditRoutePath, type CharacterSeriesOrderRoutePath, type CharacterSeriesRoutePath, type RoutePath } from "@/app/route-paths";
|
||||
|
||||
export type BrowserLocationSnapshot = {
|
||||
readonly path: RoutePath;
|
||||
readonly successNotification: string | null;
|
||||
readonly visitKey: number;
|
||||
};
|
||||
|
||||
let currentSnapshot: BrowserLocationSnapshot = { path: readRoutePath(), visitKey: 0 };
|
||||
type NavigateState = {
|
||||
readonly successNotification?: string;
|
||||
};
|
||||
|
||||
export type AudioContentDetailRouteParams = {
|
||||
readonly characterId: string;
|
||||
readonly contentId: string;
|
||||
};
|
||||
|
||||
export type AudioContentCreateRouteParams = {
|
||||
readonly characterId: string;
|
||||
};
|
||||
|
||||
export type SeriesDetailRouteParams = {
|
||||
readonly characterId: string;
|
||||
readonly seriesId: string;
|
||||
};
|
||||
|
||||
let currentSnapshot: BrowserLocationSnapshot = { path: readRoutePath(), successNotification: readSuccessNotification(), visitKey: 0 };
|
||||
|
||||
function subscribe(onStoreChange: () => void): () => void {
|
||||
function handlePopState() {
|
||||
currentSnapshot = { path: readRoutePath(), visitKey: currentSnapshot.visitKey + 1 };
|
||||
currentSnapshot = { path: readRoutePath(), successNotification: readSuccessNotification(), visitKey: currentSnapshot.visitKey + 1 };
|
||||
onStoreChange();
|
||||
}
|
||||
|
||||
@@ -23,17 +42,269 @@ function subscribe(onStoreChange: () => void): () => void {
|
||||
function readRoutePath(): RoutePath {
|
||||
const path = window.location.pathname;
|
||||
|
||||
if (path === routePaths.login || path === routePaths.aiCharacters || path === routePaths.accessDenied) {
|
||||
if (path === routePaths.login || path === routePaths.aiCharacters || path === routePaths.aiCharacterCreate || path === routePaths.accessDenied) {
|
||||
return path;
|
||||
}
|
||||
|
||||
if (isCharacterAudioContentCreateRoutePath(path) || isCharacterAudioContentEditRoutePath(path) || isCharacterAudioContentDetailRoutePath(path) || isCharacterAudioContentsRoutePath(path) || isCharacterCommunityPostCreateRoutePath(path) || isCharacterCommunityPostsRoutePath(path) || isCharacterFanTalksRoutePath(path) || isCharacterSeriesCreateRoutePath(path) || isCharacterSeriesEditRoutePath(path) || isCharacterSeriesOrderRoutePath(path) || isCharacterSeriesDetailRoutePath(path) || isCharacterSeriesRoutePath(path) || isCharacterEditRoutePath(path) || isCharacterRoutePath(path)) {
|
||||
return isAiCharactersRoute(path) ? path : routePaths.aiCharacters;
|
||||
}
|
||||
|
||||
return routePaths.aiCharacters;
|
||||
}
|
||||
|
||||
function readSuccessNotification(): string | null {
|
||||
const state: unknown = window.history.state;
|
||||
if (state === null || typeof state !== "object" || !("successNotification" in state)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return typeof state.successNotification === "string" ? state.successNotification : null;
|
||||
}
|
||||
|
||||
function isCharacterRoutePath(path: string): path is CharacterRoutePath {
|
||||
return /^\/ai-characters\/[^/]+$/.test(path);
|
||||
}
|
||||
|
||||
function isCharacterAudioContentsRoutePath(path: string): path is CharacterAudioContentsRoutePath {
|
||||
return /^\/ai-characters\/[^/]+\/audio-contents$/.test(path);
|
||||
}
|
||||
|
||||
function isCharacterSeriesRoutePath(path: string): path is CharacterSeriesRoutePath {
|
||||
return /^\/ai-characters\/[^/]+\/series$/.test(path);
|
||||
}
|
||||
|
||||
function isCharacterSeriesCreateRoutePath(path: string): path is CharacterSeriesCreateRoutePath {
|
||||
return /^\/ai-characters\/[^/]+\/series\/new$/.test(path);
|
||||
}
|
||||
|
||||
function isCharacterCommunityPostsRoutePath(path: string): path is CharacterCommunityPostsRoutePath {
|
||||
return /^\/ai-characters\/[^/]+\/community-posts$/.test(path);
|
||||
}
|
||||
|
||||
function isCharacterFanTalksRoutePath(path: string): path is CharacterFanTalksRoutePath {
|
||||
return /^\/ai-characters\/[^/]+\/fan-talks$/.test(path);
|
||||
}
|
||||
|
||||
function isCharacterCommunityPostCreateRoutePath(path: string): path is CharacterCommunityPostCreateRoutePath {
|
||||
return /^\/ai-characters\/[^/]+\/community-posts\/new$/.test(path);
|
||||
}
|
||||
|
||||
function isCharacterSeriesDetailRoutePath(path: string): path is CharacterSeriesDetailRoutePath {
|
||||
return /^\/ai-characters\/[^/]+\/series\/(?!new$|order$)[^/]+$/.test(path);
|
||||
}
|
||||
|
||||
function isCharacterSeriesEditRoutePath(path: string): path is CharacterSeriesEditRoutePath {
|
||||
return /^\/ai-characters\/[^/]+\/series\/(?!new\/edit$|order\/edit$)[^/]+\/edit$/.test(path);
|
||||
}
|
||||
|
||||
function isCharacterSeriesOrderRoutePath(path: string): path is CharacterSeriesOrderRoutePath {
|
||||
return /^\/ai-characters\/[^/]+\/series\/order$/.test(path);
|
||||
}
|
||||
|
||||
function isCharacterAudioContentCreateRoutePath(path: string): path is CharacterAudioContentCreateRoutePath {
|
||||
return /^\/ai-characters\/[^/]+\/audio-contents\/new$/.test(path);
|
||||
}
|
||||
|
||||
function isCharacterAudioContentDetailRoutePath(path: string): path is CharacterAudioContentDetailRoutePath {
|
||||
return /^\/ai-characters\/[^/]+\/audio-contents\/(?!new$)[^/]+$/.test(path);
|
||||
}
|
||||
|
||||
function isCharacterAudioContentEditRoutePath(path: string): path is CharacterAudioContentEditRoutePath {
|
||||
return /^\/ai-characters\/[^/]+\/audio-contents\/(?!new\/edit$)[^/]+\/edit$/.test(path);
|
||||
}
|
||||
|
||||
function isCharacterEditRoutePath(path: string): path is CharacterEditRoutePath {
|
||||
return /^\/ai-characters\/[^/]+\/edit$/.test(path);
|
||||
}
|
||||
|
||||
function decodeRouteSegment(segment: string): string | null {
|
||||
try {
|
||||
return decodeURIComponent(segment);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof URIError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function getCharacterEditIdFromPath(path: RoutePath): string | null {
|
||||
if (!isCharacterEditRoutePath(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return decodeRouteSegment(path.slice(`${routePaths.aiCharacters}/`.length, -"/edit".length));
|
||||
}
|
||||
|
||||
export function getCharacterIdFromPath(path: RoutePath): string | null {
|
||||
if (path === routePaths.aiCharacters || path === routePaths.aiCharacterCreate || path === routePaths.login || path === routePaths.accessDenied) {
|
||||
return null;
|
||||
}
|
||||
if (isCharacterEditRoutePath(path) || isCharacterAudioContentsRoutePath(path) || isCharacterAudioContentCreateRoutePath(path) || isCharacterAudioContentEditRoutePath(path) || isCharacterAudioContentDetailRoutePath(path) || isCharacterCommunityPostCreateRoutePath(path) || isCharacterCommunityPostsRoutePath(path) || isCharacterFanTalksRoutePath(path) || isCharacterSeriesRoutePath(path) || isCharacterSeriesCreateRoutePath(path) || isCharacterSeriesEditRoutePath(path) || isCharacterSeriesOrderRoutePath(path) || isCharacterSeriesDetailRoutePath(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return decodeRouteSegment(path.slice(`${routePaths.aiCharacters}/`.length));
|
||||
}
|
||||
|
||||
export function getAudioContentListCharacterIdFromPath(path: RoutePath): string | null {
|
||||
if (!isCharacterAudioContentsRoutePath(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return decodeRouteSegment(path.slice(`${routePaths.aiCharacters}/`.length, -"/audio-contents".length));
|
||||
}
|
||||
|
||||
export function getAudioContentCreateCharacterIdFromPath(path: RoutePath): string | null {
|
||||
if (!isCharacterAudioContentCreateRoutePath(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return decodeRouteSegment(path.slice(`${routePaths.aiCharacters}/`.length, -"/audio-contents/new".length));
|
||||
}
|
||||
|
||||
export function getSeriesListCharacterIdFromPath(path: RoutePath): string | null {
|
||||
if (!isCharacterSeriesRoutePath(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return decodeRouteSegment(path.slice(`${routePaths.aiCharacters}/`.length, -"/series".length));
|
||||
}
|
||||
|
||||
export function getSeriesCreateCharacterIdFromPath(path: RoutePath): string | null {
|
||||
if (!isCharacterSeriesCreateRoutePath(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return decodeRouteSegment(path.slice(`${routePaths.aiCharacters}/`.length, -"/series/new".length));
|
||||
}
|
||||
|
||||
export function getCommunityPostListCharacterIdFromPath(path: RoutePath): string | null {
|
||||
if (!isCharacterCommunityPostsRoutePath(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return decodeRouteSegment(path.slice(`${routePaths.aiCharacters}/`.length, -"/community-posts".length));
|
||||
}
|
||||
|
||||
export function getCommunityPostCreateCharacterIdFromPath(path: RoutePath): string | null {
|
||||
if (!isCharacterCommunityPostCreateRoutePath(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return decodeRouteSegment(path.slice(`${routePaths.aiCharacters}/`.length, -"/community-posts/new".length));
|
||||
}
|
||||
|
||||
export function getFanTalkListCharacterIdFromPath(path: RoutePath): string | null {
|
||||
if (!isCharacterFanTalksRoutePath(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return decodeRouteSegment(path.slice(`${routePaths.aiCharacters}/`.length, -"/fan-talks".length));
|
||||
}
|
||||
|
||||
export function getSeriesDetailRouteFromPath(path: RoutePath): SeriesDetailRouteParams | null {
|
||||
if (!isCharacterSeriesDetailRoutePath(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = /^\/ai-characters\/(?<characterId>[^/]+)\/series\/(?<seriesId>[^/]+)$/.exec(path);
|
||||
const characterId = match?.groups?.characterId;
|
||||
const seriesId = match?.groups?.seriesId;
|
||||
if (characterId === undefined || seriesId === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const decodedCharacterId = decodeRouteSegment(characterId);
|
||||
const decodedSeriesId = decodeRouteSegment(seriesId);
|
||||
if (decodedCharacterId === null || decodedSeriesId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { characterId: decodedCharacterId, seriesId: decodedSeriesId };
|
||||
}
|
||||
|
||||
export function getSeriesEditRouteFromPath(path: RoutePath): SeriesDetailRouteParams | null {
|
||||
if (!isCharacterSeriesEditRoutePath(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = /^\/ai-characters\/(?<characterId>[^/]+)\/series\/(?<seriesId>[^/]+)\/edit$/.exec(path);
|
||||
const characterId = match?.groups?.characterId;
|
||||
const seriesId = match?.groups?.seriesId;
|
||||
if (characterId === undefined || seriesId === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const decodedCharacterId = decodeRouteSegment(characterId);
|
||||
const decodedSeriesId = decodeRouteSegment(seriesId);
|
||||
if (decodedCharacterId === null || decodedSeriesId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { characterId: decodedCharacterId, seriesId: decodedSeriesId };
|
||||
}
|
||||
|
||||
export function getSeriesOrderCharacterIdFromPath(path: RoutePath): string | null {
|
||||
if (!isCharacterSeriesOrderRoutePath(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return decodeRouteSegment(path.slice(`${routePaths.aiCharacters}/`.length, -"/series/order".length));
|
||||
}
|
||||
|
||||
export function getAudioContentDetailRouteFromPath(path: RoutePath): AudioContentDetailRouteParams | null {
|
||||
if (!isCharacterAudioContentDetailRoutePath(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = /^\/ai-characters\/(?<characterId>[^/]+)\/audio-contents\/(?<contentId>[^/]+)$/.exec(path);
|
||||
const characterId = match?.groups?.characterId;
|
||||
const contentId = match?.groups?.contentId;
|
||||
if (characterId === undefined || contentId === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const decodedCharacterId = decodeRouteSegment(characterId);
|
||||
const decodedContentId = decodeRouteSegment(contentId);
|
||||
if (decodedCharacterId === null || decodedContentId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { characterId: decodedCharacterId, contentId: decodedContentId };
|
||||
}
|
||||
|
||||
export function getAudioContentEditRouteFromPath(path: RoutePath): AudioContentDetailRouteParams | null {
|
||||
if (!isCharacterAudioContentEditRoutePath(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = /^\/ai-characters\/(?<characterId>[^/]+)\/audio-contents\/(?<contentId>[^/]+)\/edit$/.exec(path);
|
||||
const characterId = match?.groups?.characterId;
|
||||
const contentId = match?.groups?.contentId;
|
||||
if (characterId === undefined || contentId === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const decodedCharacterId = decodeRouteSegment(characterId);
|
||||
const decodedContentId = decodeRouteSegment(contentId);
|
||||
if (decodedCharacterId === null || decodedContentId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { characterId: decodedCharacterId, contentId: decodedContentId };
|
||||
}
|
||||
|
||||
export function isAiCharactersRoute(path: RoutePath): boolean {
|
||||
return path === routePaths.aiCharacters || path === routePaths.aiCharacterCreate || getCharacterEditIdFromPath(path) !== null || getCharacterIdFromPath(path) !== null || getAudioContentListCharacterIdFromPath(path) !== null || getAudioContentCreateCharacterIdFromPath(path) !== null || getAudioContentEditRouteFromPath(path) !== null || getAudioContentDetailRouteFromPath(path) !== null || getCommunityPostCreateCharacterIdFromPath(path) !== null || getCommunityPostListCharacterIdFromPath(path) !== null || getFanTalkListCharacterIdFromPath(path) !== null || getSeriesListCharacterIdFromPath(path) !== null || getSeriesCreateCharacterIdFromPath(path) !== null || getSeriesEditRouteFromPath(path) !== null || getSeriesOrderCharacterIdFromPath(path) !== null || getSeriesDetailRouteFromPath(path) !== null;
|
||||
}
|
||||
|
||||
function getSnapshot(): BrowserLocationSnapshot {
|
||||
const path = readRoutePath();
|
||||
if (currentSnapshot.path !== path) {
|
||||
currentSnapshot = { path, visitKey: currentSnapshot.visitKey + 1 };
|
||||
currentSnapshot = { path, successNotification: readSuccessNotification(), visitKey: currentSnapshot.visitKey + 1 };
|
||||
}
|
||||
|
||||
return currentSnapshot;
|
||||
@@ -43,8 +314,8 @@ export function useBrowserLocation(): BrowserLocationSnapshot {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
|
||||
export function navigateTo(path: RoutePath): void {
|
||||
window.history.pushState({}, "", path);
|
||||
export function navigateTo(path: string, state: NavigateState = {}): void {
|
||||
window.history.pushState(state, "", path);
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { navigateTo } from "@/app/browser-location";
|
||||
import { AiCharactersPage } from "@/app/admin-pages";
|
||||
import { getAudioContentCreateCharacterIdFromPath, getAudioContentDetailRouteFromPath, getAudioContentEditRouteFromPath, getAudioContentListCharacterIdFromPath, getCharacterEditIdFromPath, getCharacterIdFromPath, getCommunityPostCreateCharacterIdFromPath, getCommunityPostListCharacterIdFromPath, getFanTalkListCharacterIdFromPath, getSeriesCreateCharacterIdFromPath, getSeriesDetailRouteFromPath, getSeriesEditRouteFromPath, getSeriesListCharacterIdFromPath, getSeriesOrderCharacterIdFromPath, navigateTo, useBrowserLocation } from "@/app/browser-location";
|
||||
import { routePaths } from "@/app/route-paths";
|
||||
import { AudioContentDetailPage } from "@/features/audio-contents/pages/AudioContentDetailPage";
|
||||
import { AudioContentFormPage } from "@/features/audio-contents/pages/AudioContentFormPage";
|
||||
import { AudioContentListPage } from "@/features/audio-contents/pages/AudioContentListPage";
|
||||
import { useAuthSession } from "@/features/auth/model/auth-session-context";
|
||||
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
|
||||
import { CharacterCreatePage } from "@/features/characters/pages/CharacterCreatePage";
|
||||
import { CharacterDetailPage } from "@/features/characters/pages/CharacterDetailPage";
|
||||
import { CharacterEditPage } from "@/features/characters/pages/CharacterEditPage";
|
||||
import { CharacterListPage } from "@/features/characters/pages/CharacterListPage";
|
||||
import { CommunityPostListPage } from "@/features/community-posts/pages/CommunityPostListPage";
|
||||
import { CommunityPostFormPage } from "@/features/community-posts/pages/CommunityPostFormPage";
|
||||
import { FanTalkListPage } from "@/features/fan-talks/pages/FanTalkListPage";
|
||||
import { SeriesDetailPage } from "@/features/series/pages/SeriesDetailPage";
|
||||
import { SeriesFormPage } from "@/features/series/pages/SeriesFormPage";
|
||||
import { SeriesListPage } from "@/features/series/pages/SeriesListPage";
|
||||
import { SeriesOrderPage } from "@/features/series/pages/SeriesOrderPage";
|
||||
import type { ApiClient } from "@/shared/api/client";
|
||||
import type { ApiMode } from "@/shared/config/env";
|
||||
import { MockModeBanner } from "@/shared/ui/mock-mode-banner";
|
||||
|
||||
const focusableSelector = "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])";
|
||||
const sessionExpiredNotice = "세션이 만료되었습니다. 다시 로그인하세요.";
|
||||
|
||||
function NavLink() {
|
||||
return (
|
||||
@@ -24,12 +40,33 @@ function NavLink() {
|
||||
);
|
||||
}
|
||||
|
||||
export function ProtectedAdminShell({ apiMode, routeError }: { readonly apiMode: ApiMode; readonly routeError: string | null }) {
|
||||
export function ProtectedAdminShell({ apiClient, apiMode, routeError }: { readonly apiClient: ApiClient; readonly apiMode: ApiMode; readonly routeError: string | null }) {
|
||||
const auth = useAuthSession();
|
||||
const uploadAuth = {
|
||||
clearSession: () => auth.clearSession(sessionExpiredNotice),
|
||||
getToken: () => authSessionStorage.read()?.token ?? null,
|
||||
onAuthExpired: () => navigateTo(routePaths.login),
|
||||
};
|
||||
const location = useBrowserLocation();
|
||||
const audioContentDetailRoute = getAudioContentDetailRouteFromPath(location.path);
|
||||
const audioContentEditRoute = getAudioContentEditRouteFromPath(location.path);
|
||||
const audioContentCreateCharacterId = getAudioContentCreateCharacterIdFromPath(location.path);
|
||||
const audioContentListCharacterId = getAudioContentListCharacterIdFromPath(location.path);
|
||||
const seriesDetailRoute = getSeriesDetailRouteFromPath(location.path);
|
||||
const seriesEditRoute = getSeriesEditRouteFromPath(location.path);
|
||||
const seriesCreateCharacterId = getSeriesCreateCharacterIdFromPath(location.path);
|
||||
const seriesOrderCharacterId = getSeriesOrderCharacterIdFromPath(location.path);
|
||||
const seriesListCharacterId = getSeriesListCharacterIdFromPath(location.path);
|
||||
const communityPostListCharacterId = getCommunityPostListCharacterIdFromPath(location.path);
|
||||
const communityPostCreateCharacterId = getCommunityPostCreateCharacterIdFromPath(location.path);
|
||||
const fanTalkListCharacterId = getFanTalkListCharacterIdFromPath(location.path);
|
||||
const characterEditId = getCharacterEditIdFromPath(location.path);
|
||||
const characterId = getCharacterIdFromPath(location.path);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const mobileMenuRef = useRef<HTMLElement>(null);
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
const shouldRestoreMenuFocusRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -117,7 +154,10 @@ export function ProtectedAdminShell({ apiMode, routeError }: { readonly apiMode:
|
||||
<div aria-hidden={isMobileMenuOpen} className="flex min-h-[100dvh] flex-col" inert={isMobileMenuOpen}>
|
||||
<MockModeBanner apiMode={apiMode} />
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<a className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-modal focus:rounded-md focus:bg-card focus:px-4 focus:py-2 focus:text-link" href="#app-main">
|
||||
<a className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-modal focus:rounded-md focus:bg-card focus:px-4 focus:py-2 focus:text-link" href="#app-main" onClick={(event) => {
|
||||
event.preventDefault();
|
||||
mainRef.current?.focus();
|
||||
}}>
|
||||
본문으로 건너뛰기
|
||||
</a>
|
||||
<aside className="hidden w-60 shrink-0 border-r border-border bg-card p-4 lg:block">
|
||||
@@ -131,14 +171,14 @@ export function ProtectedAdminShell({ apiMode, routeError }: { readonly apiMode:
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<button
|
||||
aria-expanded={isMobileMenuOpen}
|
||||
className="rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold lg:hidden"
|
||||
className="shrink-0 whitespace-nowrap rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold lg:hidden"
|
||||
onClick={() => setIsMobileMenuOpen(true)}
|
||||
ref={menuButtonRef}
|
||||
type="button"
|
||||
>
|
||||
모바일 메뉴 열기
|
||||
</button>
|
||||
<nav aria-label="브레드크럼" className="text-sm text-muted-foreground">
|
||||
<nav aria-label="브레드크럼" className="hidden shrink-0 whitespace-nowrap text-sm text-muted-foreground sm:block">
|
||||
<ol className="flex items-center gap-2">
|
||||
<li>홈</li>
|
||||
<li aria-hidden="true">/</li>
|
||||
@@ -146,12 +186,32 @@ export function ProtectedAdminShell({ apiMode, routeError }: { readonly apiMode:
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={() => void auth.logout()} type="button">
|
||||
<button className="shrink-0 whitespace-nowrap rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={() => void auth.logout()} type="button">
|
||||
로그아웃
|
||||
</button>
|
||||
</header>
|
||||
<main aria-label="AI 캐릭터 관리" className="min-h-0 flex-1 overflow-auto p-4" id="app-main">
|
||||
<AiCharactersPage routeError={routeError} />
|
||||
<main aria-label="AI 캐릭터 관리" className="min-h-0 flex-1 overflow-auto p-4" id="app-main" ref={mainRef} tabIndex={-1}>
|
||||
{location.successNotification === null ? null : (
|
||||
<p aria-label="작업 성공" className="mb-4 rounded-lg border border-border bg-success-surface p-3 text-sm font-semibold text-success" role="status">
|
||||
{location.successNotification}
|
||||
</p>
|
||||
)}
|
||||
{location.path === routePaths.aiCharacterCreate ? <CharacterCreatePage apiClient={apiClient} /> : null}
|
||||
{characterEditId !== null ? <CharacterEditPage apiClient={apiClient} characterId={characterEditId} /> : null}
|
||||
{location.path !== routePaths.aiCharacterCreate && characterEditId === null && characterId === null && audioContentListCharacterId === null && audioContentCreateCharacterId === null && audioContentEditRoute === null && audioContentDetailRoute === null && communityPostCreateCharacterId === null && communityPostListCharacterId === null && fanTalkListCharacterId === null && seriesCreateCharacterId === null && seriesEditRoute === null && seriesListCharacterId === null && seriesOrderCharacterId === null && seriesDetailRoute === null ? <CharacterListPage apiClient={apiClient} routeError={routeError} /> : null}
|
||||
{location.path !== routePaths.aiCharacterCreate && characterEditId === null && characterId !== null ? <CharacterDetailPage apiClient={apiClient} characterId={characterId} /> : null}
|
||||
{audioContentListCharacterId !== null ? <AudioContentListPage apiClient={apiClient} characterId={audioContentListCharacterId} /> : null}
|
||||
{audioContentCreateCharacterId !== null ? <AudioContentFormPage apiClient={apiClient} characterId={audioContentCreateCharacterId} uploadAuth={uploadAuth} /> : null}
|
||||
{audioContentEditRoute !== null ? <AudioContentFormPage apiClient={apiClient} characterId={audioContentEditRoute.characterId} contentId={audioContentEditRoute.contentId} uploadAuth={uploadAuth} /> : null}
|
||||
{audioContentDetailRoute !== null ? <AudioContentDetailPage apiClient={apiClient} characterId={audioContentDetailRoute.characterId} contentId={audioContentDetailRoute.contentId} /> : null}
|
||||
{communityPostListCharacterId !== null ? <CommunityPostListPage apiClient={apiClient} characterId={communityPostListCharacterId} /> : null}
|
||||
{communityPostCreateCharacterId !== null ? <CommunityPostFormPage apiClient={apiClient} characterId={communityPostCreateCharacterId} /> : null}
|
||||
{fanTalkListCharacterId !== null ? <FanTalkListPage apiClient={apiClient} characterId={fanTalkListCharacterId} /> : null}
|
||||
{seriesListCharacterId !== null ? <SeriesListPage apiClient={apiClient} characterId={seriesListCharacterId} /> : null}
|
||||
{seriesCreateCharacterId !== null ? <SeriesFormPage apiClient={apiClient} characterId={seriesCreateCharacterId} /> : null}
|
||||
{seriesEditRoute !== null ? <SeriesFormPage apiClient={apiClient} characterId={seriesEditRoute.characterId} seriesId={seriesEditRoute.seriesId} /> : null}
|
||||
{seriesOrderCharacterId !== null ? <SeriesOrderPage apiClient={apiClient} characterId={seriesOrderCharacterId} /> : null}
|
||||
{seriesDetailRoute !== null ? <SeriesDetailPage apiClient={apiClient} characterId={seriesDetailRoute.characterId} seriesId={seriesDetailRoute.seriesId} /> : null}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
130
src/layouts/CharacterWorkspaceLayout.test.tsx
Normal file
130
src/layouts/CharacterWorkspaceLayout.test.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { App } from "@/app/App";
|
||||
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
|
||||
import { server } from "@/shared/test/server";
|
||||
|
||||
const apiBaseUrl = "https://api.example.com";
|
||||
const listEnvelope = {
|
||||
success: true,
|
||||
message: null,
|
||||
data: { totalCount: 1, content: [] },
|
||||
errorProperty: null,
|
||||
} as const;
|
||||
const activeDetail = {
|
||||
id: 101,
|
||||
characterUUID: "character-uuid-101",
|
||||
name: "루나",
|
||||
imageUrl: "https://cdn.example.com/luna.png",
|
||||
description: "차분한 상담형 AI 캐릭터",
|
||||
systemPrompt: "친절하게 답한다.",
|
||||
characterType: "Character",
|
||||
age: 24,
|
||||
gender: "여성",
|
||||
mbti: "INFJ",
|
||||
speechPattern: "존댓말",
|
||||
speechStyle: "다정함",
|
||||
appearance: "푸른 머리와 밝은 눈",
|
||||
region: "KR",
|
||||
isActive: true,
|
||||
tags: ["상담"],
|
||||
hobbies: [],
|
||||
values: [],
|
||||
goals: [],
|
||||
relationships: [],
|
||||
personalities: [],
|
||||
backgrounds: [],
|
||||
memories: [],
|
||||
originalWork: { id: 7, imageUrl: null, title: "달빛 상담소" },
|
||||
} as const;
|
||||
const inactiveDetail = { ...activeDetail, id: 202, characterUUID: "character-uuid-202", name: "미카", isActive: false, originalWork: null } as const;
|
||||
|
||||
function saveAdminSession() {
|
||||
authSessionStorage.save({ token: "admin-token", role: "ADMIN" });
|
||||
}
|
||||
|
||||
function useWorkspaceHandlers(detailStatus: 200 | 400 | 404 | 500 = 200) {
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () => HttpResponse.json(listEnvelope)),
|
||||
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/:characterId`, ({ params }) => {
|
||||
if (detailStatus !== 200) {
|
||||
return HttpResponse.json({ success: false, message: "상세 조회 실패", data: null, errorProperty: null }, { status: detailStatus });
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
message: null,
|
||||
data: params.characterId === "202" ? inactiveDetail : activeDetail,
|
||||
errorProperty: null,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
window.history.replaceState({}, "", "/");
|
||||
});
|
||||
|
||||
test("Character workspace restores an active detail from a deep link", async () => {
|
||||
// Given
|
||||
saveAdminSession();
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
useWorkspaceHandlers();
|
||||
window.history.pushState({}, "", "/ai-characters/101");
|
||||
|
||||
// When
|
||||
render(<App />);
|
||||
|
||||
// Then
|
||||
expect(await screen.findByRole("heading", { name: "루나" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("navigation", { name: "캐릭터 워크스페이스 브레드크럼" })).toHaveTextContent(/AI 캐릭터.*루나/);
|
||||
expect(screen.getByLabelText("상태: 공개")).toBeInTheDocument();
|
||||
expect(screen.getByText("characterId: 101")).toBeInTheDocument();
|
||||
expect(screen.getByText("character-uuid-101")).toBeInTheDocument();
|
||||
const workspaceNavigation = screen.getByRole("navigation", { name: "캐릭터 워크스페이스 내비게이션" });
|
||||
expect(workspaceNavigation).toHaveTextContent(/프로필.*오디오 콘텐츠.*시리즈.*커뮤니티.*FanTalk/);
|
||||
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: "프로필" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "프로필" })).toHaveAttribute("aria-current", "page");
|
||||
expect(screen.getByRole("img", { name: "루나 프로필 이미지" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("Character workspace shows read-only policy for inactive detail without mutation entry points", async () => {
|
||||
// Given
|
||||
saveAdminSession();
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
useWorkspaceHandlers();
|
||||
window.history.pushState({}, "", "/ai-characters/202");
|
||||
|
||||
// When
|
||||
render(<App />);
|
||||
|
||||
// Then
|
||||
expect(await screen.findByRole("heading", { name: "미카" })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("상태: 비활성")).toBeInTheDocument();
|
||||
expect(screen.getByText("characterId: 202")).toBeInTheDocument();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("비활성 캐릭터는 읽기 전용입니다.");
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("조회만 가능하며 생성, 수정, 비활성화 같은 쓰기 작업은 실행되지 않습니다.");
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("복원 기능을 지원하지 않습니다.");
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("영구 삭제와 삭제 후 복구도 제공하지 않습니다.");
|
||||
expect(screen.queryByText(/P3-T2|Task|범위/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "저장" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "비활성화" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test.each([400, 404, 500] as const)("Character workspace uses the common error state for detail %s", async (status) => {
|
||||
// Given
|
||||
saveAdminSession();
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
useWorkspaceHandlers(status);
|
||||
window.history.pushState({}, "", "/ai-characters/999");
|
||||
|
||||
// When
|
||||
render(<App />);
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent("상세 조회 실패"));
|
||||
expect(screen.queryByText("비활성 캐릭터는 읽기 전용입니다.")).not.toBeInTheDocument();
|
||||
});
|
||||
73
src/layouts/CharacterWorkspaceLayout.tsx
Normal file
73
src/layouts/CharacterWorkspaceLayout.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { navigateTo } from "@/app/browser-location";
|
||||
import { routePaths } from "@/app/route-paths";
|
||||
import type { CharacterDetail } from "@/features/characters/model/types";
|
||||
import { StatusBadge } from "@/shared/ui/status-badge";
|
||||
|
||||
type CharacterWorkspaceTab = "audio" | "community" | "fanTalks" | "profile" | "series";
|
||||
|
||||
function getTabClass(tab: CharacterWorkspaceTab, activeTab: CharacterWorkspaceTab): string {
|
||||
return tab === activeTab
|
||||
? "rounded-md bg-accent px-4 py-2 font-semibold text-accent-foreground"
|
||||
: "rounded-md px-4 py-2 font-semibold text-muted-foreground hover:bg-accent hover:text-accent-foreground";
|
||||
}
|
||||
|
||||
export function CharacterWorkspaceLayout({ activeTab = "profile", character, children }: { readonly activeTab?: CharacterWorkspaceTab; readonly character: CharacterDetail; readonly children: ReactNode }) {
|
||||
const status = character.isActive ? "OPEN" : "INACTIVE";
|
||||
const profilePath = routePaths.aiCharacterDetail(String(character.id));
|
||||
const audioPath = routePaths.aiCharacterAudioContents(String(character.id));
|
||||
const communityPath = routePaths.aiCharacterCommunityPosts(String(character.id));
|
||||
const fanTalkPath = routePaths.aiCharacterFanTalks(String(character.id));
|
||||
const seriesPath = routePaths.aiCharacterSeries(String(character.id));
|
||||
const workspaceNavigationItems = [
|
||||
{ label: "프로필", path: profilePath, tab: "profile" },
|
||||
{ label: "오디오 콘텐츠", path: audioPath, tab: "audio" },
|
||||
{ label: "시리즈", path: seriesPath, tab: "series" },
|
||||
{ label: "커뮤니티", path: communityPath, tab: "community" },
|
||||
{ label: "FanTalk", path: fanTalkPath, tab: "fanTalks" },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-4" aria-labelledby="character-workspace-title">
|
||||
<nav aria-label="캐릭터 워크스페이스 브레드크럼" className="text-sm text-muted-foreground">
|
||||
<ol className="flex flex-wrap items-center gap-2">
|
||||
<li><a className="font-semibold text-link hover:text-link-hover" href={routePaths.aiCharacters}>AI 캐릭터</a></li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li className="font-semibold text-foreground">{character.name}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<header className="rounded-lg border border-border bg-card p-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
{character.imageUrl === null ? (
|
||||
<div aria-hidden="true" className="grid size-16 place-items-center rounded-lg bg-muted text-xl font-bold text-muted-foreground">{character.name.slice(0, 1)}</div>
|
||||
) : (
|
||||
<img alt={`${character.name} 프로필 이미지`} className="size-16 rounded-lg object-cover" height="64" src={character.imageUrl} width="64" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-semibold text-info">CHARACTER WORKSPACE</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-2xl font-bold leading-tight" id="character-workspace-title">{character.name}</h1>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
<p className="mt-2 text-sm font-semibold text-muted-foreground">characterId: {character.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{character.isActive ? null : (
|
||||
<section className="rounded-lg border border-inactive bg-inactive-surface p-4 text-inactive" role="alert">
|
||||
<h2 className="font-semibold">비활성 캐릭터는 읽기 전용입니다.</h2>
|
||||
<p className="mt-2 text-sm">비활성 상태에서는 조회만 가능하며 생성, 수정, 비활성화 같은 쓰기 작업은 실행되지 않습니다. 현재 화면에서는 복원 기능을 지원하지 않습니다. 영구 삭제와 삭제 후 복구도 제공하지 않습니다.</p>
|
||||
</section>
|
||||
)}
|
||||
<nav className="flex flex-wrap gap-2 rounded-lg border border-border bg-card p-2" aria-label="캐릭터 워크스페이스 내비게이션">
|
||||
{workspaceNavigationItems.map((item) => (
|
||||
<a aria-current={item.tab === activeTab ? "page" : undefined} className={getTabClass(item.tab, activeTab)} href={item.path} key={item.tab} onClick={(event) => { event.preventDefault(); navigateTo(item.path); }}>{item.label}</a>
|
||||
))}
|
||||
</nav>
|
||||
<div>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,52 +1,8 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createPageParams, type PageData } from "../pagination";
|
||||
|
||||
describe("createPageParams", () => {
|
||||
test("uses documented default page and size", () => {
|
||||
// Given
|
||||
const request = {};
|
||||
|
||||
// When
|
||||
const pageParams = createPageParams(request);
|
||||
|
||||
// Then
|
||||
expect(pageParams).toEqual({ page: 0, size: 20 });
|
||||
});
|
||||
|
||||
test("clamps size to the documented lower bound", () => {
|
||||
// Given
|
||||
const request = { size: 1 };
|
||||
|
||||
// When
|
||||
const pageParams = createPageParams(request);
|
||||
|
||||
// Then
|
||||
expect(pageParams.size).toBe(20);
|
||||
});
|
||||
|
||||
test("clamps size to the documented upper bound", () => {
|
||||
// Given
|
||||
const request = { size: 51 };
|
||||
|
||||
// When
|
||||
const pageParams = createPageParams(request);
|
||||
|
||||
// Then
|
||||
expect(pageParams.size).toBe(50);
|
||||
});
|
||||
|
||||
test("keeps a provided page unchanged", () => {
|
||||
// Given
|
||||
const request = { page: 3, size: 20 };
|
||||
|
||||
// When
|
||||
const pageParams = createPageParams(request);
|
||||
|
||||
// Then
|
||||
expect(pageParams.page).toBe(3);
|
||||
});
|
||||
import type { PageData } from "../pagination";
|
||||
|
||||
describe("PageData", () => {
|
||||
test("defines the documented page response shape", () => {
|
||||
// Given
|
||||
const page: PageData<string> = {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export const UNKNOWN_API_ERROR_MESSAGE = "알 수 없는 오류가 발생했습니다.";
|
||||
|
||||
export type ApiErrorOptions = {
|
||||
readonly status: number;
|
||||
readonly message: string;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -5,20 +5,3 @@ export type PageData<Item> = {
|
||||
readonly hasNext: boolean;
|
||||
readonly items: readonly Item[];
|
||||
};
|
||||
|
||||
export type PageParams = {
|
||||
readonly page?: number;
|
||||
readonly size?: number;
|
||||
};
|
||||
|
||||
export function createPageParams(params: PageParams = {}): {
|
||||
readonly page: number;
|
||||
readonly size: number;
|
||||
} {
|
||||
const size = params.size ?? 20;
|
||||
|
||||
return {
|
||||
page: params.page ?? 0,
|
||||
size: Math.min(Math.max(size, 20), 50),
|
||||
};
|
||||
}
|
||||
|
||||
51
src/shared/lib/create-image-crop-source.test.ts
Normal file
51
src/shared/lib/create-image-crop-source.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { expect, test, vi } from "vitest";
|
||||
|
||||
import { createImageCropSource } from "@/shared/lib/create-image-crop-source";
|
||||
|
||||
test("createImageCropSource releases owned Blob URLs exactly once", async () => {
|
||||
const image = new File(["image"], "profile.png", { type: "image/png" });
|
||||
const createObjectURL = vi.fn(() => "blob:profile");
|
||||
const revokeObjectURL = vi.fn();
|
||||
vi.stubGlobal("URL", { createObjectURL, revokeObjectURL });
|
||||
vi.stubGlobal(
|
||||
"Image",
|
||||
class FakeImage extends EventTarget {
|
||||
naturalHeight = 600;
|
||||
naturalWidth = 800;
|
||||
|
||||
set src(_value: string) {
|
||||
queueMicrotask(() => this.dispatchEvent(new Event("load")));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const source = await createImageCropSource(image);
|
||||
|
||||
expect(source).toMatchObject({ file: image, height: 600, previewUrl: "blob:profile", width: 800 });
|
||||
expect(source.release).toBeDefined();
|
||||
if (source.release === undefined) {
|
||||
throw new Error("release missing");
|
||||
}
|
||||
source.release();
|
||||
source.release();
|
||||
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith("blob:profile");
|
||||
});
|
||||
|
||||
test("createImageCropSource releases owned Blob URLs when preview loading fails", async () => {
|
||||
const createObjectURL = vi.fn(() => "blob:broken");
|
||||
const revokeObjectURL = vi.fn();
|
||||
vi.stubGlobal("URL", { createObjectURL, revokeObjectURL });
|
||||
vi.stubGlobal(
|
||||
"Image",
|
||||
class FakeImage extends EventTarget {
|
||||
set src(_value: string) {
|
||||
queueMicrotask(() => this.dispatchEvent(new Event("error")));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await expect(createImageCropSource(new File(["image"], "broken.png", { type: "image/png" }))).rejects.toThrow("Image preview unavailable");
|
||||
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith("blob:broken");
|
||||
});
|
||||
22
src/shared/lib/create-image-crop-source.ts
Normal file
22
src/shared/lib/create-image-crop-source.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";
|
||||
|
||||
export function createImageCropSource(file: File): Promise<CropSourceImage> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
let isReleased = false;
|
||||
const release = () => {
|
||||
if (!isReleased) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
isReleased = true;
|
||||
}
|
||||
};
|
||||
|
||||
image.addEventListener("load", () => resolve({ file, height: image.naturalHeight, previewUrl, release, width: image.naturalWidth }));
|
||||
image.addEventListener("error", () => {
|
||||
release();
|
||||
reject(new Error("Image preview unavailable"));
|
||||
});
|
||||
image.src = previewUrl;
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ export type CropOutputSizeRequest = {
|
||||
readonly noUpscale: boolean;
|
||||
readonly sourceHeight: number;
|
||||
readonly sourceWidth: number;
|
||||
readonly zoom: number;
|
||||
};
|
||||
|
||||
export type CropOutputSize = {
|
||||
@@ -18,6 +19,8 @@ export type CropRenderRequest = {
|
||||
readonly offsetY: number;
|
||||
readonly outputHeight: number;
|
||||
readonly outputWidth: number;
|
||||
readonly previewFrameHeight?: number;
|
||||
readonly previewFrameWidth?: number;
|
||||
readonly previewUrl: string;
|
||||
readonly sourceHeight: number;
|
||||
readonly sourceWidth: number;
|
||||
@@ -28,6 +31,8 @@ export type CropSourceRectRequest = {
|
||||
readonly aspect: number | "free";
|
||||
readonly offsetX: number;
|
||||
readonly offsetY: number;
|
||||
readonly previewFrameHeight?: number;
|
||||
readonly previewFrameWidth?: number;
|
||||
readonly sourceHeight: number;
|
||||
readonly sourceWidth: number;
|
||||
readonly zoom: number;
|
||||
@@ -44,7 +49,15 @@ function getAspect(aspect: number | "free", sourceWidth: number, sourceHeight: n
|
||||
return aspect === "free" ? sourceWidth / sourceHeight : aspect;
|
||||
}
|
||||
|
||||
export function calculateCropSourceRect({ aspect, offsetX, offsetY, sourceHeight, sourceWidth, zoom }: CropSourceRectRequest): CropSourceRect {
|
||||
function scalePreviewOffset(offset: number, sourceSize: number, previewSize: number | undefined, zoom: number): number {
|
||||
if (previewSize === undefined || previewSize <= 0) {
|
||||
return offset / zoom;
|
||||
}
|
||||
|
||||
return (offset * sourceSize) / previewSize / zoom;
|
||||
}
|
||||
|
||||
export function calculateCropSourceRect({ aspect, offsetX, offsetY, previewFrameHeight, previewFrameWidth, sourceHeight, sourceWidth, zoom }: CropSourceRectRequest): CropSourceRect {
|
||||
const cropAspect = getAspect(aspect, sourceWidth, sourceHeight);
|
||||
const sourceAspect = sourceWidth / sourceHeight;
|
||||
const baseWidth = sourceAspect > cropAspect ? Math.round(sourceHeight * cropAspect) : sourceWidth;
|
||||
@@ -53,8 +66,8 @@ export function calculateCropSourceRect({ aspect, offsetX, offsetY, sourceHeight
|
||||
const height = Math.round(baseHeight / zoom);
|
||||
const maxSourceX = sourceWidth - width;
|
||||
const maxSourceY = sourceHeight - height;
|
||||
const centeredX = Math.round((sourceWidth - width) / 2 - offsetX / zoom);
|
||||
const centeredY = Math.round((sourceHeight - height) / 2 - offsetY / zoom);
|
||||
const centeredX = Math.round((sourceWidth - width) / 2 - scalePreviewOffset(offsetX, baseWidth, previewFrameWidth, zoom));
|
||||
const centeredY = Math.round((sourceHeight - height) / 2 - scalePreviewOffset(offsetY, baseHeight, previewFrameHeight, zoom));
|
||||
|
||||
return {
|
||||
height,
|
||||
@@ -64,12 +77,18 @@ export function calculateCropSourceRect({ aspect, offsetX, offsetY, sourceHeight
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateCropOutputSize({ aspect, maxWidth, noUpscale, sourceHeight, sourceWidth }: CropOutputSizeRequest): CropOutputSize {
|
||||
export function calculateCropOutputSize({ aspect, maxWidth, noUpscale, sourceHeight, sourceWidth, zoom }: CropOutputSizeRequest): CropOutputSize {
|
||||
const cropAspect = getAspect(aspect, sourceWidth, sourceHeight);
|
||||
const cropRect = calculateCropSourceRect({ aspect, offsetX: 0, offsetY: 0, sourceHeight, sourceWidth, zoom: 1 });
|
||||
const width = noUpscale ? Math.min(maxWidth, cropRect.width) : maxWidth;
|
||||
const cropRect = calculateCropSourceRect({ aspect, offsetX: 0, offsetY: 0, sourceHeight, sourceWidth, zoom });
|
||||
let width = noUpscale ? Math.min(maxWidth, cropRect.width) : maxWidth;
|
||||
let height = noUpscale && width > 0 ? Math.max(1, Math.floor(width / cropAspect)) : Math.round(width / cropAspect);
|
||||
|
||||
return { height: Math.round(width / cropAspect), width };
|
||||
if (noUpscale && height > cropRect.height) {
|
||||
height = cropRect.height;
|
||||
width = height === 0 ? 0 : Math.min(width, Math.max(1, Math.floor(height * cropAspect)));
|
||||
}
|
||||
|
||||
return { height, width };
|
||||
}
|
||||
|
||||
export function createCroppedImageFile(request: CropRenderRequest): Promise<File> {
|
||||
|
||||
22
src/shared/lib/focus-first-invalid-control.test.ts
Normal file
22
src/shared/lib/focus-first-invalid-control.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { focusFirstInvalidControl } from "@/shared/lib/focus-first-invalid-control";
|
||||
|
||||
test("focusFirstInvalidControl focuses the first enabled invalid control in form order", () => {
|
||||
// Given
|
||||
document.body.innerHTML = `
|
||||
<form>
|
||||
<input aria-invalid="true" disabled />
|
||||
<input aria-invalid="true" />
|
||||
<textarea aria-invalid="true"></textarea>
|
||||
</form>
|
||||
`;
|
||||
|
||||
const form = document.querySelector("form");
|
||||
|
||||
// When
|
||||
focusFirstInvalidControl(form);
|
||||
|
||||
// Then
|
||||
expect(document.activeElement).toBe(document.querySelectorAll("input, textarea")[1]);
|
||||
});
|
||||
14
src/shared/lib/focus-first-invalid-control.ts
Normal file
14
src/shared/lib/focus-first-invalid-control.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export function focusFirstInvalidControl(form: HTMLFormElement | null): void {
|
||||
if (form === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const element of Array.from(form.elements)) {
|
||||
if (!(element instanceof HTMLElement) || element.getAttribute("aria-invalid") !== "true" || element.hasAttribute("disabled")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
element.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
177
src/shared/mocks/__tests__/character-handlers.test.ts
Normal file
177
src/shared/mocks/__tests__/character-handlers.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { z } from "zod";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createApiResponseSchema } from "@/shared/api/types";
|
||||
import { audioContentDetailSchema, audioContentListResponseSchema } from "@/features/audio-contents/model/types";
|
||||
import { createMockHandlers, createMockStore } from "@/shared/mocks/handlers";
|
||||
import { server } from "@/shared/test/server";
|
||||
|
||||
const apiBaseUrl = "https://api.example.com";
|
||||
const adminToken = "mock-admin-jwt";
|
||||
|
||||
const characterDetailPreviewSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
systemPrompt: z.string(),
|
||||
isActive: z.boolean(),
|
||||
});
|
||||
const aiCharactersPreviewSchema = z.object({
|
||||
totalCount: z.number(),
|
||||
content: z.array(z.object({ id: z.number(), name: z.string() })),
|
||||
});
|
||||
const originalWorkPreviewSchema = z.object({ id: z.number(), title: z.string(), imageUrl: z.string().nullable() });
|
||||
const originalWorkSearchItemSchema = z.object({
|
||||
id: z.number().int(),
|
||||
title: z.string(),
|
||||
contentType: z.string(),
|
||||
category: z.string(),
|
||||
isAdult: z.boolean(),
|
||||
description: z.string(),
|
||||
originalWork: z.string().nullable(),
|
||||
originalLink: z.string().nullable(),
|
||||
writer: z.string().nullable(),
|
||||
studio: z.string().nullable(),
|
||||
originalLinks: z.array(z.string()),
|
||||
tags: z.array(z.string()),
|
||||
imageUrl: z.string().nullable(),
|
||||
});
|
||||
const nullSuccessSchema = z.null();
|
||||
|
||||
function requireData<Data>(data: Data | null): Data {
|
||||
if (data === null) {
|
||||
throw new Error("response data missing");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function useMockHandlers() {
|
||||
server.use(...createMockHandlers(createMockStore(), apiBaseUrl));
|
||||
}
|
||||
|
||||
function authorizedFetch(path: string, init: RequestInit = {}) {
|
||||
return fetch(`${apiBaseUrl}${path}`, {
|
||||
...init,
|
||||
headers: { Authorization: `Bearer ${adminToken}`, ...init.headers },
|
||||
});
|
||||
}
|
||||
|
||||
function mutationInit(request: object): RequestInit {
|
||||
const boundary = "test-boundary";
|
||||
|
||||
return {
|
||||
headers: { "Content-Type": `multipart/form-data; boundary=${boundary}` },
|
||||
body: `--${boundary}\r\nContent-Disposition: form-data; name="request"\r\nContent-Type: application/json\r\n\r\n${JSON.stringify(request)}\r\n--${boundary}--\r\n`,
|
||||
};
|
||||
}
|
||||
|
||||
describe("mock character handlers", () => {
|
||||
test("P10-T1 original work search handler serves v2 full DTO and rejects missing searchTerm", async () => {
|
||||
// Given
|
||||
useMockHandlers();
|
||||
|
||||
// When
|
||||
const missingResponse = await authorizedFetch("/api/v2/admin/ai-characters/original-works/search");
|
||||
const searchResponse = await authorizedFetch("/api/v2/admin/ai-characters/original-works/search?searchTerm=%EB%8B%AC%EB%B9%9B");
|
||||
|
||||
// Then
|
||||
expect(missingResponse.status).toBe(400);
|
||||
expect(requireData(createApiResponseSchema(z.array(originalWorkSearchItemSchema)).parse(await searchResponse.json()).data)).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 7,
|
||||
title: "달빛 상담소",
|
||||
contentType: "WEBTOON",
|
||||
category: "힐링",
|
||||
isAdult: false,
|
||||
originalWork: "Moonlight Office",
|
||||
originalLinks: ["https://example.com/moonlight"],
|
||||
tags: ["상담", "힐링"],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("create, update, and deactivate mutate list and detail contract responses", async () => {
|
||||
// Given
|
||||
useMockHandlers();
|
||||
|
||||
// When
|
||||
const createResponse = await authorizedFetch("/api/v2/admin/ai-characters", {
|
||||
method: "POST",
|
||||
...mutationInit({ name: "노아", systemPrompt: "명확하게 답한다.", description: "새 안내형 캐릭터" }),
|
||||
});
|
||||
const createdListResponse = await authorizedFetch("/api/v2/admin/ai-characters?page=0&size=20");
|
||||
const createdList = requireData(createApiResponseSchema(aiCharactersPreviewSchema).parse(await createdListResponse.json()).data);
|
||||
const created = createdList.content.find((character) => character.name === "노아");
|
||||
if (created === undefined) {
|
||||
throw new Error("created character missing from list");
|
||||
}
|
||||
const updateResponse = await authorizedFetch(`/api/v2/admin/ai-characters/${created.id}`, {
|
||||
method: "PUT",
|
||||
...mutationInit({ name: "노아 수정", systemPrompt: "짧게 답한다.", description: "수정된 안내형 캐릭터" }),
|
||||
});
|
||||
const updatedDetailResponse = await authorizedFetch(`/api/v2/admin/ai-characters/${created.id}`);
|
||||
const deactivateResponse = await authorizedFetch(`/api/v2/admin/ai-characters/${created.id}`, {
|
||||
method: "PUT",
|
||||
...mutationInit({ isActive: false }),
|
||||
});
|
||||
const finalListResponse = await authorizedFetch("/api/v2/admin/ai-characters?page=0&size=20");
|
||||
|
||||
// Then
|
||||
expect(createApiResponseSchema(nullSuccessSchema).parse(await createResponse.json()).data).toBeNull();
|
||||
expect(createApiResponseSchema(nullSuccessSchema).parse(await updateResponse.json()).data).toBeNull();
|
||||
expect(requireData(createApiResponseSchema(characterDetailPreviewSchema).parse(await updatedDetailResponse.json()).data)).toEqual({
|
||||
id: created.id,
|
||||
name: "노아 수정",
|
||||
description: "수정된 안내형 캐릭터",
|
||||
systemPrompt: "짧게 답한다.",
|
||||
isActive: true,
|
||||
});
|
||||
expect(createApiResponseSchema(nullSuccessSchema).parse(await deactivateResponse.json()).data).toBeNull();
|
||||
expect(requireData(createApiResponseSchema(aiCharactersPreviewSchema).parse(await finalListResponse.json()).data).content).not.toContainEqual(expect.objectContaining({ id: created.id }));
|
||||
});
|
||||
|
||||
test("P10-T1 create and update persist selected originalWorkId and clear it when omitted", async () => {
|
||||
// Given
|
||||
useMockHandlers();
|
||||
|
||||
// When
|
||||
const createResponse = await authorizedFetch("/api/v2/admin/ai-characters", {
|
||||
method: "POST",
|
||||
...mutationInit({ name: "원작 캐릭터", systemPrompt: "명확하게 답한다.", description: "원작 연결", originalWorkId: 7 }),
|
||||
});
|
||||
const createdListResponse = await authorizedFetch("/api/v2/admin/ai-characters?page=0&size=20");
|
||||
const createdList = requireData(createApiResponseSchema(aiCharactersPreviewSchema).parse(await createdListResponse.json()).data);
|
||||
const created = createdList.content.find((character) => character.name === "원작 캐릭터");
|
||||
if (created === undefined) {
|
||||
throw new Error("created character missing from list");
|
||||
}
|
||||
const createdDetailResponse = await authorizedFetch(`/api/v2/admin/ai-characters/${created.id}`);
|
||||
const updateResponse = await authorizedFetch(`/api/v2/admin/ai-characters/${created.id}`, {
|
||||
method: "PUT",
|
||||
...mutationInit({ name: "원작 해제 캐릭터", systemPrompt: "짧게 답한다.", description: "원작 해제" }),
|
||||
});
|
||||
const updatedDetailResponse = await authorizedFetch(`/api/v2/admin/ai-characters/${created.id}`);
|
||||
|
||||
// Then
|
||||
expect(createApiResponseSchema(nullSuccessSchema).parse(await createResponse.json()).data).toBeNull();
|
||||
expect(requireData(createApiResponseSchema(z.object({ originalWork: originalWorkPreviewSchema.nullable() })).parse(await createdDetailResponse.json()).data).originalWork).toEqual({ id: 7, title: "달빛 상담소", imageUrl: null });
|
||||
expect(createApiResponseSchema(nullSuccessSchema).parse(await updateResponse.json()).data).toBeNull();
|
||||
expect(requireData(createApiResponseSchema(z.object({ originalWork: originalWorkPreviewSchema.nullable() })).parse(await updatedDetailResponse.json()).data).originalWork).toBeNull();
|
||||
});
|
||||
|
||||
test("audio content mock handlers expose contract-shaped list and detail without timezone", async () => {
|
||||
// Given
|
||||
useMockHandlers();
|
||||
|
||||
// When
|
||||
const listResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/audio-contents?search_word=달빛&page=0&size=20");
|
||||
const detailResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/audio-contents/9001");
|
||||
|
||||
// Then
|
||||
const list = requireData(createApiResponseSchema(audioContentListResponseSchema).parse(await listResponse.json()).data);
|
||||
const detail = requireData(createApiResponseSchema(audioContentDetailSchema).parse(await detailResponse.json()).data);
|
||||
expect(list).toMatchObject({ totalCount: 1, items: [{ audioContentId: 9001, title: "달빛 상담 오디오", coverImageUrl: expect.stringMatching(/^data:image\//) }] });
|
||||
expect(detail).toMatchObject({ contentId: 9001, title: "달빛 상담 오디오", coverImageUrl: expect.stringMatching(/^data:image\//), contentUrl: expect.stringMatching(/^data:audio\//), duration: "00:01" });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
126
src/shared/mocks/audio-content-fixtures.ts
Normal file
126
src/shared/mocks/audio-content-fixtures.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { AudioContentDetail, AudioContentListItem } from "@/features/audio-contents/model/types";
|
||||
import type { AudioContentTheme } from "@/features/audio-contents/schemas/audio-content-schema";
|
||||
|
||||
function writeAscii(bytes: Uint8Array, offset: number, value: string): void {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
bytes[offset + index] = value.charCodeAt(index);
|
||||
}
|
||||
}
|
||||
|
||||
function createOneSecondWavDataUrl(): string {
|
||||
const sampleRate = 8_000;
|
||||
const bytesPerSample = 2;
|
||||
const dataSize = sampleRate * bytesPerSample;
|
||||
const bytes = new Uint8Array(44 + dataSize);
|
||||
const view = new DataView(bytes.buffer);
|
||||
writeAscii(bytes, 0, "RIFF");
|
||||
view.setUint32(4, 36 + dataSize, true);
|
||||
writeAscii(bytes, 8, "WAVEfmt ");
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, 1, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * bytesPerSample, true);
|
||||
view.setUint16(32, bytesPerSample, true);
|
||||
view.setUint16(34, 16, true);
|
||||
writeAscii(bytes, 36, "data");
|
||||
view.setUint32(40, dataSize, true);
|
||||
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
|
||||
return `data:audio/wav;base64,${btoa(binary)}`;
|
||||
}
|
||||
|
||||
export const previewAudioUrl = createOneSecondWavDataUrl();
|
||||
const previewCoverImageUrl = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='128' height='128' viewBox='0 0 128 128'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop stop-color='%23D9F6FF'/%3E%3Cstop offset='1' stop-color='%2300BDF7'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='128' height='128' rx='20' fill='url(%23g)'/%3E%3Ccircle cx='64' cy='64' r='30' fill='%23FFFFFF' fill-opacity='.72'/%3E%3Cpath d='M52 48v32l30-16z' fill='%23062B36'/%3E%3C/svg%3E";
|
||||
|
||||
export const mockAudioContentThemes = [
|
||||
{ id: 7, theme: "힐링", image: previewCoverImageUrl },
|
||||
{ id: 8, theme: "안내", image: previewCoverImageUrl },
|
||||
] as const satisfies readonly AudioContentTheme[];
|
||||
|
||||
export const mockAudioContentListItems = [
|
||||
{
|
||||
audioContentId: 9001,
|
||||
title: "달빛 상담 오디오",
|
||||
detail: "잠들기 전 듣는 상담 오디오",
|
||||
coverImageUrl: previewCoverImageUrl,
|
||||
creatorNickname: "루나",
|
||||
theme: "힐링",
|
||||
price: 1000,
|
||||
totalContentCount: 5,
|
||||
remainingContentCount: 4,
|
||||
isAdult: false,
|
||||
isPointAvailable: true,
|
||||
isCommentAvailable: true,
|
||||
remainingTime: "7일",
|
||||
contentUrl: previewAudioUrl,
|
||||
date: "2026-07-28T01:00:00Z",
|
||||
releaseDate: "2026-07-28T01:00:00Z",
|
||||
tags: "상담,힐링",
|
||||
},
|
||||
{
|
||||
audioContentId: 9002,
|
||||
title: "아침 안내 오디오",
|
||||
detail: "하루를 시작하는 안내",
|
||||
coverImageUrl: previewCoverImageUrl,
|
||||
creatorNickname: "루나",
|
||||
theme: "안내",
|
||||
price: 0,
|
||||
totalContentCount: null,
|
||||
remainingContentCount: null,
|
||||
isAdult: false,
|
||||
isPointAvailable: false,
|
||||
isCommentAvailable: false,
|
||||
remainingTime: "",
|
||||
contentUrl: previewAudioUrl,
|
||||
date: "2026-07-27 09:00:00",
|
||||
releaseDate: null,
|
||||
tags: "안내",
|
||||
},
|
||||
] as const satisfies readonly AudioContentListItem[];
|
||||
|
||||
export const mockAudioContentDetails = [
|
||||
{
|
||||
contentId: 9001,
|
||||
title: "달빛 상담 오디오",
|
||||
detail: "잠들기 전 듣는 상담 오디오",
|
||||
languageCode: "ko",
|
||||
coverImageUrl: previewCoverImageUrl,
|
||||
contentUrl: previewAudioUrl,
|
||||
themeStr: "힐링",
|
||||
tag: "상담,힐링",
|
||||
price: 1000,
|
||||
duration: "00:01",
|
||||
releaseDate: "2026-07-28T01:00:00Z",
|
||||
totalContentCount: 5,
|
||||
remainingContentCount: 4,
|
||||
orderSequence: 1,
|
||||
isActivePreview: true,
|
||||
isAdult: false,
|
||||
isMosaic: false,
|
||||
isOnlyRental: false,
|
||||
existOrdered: false,
|
||||
purchaseOption: "RENT_ONLY",
|
||||
orderType: null,
|
||||
remainingTime: "7일",
|
||||
creatorOtherContentList: [],
|
||||
sameThemeOtherContentList: [],
|
||||
isCommentAvailable: true,
|
||||
isLike: false,
|
||||
likeCount: 0,
|
||||
commentList: [],
|
||||
commentCount: 0,
|
||||
isPin: false,
|
||||
isAvailablePin: false,
|
||||
creator: { creatorId: 101, nickname: "루나", profileImageUrl: "https://cdn.example.com/mock/luna.png", isFollowing: false, isFollow: false, isNotify: false },
|
||||
previousContent: null,
|
||||
nextContent: null,
|
||||
buyerList: [],
|
||||
isAvailableUsePoint: true,
|
||||
translated: null,
|
||||
},
|
||||
] as const satisfies readonly AudioContentDetail[];
|
||||
133
src/shared/mocks/audio-content-handlers.ts
Normal file
133
src/shared/mocks/audio-content-handlers.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import type { RequestHandler } from "msw";
|
||||
|
||||
import type { AudioContentCreateRequest, AudioContentDeactivateRequest, AudioContentTheme, AudioContentUpdateRequest } from "@/features/audio-contents/schemas/audio-content-schema";
|
||||
import { error, ok } from "@/shared/mocks/contract";
|
||||
|
||||
type AccessResponse = (request: Request) => Response | null;
|
||||
|
||||
type AudioContentStore = {
|
||||
readonly createAudioContent: (characterId: string, request: AudioContentCreateRequest) => number | null;
|
||||
readonly deactivateAudioContent: (characterId: string, contentId: string, request: AudioContentDeactivateRequest) => boolean;
|
||||
readonly getAudioContent: (characterId: string, contentId: string) => unknown | null;
|
||||
readonly listAudioContentThemes: () => readonly AudioContentTheme[];
|
||||
readonly listAudioContents: (characterId: string, searchWord: string | null, page: number, size: number) => unknown | null;
|
||||
readonly updateAudioContent: (characterId: string, contentId: string, request: AudioContentUpdateRequest) => boolean;
|
||||
};
|
||||
|
||||
type AudioContentMockHandlerOptions = {
|
||||
readonly apiBaseUrl: string;
|
||||
readonly invalidRequestMessage: string;
|
||||
readonly parseCreateRequest: (request: Request) => Promise<AudioContentCreateRequest | null>;
|
||||
readonly parseDeactivateRequest: (request: Request) => Promise<AudioContentDeactivateRequest | null>;
|
||||
readonly parseUpdateRequest: (request: Request) => Promise<AudioContentUpdateRequest | null>;
|
||||
};
|
||||
|
||||
function endpointUrl(apiBaseUrl: string, path: string): string {
|
||||
return new URL(path, apiBaseUrl).toString();
|
||||
}
|
||||
|
||||
function parseListQuery(request: Request): { readonly page: number; readonly searchWord: string | null; readonly size: number } | null {
|
||||
const url = new URL(request.url);
|
||||
const page = Number(url.searchParams.get("page") ?? "0");
|
||||
const size = Number(url.searchParams.get("size") ?? "20");
|
||||
const searchWord = url.searchParams.get("search_word");
|
||||
if (!Number.isInteger(page) || page < 0 || !Number.isInteger(size) || size < 1 || (searchWord !== null && searchWord.trim().length < 2)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { page, searchWord, size };
|
||||
}
|
||||
|
||||
export function createAudioContentMockHandlers(store: AudioContentStore, accessResponse: AccessResponse, options: AudioContentMockHandlerOptions): readonly RequestHandler[] {
|
||||
return [
|
||||
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/audio-content-themes"), ({ request }) => {
|
||||
const deniedResponse = accessResponse(request);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok(store.listAudioContentThemes()));
|
||||
}),
|
||||
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/audio-contents"), ({ params, request }) => {
|
||||
const deniedResponse = accessResponse(request);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
const query = parseListQuery(request);
|
||||
if (query === null) {
|
||||
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
|
||||
const characterId = typeof params.characterId === "string" ? params.characterId : "";
|
||||
const audioContents = store.listAudioContents(characterId, query.searchWord, query.page, query.size);
|
||||
if (audioContents === null) {
|
||||
return HttpResponse.json(error("AI 캐릭터를 찾을 수 없습니다."), { status: 404 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok(audioContents));
|
||||
}),
|
||||
http.post(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/audio-contents"), async ({ params, request }) => {
|
||||
const deniedResponse = accessResponse(request);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
const mutation = await options.parseCreateRequest(request);
|
||||
if (mutation === null) {
|
||||
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
|
||||
const characterId = typeof params.characterId === "string" ? params.characterId : "";
|
||||
const contentId = store.createAudioContent(characterId, mutation);
|
||||
if (contentId === null) {
|
||||
return HttpResponse.json(error("AI 캐릭터 또는 오디오 테마를 찾을 수 없습니다."), { status: 404 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok({ contentId }));
|
||||
}),
|
||||
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/audio-contents/:contentId"), ({ params, request }) => {
|
||||
const deniedResponse = accessResponse(request);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
|
||||
const characterId = typeof params.characterId === "string" ? params.characterId : "";
|
||||
const contentId = typeof params.contentId === "string" ? params.contentId : "";
|
||||
const audioContent = store.getAudioContent(characterId, contentId);
|
||||
if (audioContent === null) {
|
||||
return HttpResponse.json(error("오디오 콘텐츠를 찾을 수 없습니다."), { status: 404 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok(audioContent));
|
||||
}),
|
||||
http.put(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/audio-contents/:contentId"), async ({ params, request }) => {
|
||||
const deniedResponse = accessResponse(request);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
const updateMutation = await options.parseUpdateRequest(request.clone());
|
||||
if (updateMutation !== null) {
|
||||
const characterId = typeof params.characterId === "string" ? params.characterId : "";
|
||||
const contentId = typeof params.contentId === "string" ? params.contentId : "";
|
||||
if (!store.updateAudioContent(characterId, contentId, updateMutation)) {
|
||||
return HttpResponse.json(error("오디오 콘텐츠를 찾을 수 없습니다."), { status: 404 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok(null));
|
||||
}
|
||||
|
||||
const deactivateMutation = await options.parseDeactivateRequest(request);
|
||||
if (deactivateMutation === null) {
|
||||
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
|
||||
const characterId = typeof params.characterId === "string" ? params.characterId : "";
|
||||
const contentId = typeof params.contentId === "string" ? params.contentId : "";
|
||||
if (!store.deactivateAudioContent(characterId, contentId, deactivateMutation)) {
|
||||
return HttpResponse.json(error("오디오 콘텐츠를 찾을 수 없습니다."), { status: 404 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok(null));
|
||||
}),
|
||||
];
|
||||
}
|
||||
228
src/shared/mocks/audio-content-mock-store.ts
Normal file
228
src/shared/mocks/audio-content-mock-store.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { AudioContentDetail, AudioContentListItem } from "@/features/audio-contents/model/types";
|
||||
import { audioContentCreateRequestSchema, audioContentDeactivateRequestSchema, audioContentUpdateRequestSchema } from "@/features/audio-contents/schemas/audio-content-schema";
|
||||
import type { AudioContentCreateRequest, AudioContentDeactivateRequest, AudioContentTheme, AudioContentUpdateRequest } from "@/features/audio-contents/schemas/audio-content-schema";
|
||||
import type { CharacterDetail } from "@/features/characters/model/types";
|
||||
import { mockAudioContentDetails, mockAudioContentListItems, mockAudioContentThemes } from "@/shared/mocks/audio-content-fixtures";
|
||||
|
||||
type GetCharacter = (characterId: string) => CharacterDetail | null;
|
||||
|
||||
export class AudioContentMockStore {
|
||||
#audioContentDetails: AudioContentDetail[] = mockAudioContentDetails.map(toMutableAudioDetail);
|
||||
#audioContents: AudioContentListItem[] = mockAudioContentListItems.map(toMutableAudioListItem);
|
||||
#nextAudioContentId = 9300;
|
||||
readonly #getCharacter: GetCharacter;
|
||||
|
||||
constructor(getCharacter: GetCharacter) {
|
||||
this.#getCharacter = getCharacter;
|
||||
}
|
||||
|
||||
listAudioContents(characterId: string, searchWord: string | null, page: number, size: number) {
|
||||
if (this.#getCharacter(characterId) === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedSearch = searchWord?.trim().toLowerCase() ?? "";
|
||||
const filteredAudioContents = normalizedSearch.length === 0
|
||||
? this.#audioContents
|
||||
: this.#audioContents.filter((audio) => `${audio.title} ${audio.detail} ${audio.theme} ${audio.tags}`.toLowerCase().includes(normalizedSearch));
|
||||
const start = page * size;
|
||||
|
||||
return {
|
||||
totalCount: filteredAudioContents.length,
|
||||
items: filteredAudioContents.slice(start, start + size),
|
||||
};
|
||||
}
|
||||
|
||||
getAudioContent(characterId: string, contentId: string) {
|
||||
if (this.#getCharacter(characterId) === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.#audioContentDetails.find((audio) => String(audio.contentId) === contentId) ?? null;
|
||||
}
|
||||
|
||||
listAudioContentThemes(): readonly AudioContentTheme[] {
|
||||
return mockAudioContentThemes;
|
||||
}
|
||||
|
||||
createAudioContent(characterId: string, request: AudioContentCreateRequest): number | null {
|
||||
const character = this.#getCharacter(characterId);
|
||||
if (character === null) {
|
||||
return null;
|
||||
}
|
||||
const theme = mockAudioContentThemes.find((item) => item.id === request.themeId);
|
||||
if (theme === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.#nextAudioContentId += 1;
|
||||
const contentId = this.#nextAudioContentId;
|
||||
const listItem: AudioContentListItem = {
|
||||
audioContentId: contentId,
|
||||
title: request.title,
|
||||
detail: request.detail,
|
||||
coverImageUrl: theme.image,
|
||||
creatorNickname: character.name,
|
||||
theme: theme.theme,
|
||||
price: request.price,
|
||||
totalContentCount: request.limited,
|
||||
remainingContentCount: request.limited,
|
||||
isAdult: request.isAdult,
|
||||
isPointAvailable: request.isPointAvailable,
|
||||
isCommentAvailable: request.isCommentAvailable,
|
||||
remainingTime: "",
|
||||
contentUrl: mockAudioContentListItems[0].contentUrl,
|
||||
date: "2026-07-28 12:00:00",
|
||||
releaseDate: request.releaseDate,
|
||||
tags: request.tags,
|
||||
};
|
||||
const detail: AudioContentDetail = {
|
||||
...mockAudioContentDetails[0],
|
||||
contentId,
|
||||
title: request.title,
|
||||
detail: request.detail,
|
||||
languageCode: request.languageCode,
|
||||
coverImageUrl: theme.image,
|
||||
contentUrl: listItem.contentUrl,
|
||||
themeStr: theme.theme,
|
||||
tag: request.tags,
|
||||
price: request.price,
|
||||
releaseDate: request.releaseDate,
|
||||
totalContentCount: request.limited,
|
||||
remainingContentCount: request.limited,
|
||||
isAdult: request.isAdult,
|
||||
purchaseOption: request.purchaseOption,
|
||||
isCommentAvailable: request.isCommentAvailable,
|
||||
isAvailableUsePoint: request.isPointAvailable,
|
||||
creator: { ...mockAudioContentDetails[0].creator, creatorId: character.id, nickname: character.name },
|
||||
};
|
||||
this.#audioContents = [...this.#audioContents, listItem];
|
||||
this.#audioContentDetails = [...this.#audioContentDetails, detail];
|
||||
|
||||
return contentId;
|
||||
}
|
||||
|
||||
updateAudioContent(characterId: string, contentId: string, request: AudioContentUpdateRequest): boolean {
|
||||
if (this.#getCharacter(characterId) === null) {
|
||||
return false;
|
||||
}
|
||||
const current = this.getAudioContent(characterId, contentId);
|
||||
if (current === null) {
|
||||
return false;
|
||||
}
|
||||
const nextDetail: AudioContentDetail = {
|
||||
...current,
|
||||
title: request.title ?? current.title,
|
||||
detail: request.detail ?? current.detail,
|
||||
tag: request.tags ?? current.tag,
|
||||
price: request.price ?? current.price,
|
||||
isAdult: request.isAdult ?? current.isAdult,
|
||||
isCommentAvailable: request.isCommentAvailable ?? current.isCommentAvailable,
|
||||
isAvailableUsePoint: request.isPointAvailable ?? current.isAvailableUsePoint,
|
||||
};
|
||||
this.#audioContentDetails = this.#audioContentDetails.map((audio) => (audio.contentId === nextDetail.contentId ? nextDetail : audio));
|
||||
this.#audioContents = this.#audioContents.map((audio) => (String(audio.audioContentId) === contentId ? {
|
||||
...audio,
|
||||
title: nextDetail.title,
|
||||
detail: nextDetail.detail,
|
||||
tags: nextDetail.tag,
|
||||
price: nextDetail.price,
|
||||
isAdult: nextDetail.isAdult,
|
||||
isCommentAvailable: nextDetail.isCommentAvailable,
|
||||
isPointAvailable: nextDetail.isAvailableUsePoint,
|
||||
} : audio));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
deactivateAudioContent(characterId: string, contentId: string, request: AudioContentDeactivateRequest): boolean {
|
||||
if (!request.isActive) {
|
||||
if (this.#getCharacter(characterId) === null) {
|
||||
return false;
|
||||
}
|
||||
const current = this.getAudioContent(characterId, contentId);
|
||||
if (current === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.#audioContents = this.#audioContents.filter((audio) => String(audio.audioContentId) !== contentId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function parseAudioContentCreateRequest(request: Request): Promise<AudioContentCreateRequest | null> {
|
||||
try {
|
||||
const body = await request.text();
|
||||
if (!body.includes('name="contentFile"') || !body.includes('name="coverImage"') || body.includes('name="audioFile"')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return audioContentCreateRequestSchema.parse(JSON.parse(extractMultipartJsonRequest(body)));
|
||||
} catch (parseError) {
|
||||
if (parseError instanceof SyntaxError || parseError instanceof z.ZodError || parseError instanceof Error && parseError.message === "missing request part") {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw parseError;
|
||||
}
|
||||
}
|
||||
|
||||
export async function parseAudioContentUpdateRequest(request: Request): Promise<AudioContentUpdateRequest | null> {
|
||||
try {
|
||||
const body = await request.text();
|
||||
if (body.includes('name="contentFile"') || body.includes('name="audioFile"')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return audioContentUpdateRequestSchema.parse(JSON.parse(extractMultipartJsonRequest(body)));
|
||||
} catch (parseError) {
|
||||
if (parseError instanceof SyntaxError || parseError instanceof z.ZodError || parseError instanceof Error && parseError.message === "missing request part") {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw parseError;
|
||||
}
|
||||
}
|
||||
|
||||
export async function parseAudioContentDeactivateRequest(request: Request): Promise<AudioContentDeactivateRequest | null> {
|
||||
try {
|
||||
const body = await request.text();
|
||||
return audioContentDeactivateRequestSchema.parse(JSON.parse(extractMultipartJsonRequest(body)));
|
||||
} catch (parseError) {
|
||||
if (parseError instanceof SyntaxError || parseError instanceof z.ZodError || parseError instanceof Error && parseError.message === "missing request part") {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw parseError;
|
||||
}
|
||||
}
|
||||
|
||||
function extractMultipartJsonRequest(body: string): string {
|
||||
const match = /name="request"(?:; filename="[^"]*")?\r\n(?:Content-Type: application\/json\r\n)?\r\n(?<json>.*?)\r\n--/s.exec(body);
|
||||
const json = match?.groups?.json;
|
||||
if (json === undefined) {
|
||||
throw new Error("missing request part");
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
function toMutableAudioListItem(audio: (typeof mockAudioContentListItems)[number]): AudioContentListItem {
|
||||
return { ...audio };
|
||||
}
|
||||
|
||||
function toMutableAudioDetail(audio: (typeof mockAudioContentDetails)[number]): AudioContentDetail {
|
||||
return {
|
||||
...audio,
|
||||
creatorOtherContentList: [...audio.creatorOtherContentList],
|
||||
sameThemeOtherContentList: [...audio.sameThemeOtherContentList],
|
||||
commentList: [...audio.commentList],
|
||||
buyerList: [...audio.buyerList],
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,5 +7,5 @@ let worker: ReturnType<typeof setupWorker> | null = null;
|
||||
|
||||
export async function startMockWorker(): Promise<void> {
|
||||
worker ??= setupWorker(...createMockHandlers(createMockStore(), getRuntimeEnv().apiBaseUrl));
|
||||
await worker.start({ onUnhandledRequest: "error" });
|
||||
await worker.start({ onUnhandledRequest: "error", quiet: true });
|
||||
}
|
||||
|
||||
146
src/shared/mocks/character-fixtures.ts
Normal file
146
src/shared/mocks/character-fixtures.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
export const mockActiveCharacters = [
|
||||
{
|
||||
id: 101,
|
||||
name: "루나",
|
||||
imageUrl: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64'%3E%3Crect width='64' height='64' rx='12' fill='%2300BDF7'/%3E%3Ctext x='32' y='39' text-anchor='middle' font-size='24' font-family='sans-serif' fill='%23062B36'%3EL%3C/text%3E%3C/svg%3E",
|
||||
description: "차분한 상담형 AI 캐릭터",
|
||||
gender: "여성",
|
||||
age: 24,
|
||||
mbti: "INFJ",
|
||||
speechStyle: "다정함",
|
||||
speechPattern: "존댓말",
|
||||
region: "KR",
|
||||
tags: ["상담", "힐링"],
|
||||
createdAt: "2026-07-28 10:00:00",
|
||||
updatedAt: "2026-07-28 11:00:00",
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
name: "테오",
|
||||
imageUrl: null,
|
||||
description: "명랑한 안내형 AI 캐릭터",
|
||||
gender: "남성",
|
||||
age: 28,
|
||||
mbti: "ENFP",
|
||||
speechStyle: "경쾌함",
|
||||
speechPattern: "반말",
|
||||
region: "KR",
|
||||
tags: ["안내", "친근함"],
|
||||
createdAt: "2026-07-27 09:00:00",
|
||||
updatedAt: "2026-07-28 09:30:00",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const mockCharacterDetails = [
|
||||
{
|
||||
id: 101,
|
||||
characterUUID: "character-uuid-101",
|
||||
name: "루나",
|
||||
imageUrl: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64'%3E%3Crect width='64' height='64' rx='12' fill='%2300BDF7'/%3E%3Ctext x='32' y='39' text-anchor='middle' font-size='24' font-family='sans-serif' fill='%23062B36'%3EL%3C/text%3E%3C/svg%3E",
|
||||
description: "차분한 상담형 AI 캐릭터",
|
||||
systemPrompt: "친절하고 안전하게 답한다.",
|
||||
characterType: "Character",
|
||||
age: 24,
|
||||
gender: "여성",
|
||||
mbti: "INFJ",
|
||||
speechPattern: "존댓말",
|
||||
speechStyle: "다정함",
|
||||
appearance: "푸른 머리와 밝은 눈",
|
||||
region: "KR",
|
||||
isActive: true,
|
||||
tags: ["상담", "힐링"],
|
||||
hobbies: [],
|
||||
values: [],
|
||||
goals: [],
|
||||
relationships: [],
|
||||
personalities: [],
|
||||
backgrounds: [],
|
||||
memories: [],
|
||||
originalWork: { id: 7, imageUrl: null, title: "달빛 상담소" },
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
characterUUID: "character-uuid-102",
|
||||
name: "테오",
|
||||
imageUrl: null,
|
||||
description: "명랑한 안내형 AI 캐릭터",
|
||||
systemPrompt: "짧고 명확하게 안내한다.",
|
||||
characterType: "Character",
|
||||
age: 28,
|
||||
gender: "남성",
|
||||
mbti: "ENFP",
|
||||
speechPattern: "반말",
|
||||
speechStyle: "경쾌함",
|
||||
appearance: null,
|
||||
region: "KR",
|
||||
isActive: true,
|
||||
tags: ["안내", "친근함"],
|
||||
hobbies: [],
|
||||
values: [],
|
||||
goals: [],
|
||||
relationships: [],
|
||||
personalities: [],
|
||||
backgrounds: [],
|
||||
memories: [],
|
||||
originalWork: null,
|
||||
},
|
||||
{
|
||||
id: 202,
|
||||
characterUUID: "character-uuid-202",
|
||||
name: "미카",
|
||||
imageUrl: null,
|
||||
description: "비활성 검증용 AI 캐릭터",
|
||||
systemPrompt: "읽기 전용 상태를 검증한다.",
|
||||
characterType: "Character",
|
||||
age: null,
|
||||
gender: null,
|
||||
mbti: null,
|
||||
speechPattern: null,
|
||||
speechStyle: null,
|
||||
appearance: null,
|
||||
region: "KR",
|
||||
isActive: false,
|
||||
tags: [],
|
||||
hobbies: [],
|
||||
values: [],
|
||||
goals: [],
|
||||
relationships: [],
|
||||
personalities: [],
|
||||
backgrounds: [],
|
||||
memories: [],
|
||||
originalWork: null,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const mockOriginalWorks = [
|
||||
{
|
||||
id: 7,
|
||||
title: "달빛 상담소",
|
||||
contentType: "WEBTOON",
|
||||
category: "힐링",
|
||||
isAdult: false,
|
||||
description: "차분한 상담 원작",
|
||||
originalWork: "Moonlight Office",
|
||||
originalLink: "https://example.com/moonlight",
|
||||
writer: "하린",
|
||||
studio: "소다스튜디오",
|
||||
originalLinks: ["https://example.com/moonlight"],
|
||||
tags: ["상담", "힐링"],
|
||||
imageUrl: null,
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
title: "별빛 기록실",
|
||||
contentType: "NOVEL",
|
||||
category: "드라마",
|
||||
isAdult: false,
|
||||
description: "기록형 원작",
|
||||
originalWork: null,
|
||||
originalLink: null,
|
||||
writer: "이든",
|
||||
studio: "소다스튜디오",
|
||||
originalLinks: [],
|
||||
tags: ["기록"],
|
||||
imageUrl: null,
|
||||
},
|
||||
] as const;
|
||||
278
src/shared/mocks/character-mock-store.ts
Normal file
278
src/shared/mocks/character-mock-store.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { AudioContentCreateRequest, AudioContentDeactivateRequest, AudioContentUpdateRequest } from "@/features/audio-contents/schemas/audio-content-schema";
|
||||
import type { CharacterDetail, CharacterListItem } from "@/features/characters/model/types";
|
||||
import { AudioContentMockStore } from "@/shared/mocks/audio-content-mock-store";
|
||||
import { mockActiveCharacters, mockCharacterDetails, mockOriginalWorks } from "@/shared/mocks/character-fixtures";
|
||||
import { CommunityPostMockStore } from "@/shared/mocks/community-post-mock-store";
|
||||
import type { CommunityPostCreateMutation, CommunityPostUpdateMutation } from "@/shared/mocks/community-post-mock-store";
|
||||
import { FanTalkMockStore } from "@/shared/mocks/fan-talk-mock-store";
|
||||
import { SeriesMockStore } from "@/shared/mocks/series-mock-store";
|
||||
import type { SeriesCreateMutation, SeriesUpdateMutation } from "@/shared/mocks/series-mock-store";
|
||||
|
||||
export { parseAudioContentCreateRequest, parseAudioContentDeactivateRequest, parseAudioContentUpdateRequest } from "@/shared/mocks/audio-content-mock-store";
|
||||
|
||||
const characterMutationRequestSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
originalWorkId: z.number().int().nullable().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type CharacterMutationRequest = z.infer<typeof characterMutationRequestSchema>;
|
||||
|
||||
export class CharacterMockStore {
|
||||
readonly #audioStore = new AudioContentMockStore((characterId) => this.getCharacter(characterId));
|
||||
readonly #communityPostStore = new CommunityPostMockStore((characterId) => this.getCharacter(characterId));
|
||||
readonly #fanTalkStore = new FanTalkMockStore((characterId) => this.getCharacter(characterId));
|
||||
readonly #seriesStore = new SeriesMockStore((characterId) => this.getCharacter(characterId));
|
||||
#characterDetails: CharacterDetail[] = mockCharacterDetails.map(toMutableDetail);
|
||||
#characters: CharacterListItem[] = mockActiveCharacters.map(toMutableListItem);
|
||||
#nextCharacterId = 1000;
|
||||
|
||||
getCharacter(characterId: string) {
|
||||
return this.#characterDetails.find((character) => String(character.id) === characterId) ?? null;
|
||||
}
|
||||
|
||||
listCharacters(searchTerm: string | null, page: number, size: number) {
|
||||
const normalizedSearch = searchTerm?.trim().toLowerCase() ?? "";
|
||||
const filteredCharacters = normalizedSearch.length === 0
|
||||
? this.#characters
|
||||
: this.#characters.filter((character) => `${character.name} ${character.description} ${character.tags.join(" ")}`.toLowerCase().includes(normalizedSearch));
|
||||
const start = page * size;
|
||||
|
||||
return {
|
||||
totalCount: filteredCharacters.length,
|
||||
content: filteredCharacters.slice(start, start + size),
|
||||
};
|
||||
}
|
||||
|
||||
searchOriginalWorks(searchTerm: string) {
|
||||
const normalizedSearch = searchTerm.trim().toLowerCase();
|
||||
|
||||
return mockOriginalWorks.filter((originalWork) => `${originalWork.title} ${originalWork.contentType} ${originalWork.category}`.toLowerCase().includes(normalizedSearch)).map(toMutableOriginalWork);
|
||||
}
|
||||
|
||||
listAudioContents(characterId: string, searchWord: string | null, page: number, size: number) {
|
||||
return this.#audioStore.listAudioContents(characterId, searchWord, page, size);
|
||||
}
|
||||
|
||||
getAudioContent(characterId: string, contentId: string) {
|
||||
return this.#audioStore.getAudioContent(characterId, contentId);
|
||||
}
|
||||
|
||||
listSeries(characterId: string, page: number, size: number) {
|
||||
return this.#seriesStore.listSeries(characterId, page, size);
|
||||
}
|
||||
|
||||
listSeriesGenres() {
|
||||
return this.#seriesStore.listSeriesGenres();
|
||||
}
|
||||
|
||||
listCommunityPosts(characterId: string, page: number, size: number) {
|
||||
return this.#communityPostStore.listCommunityPosts(characterId, page, size);
|
||||
}
|
||||
|
||||
listFanTalks(characterId: string, page: number, size: number) {
|
||||
return this.#fanTalkStore.listFanTalks(characterId, page, size);
|
||||
}
|
||||
|
||||
createFanTalkReply(characterId: string, fanTalkId: string, content: string) {
|
||||
return this.#fanTalkStore.createFanTalkReply(characterId, fanTalkId, content);
|
||||
}
|
||||
|
||||
updateFanTalkReply(characterId: string, fanTalkId: string, replyId: string, content: string) {
|
||||
return this.#fanTalkStore.updateFanTalkReply(characterId, fanTalkId, replyId, content);
|
||||
}
|
||||
|
||||
deleteFanTalk(characterId: string, fanTalkId: string) {
|
||||
return this.#fanTalkStore.deleteFanTalk(characterId, fanTalkId);
|
||||
}
|
||||
|
||||
updateCommunityPost(characterId: string, postId: string, mutation: CommunityPostUpdateMutation): boolean {
|
||||
return this.#communityPostStore.updateCommunityPost(characterId, postId, mutation);
|
||||
}
|
||||
|
||||
createCommunityPost(characterId: string, mutation: CommunityPostCreateMutation): boolean {
|
||||
return this.#communityPostStore.createCommunityPost(characterId, mutation);
|
||||
}
|
||||
|
||||
getSeriesDetail(characterId: string, seriesId: string) {
|
||||
return this.#seriesStore.getSeriesDetail(characterId, seriesId);
|
||||
}
|
||||
|
||||
listSeriesContents(characterId: string, seriesId: string, page: number, size: number) {
|
||||
return this.#seriesStore.listSeriesContents(characterId, seriesId, page, size);
|
||||
}
|
||||
|
||||
searchUnlinkedSeriesContents(characterId: string, seriesId: string, searchWord: string) {
|
||||
return this.#seriesStore.searchUnlinkedSeriesContents(characterId, seriesId, searchWord);
|
||||
}
|
||||
|
||||
addSeriesContents(characterId: string, seriesId: string, contentIdList: readonly number[]): boolean {
|
||||
return this.#seriesStore.addSeriesContents(characterId, seriesId, contentIdList);
|
||||
}
|
||||
|
||||
createSeries(characterId: string, mutation: SeriesCreateMutation): boolean {
|
||||
return this.#seriesStore.createSeries(characterId, mutation);
|
||||
}
|
||||
|
||||
updateSeries(characterId: string, seriesId: string, mutation: SeriesUpdateMutation): boolean {
|
||||
return this.#seriesStore.updateSeries(characterId, seriesId, mutation);
|
||||
}
|
||||
|
||||
removeSeriesContent(characterId: string, seriesId: string, contentId: number): boolean {
|
||||
return this.#seriesStore.removeSeriesContent(characterId, seriesId, contentId);
|
||||
}
|
||||
|
||||
updateSeriesOrder(characterId: string, ids: readonly number[]): boolean {
|
||||
return this.#seriesStore.updateSeriesOrder(characterId, ids);
|
||||
}
|
||||
|
||||
listAudioContentThemes() {
|
||||
return this.#audioStore.listAudioContentThemes();
|
||||
}
|
||||
|
||||
createAudioContent(characterId: string, request: AudioContentCreateRequest): number | null {
|
||||
return this.#audioStore.createAudioContent(characterId, request);
|
||||
}
|
||||
|
||||
updateAudioContent(characterId: string, contentId: string, request: AudioContentUpdateRequest): boolean {
|
||||
return this.#audioStore.updateAudioContent(characterId, contentId, request);
|
||||
}
|
||||
|
||||
deactivateAudioContent(characterId: string, contentId: string, request: AudioContentDeactivateRequest): boolean {
|
||||
return this.#audioStore.deactivateAudioContent(characterId, contentId, request);
|
||||
}
|
||||
|
||||
createCharacter(request: CharacterMutationRequest) {
|
||||
const characterId = this.#nextCharacterId;
|
||||
this.#nextCharacterId += 1;
|
||||
const detail = {
|
||||
id: characterId,
|
||||
characterUUID: `mock-character-${characterId}`,
|
||||
name: request.name ?? "새 캐릭터",
|
||||
imageUrl: null,
|
||||
description: request.description ?? "",
|
||||
systemPrompt: request.systemPrompt ?? "",
|
||||
characterType: "Character",
|
||||
age: null,
|
||||
gender: null,
|
||||
mbti: null,
|
||||
speechPattern: null,
|
||||
speechStyle: null,
|
||||
appearance: null,
|
||||
region: "KR",
|
||||
isActive: true,
|
||||
tags: [],
|
||||
hobbies: [],
|
||||
values: [],
|
||||
goals: [],
|
||||
relationships: [],
|
||||
personalities: [],
|
||||
backgrounds: [],
|
||||
memories: [],
|
||||
originalWork: toOriginalWorkPreview(request.originalWorkId),
|
||||
} satisfies CharacterDetail;
|
||||
this.#characterDetails = [...this.#characterDetails, detail];
|
||||
this.#characters = [...this.#characters, toListItem(detail)];
|
||||
}
|
||||
|
||||
updateCharacter(characterId: string, request: CharacterMutationRequest): boolean {
|
||||
const character = this.getCharacter(characterId);
|
||||
if (character === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nextCharacter = {
|
||||
...character,
|
||||
name: request.name ?? character.name,
|
||||
description: request.description ?? character.description,
|
||||
systemPrompt: request.systemPrompt ?? character.systemPrompt,
|
||||
originalWork: toOriginalWorkPreview(request.originalWorkId),
|
||||
isActive: request.isActive ?? character.isActive,
|
||||
} satisfies CharacterDetail;
|
||||
this.#characterDetails = this.#characterDetails.map((item) => (item.id === nextCharacter.id ? nextCharacter : item));
|
||||
this.#characters = nextCharacter.isActive
|
||||
? this.#characters.map((item) => (item.id === nextCharacter.id ? toListItem(nextCharacter) : item))
|
||||
: this.#characters.filter((item) => item.id !== nextCharacter.id);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function toOriginalWorkPreview(originalWorkId: number | null | undefined): CharacterDetail["originalWork"] {
|
||||
if (originalWorkId === undefined || originalWorkId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const originalWork = mockOriginalWorks.find((item) => item.id === originalWorkId);
|
||||
return originalWork === undefined ? null : { id: originalWork.id, imageUrl: originalWork.imageUrl, title: originalWork.title };
|
||||
}
|
||||
|
||||
function toMutableOriginalWork(originalWork: (typeof mockOriginalWorks)[number]) {
|
||||
return {
|
||||
...originalWork,
|
||||
originalLinks: [...originalWork.originalLinks],
|
||||
tags: [...originalWork.tags],
|
||||
};
|
||||
}
|
||||
|
||||
export async function parseCharacterMutationRequest(request: Request): Promise<CharacterMutationRequest | null> {
|
||||
try {
|
||||
const body = await request.text();
|
||||
const match = /name="request"(?:; filename="[^"]*")?\r\nContent-Type: application\/json\r\n\r\n(?<json>.*?)\r\n--/s.exec(body);
|
||||
const json = match?.groups?.json;
|
||||
if (json === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return characterMutationRequestSchema.parse(JSON.parse(json));
|
||||
} catch (parseError) {
|
||||
if (parseError instanceof SyntaxError || parseError instanceof z.ZodError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw parseError;
|
||||
}
|
||||
}
|
||||
|
||||
function toMutableDetail(character: (typeof mockCharacterDetails)[number]): CharacterDetail {
|
||||
return {
|
||||
...character,
|
||||
tags: [...character.tags],
|
||||
hobbies: [...character.hobbies],
|
||||
values: [...character.values],
|
||||
goals: [...character.goals],
|
||||
relationships: [...character.relationships],
|
||||
personalities: [...character.personalities],
|
||||
backgrounds: [...character.backgrounds],
|
||||
memories: [...character.memories],
|
||||
};
|
||||
}
|
||||
|
||||
function toMutableListItem(character: (typeof mockActiveCharacters)[number]): CharacterListItem {
|
||||
return {
|
||||
...character,
|
||||
tags: [...character.tags],
|
||||
};
|
||||
}
|
||||
|
||||
function toListItem(character: CharacterDetail): CharacterListItem {
|
||||
return {
|
||||
id: character.id,
|
||||
name: character.name,
|
||||
imageUrl: character.imageUrl,
|
||||
description: character.description,
|
||||
gender: character.gender,
|
||||
age: character.age,
|
||||
mbti: character.mbti,
|
||||
speechStyle: character.speechStyle,
|
||||
speechPattern: character.speechPattern,
|
||||
region: character.region,
|
||||
tags: character.tags,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
134
src/shared/mocks/comment-fixtures.ts
Normal file
134
src/shared/mocks/comment-fixtures.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import type { CommentRecord } from "@/features/comments/model/types";
|
||||
import { previewCommunityPostImageUrl } from "@/shared/mocks/community-post-fixtures";
|
||||
|
||||
const lunaProfileUrl = "https://cdn.example.com/mock/luna.png";
|
||||
|
||||
export type MockComment = CommentRecord & {
|
||||
readonly characterId: string;
|
||||
readonly parentId: number | null;
|
||||
readonly targetId: string;
|
||||
readonly targetKind: "audio" | "community";
|
||||
};
|
||||
|
||||
export const mockComments = [
|
||||
{
|
||||
id: 1101,
|
||||
characterId: "101",
|
||||
targetKind: "audio",
|
||||
targetId: "9001",
|
||||
parentId: null,
|
||||
writerId: 301,
|
||||
nickname: "팬",
|
||||
profileUrl: previewCommunityPostImageUrl,
|
||||
comment: "오디오 팬 루트 댓글",
|
||||
isSecret: false,
|
||||
date: "2026-07-29T00:30:00Z",
|
||||
replyCount: 2,
|
||||
languageCode: "ko",
|
||||
donationCan: 5,
|
||||
},
|
||||
{
|
||||
id: 1102,
|
||||
characterId: "101",
|
||||
targetKind: "audio",
|
||||
targetId: "9001",
|
||||
parentId: null,
|
||||
writerId: 101,
|
||||
nickname: "루나",
|
||||
profileUrl: lunaProfileUrl,
|
||||
comment: "오디오 AI 루트 댓글",
|
||||
isSecret: false,
|
||||
date: "2026-07-29T01:00:00Z",
|
||||
replyCount: 0,
|
||||
languageCode: null,
|
||||
donationCan: 0,
|
||||
},
|
||||
{
|
||||
id: 1201,
|
||||
characterId: "101",
|
||||
targetKind: "audio",
|
||||
targetId: "9001",
|
||||
parentId: 1101,
|
||||
writerId: 301,
|
||||
nickname: "팬",
|
||||
profileUrl: previewCommunityPostImageUrl,
|
||||
comment: "오디오 팬 답글",
|
||||
isSecret: false,
|
||||
date: "2026-07-29T01:10:00Z",
|
||||
replyCount: 0,
|
||||
languageCode: "ko",
|
||||
donationCan: 1,
|
||||
},
|
||||
{
|
||||
id: 1202,
|
||||
characterId: "101",
|
||||
targetKind: "audio",
|
||||
targetId: "9001",
|
||||
parentId: 1101,
|
||||
writerId: 101,
|
||||
nickname: "루나",
|
||||
profileUrl: lunaProfileUrl,
|
||||
comment: "오디오 AI 답글",
|
||||
isSecret: false,
|
||||
date: "2026-07-29T01:20:00Z",
|
||||
replyCount: 0,
|
||||
languageCode: null,
|
||||
donationCan: 0,
|
||||
},
|
||||
{
|
||||
id: 2101,
|
||||
characterId: "101",
|
||||
targetKind: "community",
|
||||
targetId: "7001",
|
||||
parentId: null,
|
||||
writerId: 301,
|
||||
nickname: "팬",
|
||||
profileUrl: previewCommunityPostImageUrl,
|
||||
comment: "커뮤니티 팬 루트 댓글",
|
||||
isSecret: false,
|
||||
date: "2026-07-29T02:00:00Z",
|
||||
replyCount: 2,
|
||||
},
|
||||
{
|
||||
id: 2102,
|
||||
characterId: "101",
|
||||
targetKind: "community",
|
||||
targetId: "7001",
|
||||
parentId: null,
|
||||
writerId: 101,
|
||||
nickname: "루나",
|
||||
profileUrl: lunaProfileUrl,
|
||||
comment: "커뮤니티 AI 루트 댓글",
|
||||
isSecret: false,
|
||||
date: "2026-07-29T02:10:00Z",
|
||||
replyCount: 0,
|
||||
},
|
||||
{
|
||||
id: 2201,
|
||||
characterId: "101",
|
||||
targetKind: "community",
|
||||
targetId: "7001",
|
||||
parentId: 2101,
|
||||
writerId: 301,
|
||||
nickname: "팬",
|
||||
profileUrl: previewCommunityPostImageUrl,
|
||||
comment: "커뮤니티 팬 답글",
|
||||
isSecret: false,
|
||||
date: "2026-07-29T02:20:00Z",
|
||||
replyCount: 0,
|
||||
},
|
||||
{
|
||||
id: 2202,
|
||||
characterId: "101",
|
||||
targetKind: "community",
|
||||
targetId: "7001",
|
||||
parentId: 2101,
|
||||
writerId: 101,
|
||||
nickname: "루나",
|
||||
profileUrl: lunaProfileUrl,
|
||||
comment: "커뮤니티 AI 답글",
|
||||
isSecret: false,
|
||||
date: "2026-07-29T02:30:00Z",
|
||||
replyCount: 0,
|
||||
},
|
||||
] as const satisfies readonly MockComment[];
|
||||
188
src/shared/mocks/comment-handlers.ts
Normal file
188
src/shared/mocks/comment-handlers.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import type { RequestHandler } from "msw";
|
||||
|
||||
import type { AudioCommentCreateRequest, CommentUpdateRequest, CommunityCommentCreateRequest } from "@/features/comments/model/types";
|
||||
import type { CommentCreateMutation, CommentTargetRef } from "@/shared/mocks/comment-mock-store";
|
||||
import { error, ok } from "@/shared/mocks/contract";
|
||||
|
||||
type AccessResponse = (request: Request) => Response | null;
|
||||
|
||||
type CommentStore = {
|
||||
readonly createComment: (target: CommentTargetRef, request: CommentCreateMutation) => boolean;
|
||||
readonly deleteComment: (target: CommentTargetRef, commentId: number) => boolean;
|
||||
readonly listReplies: (target: CommentTargetRef, commentId: number, page: number, size: number) => unknown | null;
|
||||
readonly listRootComments: (target: CommentTargetRef, page: number, size: number) => unknown | null;
|
||||
readonly updateComment: (target: CommentTargetRef, commentId: number, request: CommentUpdateRequest) => boolean;
|
||||
};
|
||||
|
||||
type CommentMockHandlerOptions = {
|
||||
readonly apiBaseUrl: string;
|
||||
readonly invalidRequestMessage: string;
|
||||
readonly parseAudioCreateRequest: (request: Request) => Promise<AudioCommentCreateRequest | null>;
|
||||
readonly parseCommunityCreateRequest: (request: Request) => Promise<CommunityCommentCreateRequest | null>;
|
||||
readonly parseUpdateRequest: (request: Request) => Promise<CommentUpdateRequest | null>;
|
||||
};
|
||||
|
||||
function endpointUrl(apiBaseUrl: string, path: string): string {
|
||||
return new URL(path, apiBaseUrl).toString();
|
||||
}
|
||||
|
||||
function parseListQuery(request: Request): { readonly page: number; readonly size: number } | null {
|
||||
const url = new URL(request.url);
|
||||
const page = Number(url.searchParams.get("page") ?? "0");
|
||||
const size = Number(url.searchParams.get("size") ?? "20");
|
||||
const queryKeys = [...url.searchParams.keys()];
|
||||
if (!queryKeys.every((key) => key === "page" || key === "size") || !Number.isInteger(page) || page < 0 || !Number.isInteger(size) || size < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { page, size };
|
||||
}
|
||||
|
||||
function parseCommentId(value: string | readonly string[] | undefined): number | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const commentId = Number(value);
|
||||
|
||||
return Number.isInteger(commentId) ? commentId : null;
|
||||
}
|
||||
|
||||
function audioTarget(params: { readonly characterId?: string | readonly string[]; readonly contentId?: string | readonly string[] }): CommentTargetRef | null {
|
||||
if (typeof params.characterId !== "string" || typeof params.contentId !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { kind: "audio", characterId: params.characterId, contentId: params.contentId };
|
||||
}
|
||||
|
||||
function communityTarget(params: { readonly characterId?: string | readonly string[]; readonly postId?: string | readonly string[] }): CommentTargetRef | null {
|
||||
if (typeof params.characterId !== "string" || typeof params.postId !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { kind: "community", characterId: params.characterId, postId: params.postId };
|
||||
}
|
||||
|
||||
function listResponse(store: CommentStore, target: CommentTargetRef | null, request: Request, commentId?: number): Response {
|
||||
const query = parseListQuery(request);
|
||||
if (target === null || query === null) {
|
||||
return HttpResponse.json(error("잘못된 요청입니다."), { status: 400 });
|
||||
}
|
||||
const comments = commentId === undefined
|
||||
? store.listRootComments(target, query.page, query.size)
|
||||
: store.listReplies(target, commentId, query.page, query.size);
|
||||
|
||||
return comments === null
|
||||
? HttpResponse.json(error("AI 캐릭터를 찾을 수 없습니다."), { status: 404 })
|
||||
: HttpResponse.json(ok(comments));
|
||||
}
|
||||
|
||||
export function createCommentMockHandlers(store: CommentStore, accessResponse: AccessResponse, options: CommentMockHandlerOptions): readonly RequestHandler[] {
|
||||
return [
|
||||
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/audio-contents/:contentId/comments"), ({ params, request }) => {
|
||||
const deniedResponse = accessResponse(request);
|
||||
return deniedResponse ?? listResponse(store, audioTarget(params), request);
|
||||
}),
|
||||
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/audio-contents/:contentId/comments/:commentId/replies"), ({ params, request }) => {
|
||||
const deniedResponse = accessResponse(request);
|
||||
const commentId = parseCommentId(params.commentId);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
return commentId === null ? HttpResponse.json(error(options.invalidRequestMessage), { status: 400 }) : listResponse(store, audioTarget(params), request, commentId);
|
||||
}),
|
||||
http.post(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/audio-contents/:contentId/comments"), async ({ params, request }) => {
|
||||
const deniedResponse = accessResponse(request);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
const target = audioTarget(params);
|
||||
const mutation = await options.parseAudioCreateRequest(request);
|
||||
if (target === null || mutation === null || !store.createComment(target, mutation)) {
|
||||
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok(null));
|
||||
}),
|
||||
http.put(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/audio-contents/:contentId/comments/:commentId"), async ({ params, request }) => {
|
||||
const deniedResponse = accessResponse(request);
|
||||
const commentId = parseCommentId(params.commentId);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
const target = audioTarget(params);
|
||||
const mutation = await options.parseUpdateRequest(request);
|
||||
if (target === null || commentId === null || mutation === null || !store.updateComment(target, commentId, mutation)) {
|
||||
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok(null));
|
||||
}),
|
||||
http.delete(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/audio-contents/:contentId/comments/:commentId"), ({ params, request }) => {
|
||||
const deniedResponse = accessResponse(request);
|
||||
const commentId = parseCommentId(params.commentId);
|
||||
const target = audioTarget(params);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
if (target === null || commentId === null || !store.deleteComment(target, commentId)) {
|
||||
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok(null));
|
||||
}),
|
||||
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/community-posts/:postId/comments"), ({ params, request }) => {
|
||||
const deniedResponse = accessResponse(request);
|
||||
return deniedResponse ?? listResponse(store, communityTarget(params), request);
|
||||
}),
|
||||
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/community-posts/:postId/comments/:commentId/replies"), ({ params, request }) => {
|
||||
const deniedResponse = accessResponse(request);
|
||||
const commentId = parseCommentId(params.commentId);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
return commentId === null ? HttpResponse.json(error(options.invalidRequestMessage), { status: 400 }) : listResponse(store, communityTarget(params), request, commentId);
|
||||
}),
|
||||
http.post(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/community-posts/:postId/comments"), async ({ params, request }) => {
|
||||
const deniedResponse = accessResponse(request);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
const target = communityTarget(params);
|
||||
const mutation = await options.parseCommunityCreateRequest(request);
|
||||
if (target === null || mutation === null || !store.createComment(target, mutation)) {
|
||||
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok(null));
|
||||
}),
|
||||
http.put(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/community-posts/:postId/comments/:commentId"), async ({ params, request }) => {
|
||||
const deniedResponse = accessResponse(request);
|
||||
const commentId = parseCommentId(params.commentId);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
const target = communityTarget(params);
|
||||
const mutation = await options.parseUpdateRequest(request);
|
||||
if (target === null || commentId === null || mutation === null || !store.updateComment(target, commentId, mutation)) {
|
||||
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok(null));
|
||||
}),
|
||||
http.delete(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/community-posts/:postId/comments/:commentId"), ({ params, request }) => {
|
||||
const deniedResponse = accessResponse(request);
|
||||
const commentId = parseCommentId(params.commentId);
|
||||
const target = communityTarget(params);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
if (target === null || commentId === null || !store.deleteComment(target, commentId)) {
|
||||
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok(null));
|
||||
}),
|
||||
];
|
||||
}
|
||||
170
src/shared/mocks/comment-mock-store.ts
Normal file
170
src/shared/mocks/comment-mock-store.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { audioCommentCreateRequestSchema, commentUpdateRequestSchema, communityCommentCreateRequestSchema } from "@/features/comments/model/types";
|
||||
import type { AudioCommentCreateRequest, CommentPage, CommentRecord, CommentUpdateRequest, CommunityCommentCreateRequest } from "@/features/comments/model/types";
|
||||
import type { CharacterDetail } from "@/features/characters/model/types";
|
||||
import { mockComments } from "@/shared/mocks/comment-fixtures";
|
||||
import type { MockComment } from "@/shared/mocks/comment-fixtures";
|
||||
|
||||
type GetCharacter = (characterId: string) => CharacterDetail | null;
|
||||
|
||||
export type CommentTargetRef =
|
||||
| { readonly characterId: string; readonly contentId: string; readonly kind: "audio" }
|
||||
| { readonly characterId: string; readonly kind: "community"; readonly postId: string };
|
||||
|
||||
export type CommentCreateMutation = AudioCommentCreateRequest | CommunityCommentCreateRequest;
|
||||
|
||||
export class CommentMockStore {
|
||||
#comments: MockComment[] = mockComments.map(toMutableComment);
|
||||
#nextCommentId = 3000;
|
||||
readonly #getCharacter: GetCharacter;
|
||||
|
||||
constructor(getCharacter: GetCharacter) {
|
||||
this.#getCharacter = getCharacter;
|
||||
}
|
||||
|
||||
listRootComments(target: CommentTargetRef, page: number, size: number): CommentPage | null {
|
||||
return this.#listComments(target, null, page, size);
|
||||
}
|
||||
|
||||
listReplies(target: CommentTargetRef, commentId: number, page: number, size: number): CommentPage | null {
|
||||
return this.#listComments(target, commentId, page, size);
|
||||
}
|
||||
|
||||
createComment(target: CommentTargetRef, request: CommentCreateMutation): boolean {
|
||||
const character = this.#getCharacter(target.characterId);
|
||||
const parentId = request.parentId ?? null;
|
||||
if (character === null || (parentId !== null && !this.#isRootParent(this.#findComment(parentId), target))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.#nextCommentId += 1;
|
||||
const baseComment = {
|
||||
id: this.#nextCommentId,
|
||||
characterId: target.characterId,
|
||||
targetKind: target.kind,
|
||||
targetId: getTargetId(target),
|
||||
parentId,
|
||||
writerId: character.id,
|
||||
nickname: character.name,
|
||||
profileUrl: character.imageUrl ?? "https://cdn.example.com/mock/luna.png",
|
||||
comment: request.comment,
|
||||
isSecret: request.isSecret,
|
||||
date: "2026-07-29T03:00:00Z",
|
||||
replyCount: 0,
|
||||
} satisfies MockComment;
|
||||
const comment = target.kind === "audio"
|
||||
? { ...baseComment, languageCode: "languageCode" in request ? request.languageCode ?? null : null, donationCan: 0 } satisfies MockComment
|
||||
: baseComment;
|
||||
this.#comments = [comment, ...this.#comments];
|
||||
this.#refreshReplyCounts();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
updateComment(target: CommentTargetRef, commentId: number, request: CommentUpdateRequest): boolean {
|
||||
const character = this.#getCharacter(target.characterId);
|
||||
if (character === null || !this.#isCreatorOwned(this.#findComment(commentId), target, character.id)) {
|
||||
return false;
|
||||
}
|
||||
this.#comments = this.#comments.map((comment) => this.#matchesTarget(comment, target) && comment.id === commentId ? { ...comment, comment: request.comment } : comment);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
deleteComment(target: CommentTargetRef, commentId: number): boolean {
|
||||
if (!this.#matchesTarget(this.#findComment(commentId), target)) {
|
||||
return false;
|
||||
}
|
||||
this.#comments = this.#comments.filter((comment) => !this.#matchesTarget(comment, target) || comment.id !== commentId);
|
||||
this.#refreshReplyCounts();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
#listComments(target: CommentTargetRef, parentId: number | null, page: number, size: number): CommentPage | null {
|
||||
if (this.#getCharacter(target.characterId) === null) {
|
||||
return null;
|
||||
}
|
||||
const filtered = this.#comments.filter((comment) => this.#matchesTarget(comment, target) && comment.parentId === parentId);
|
||||
const start = page * size;
|
||||
|
||||
return { totalCount: filtered.length, items: filtered.slice(start, start + size).map(toCommentRecord) };
|
||||
}
|
||||
|
||||
#findComment(commentId: number): MockComment | undefined {
|
||||
return this.#comments.find((comment) => comment.id === commentId);
|
||||
}
|
||||
|
||||
#matchesTarget(comment: MockComment | undefined, target: CommentTargetRef): boolean {
|
||||
return comment !== undefined && comment.characterId === target.characterId && comment.targetKind === target.kind && comment.targetId === getTargetId(target);
|
||||
}
|
||||
|
||||
#isRootParent(comment: MockComment | undefined, target: CommentTargetRef): boolean {
|
||||
return comment !== undefined && this.#matchesTarget(comment, target) && comment.parentId === null;
|
||||
}
|
||||
|
||||
#isCreatorOwned(comment: MockComment | undefined, target: CommentTargetRef, creatorId: number): boolean {
|
||||
return comment !== undefined && this.#matchesTarget(comment, target) && comment.writerId === creatorId;
|
||||
}
|
||||
|
||||
#refreshReplyCounts(): void {
|
||||
this.#comments = this.#comments.map((comment) => comment.parentId === null
|
||||
? { ...comment, replyCount: this.#comments.filter((reply) => reply.parentId === comment.id).length }
|
||||
: comment);
|
||||
}
|
||||
}
|
||||
|
||||
export async function parseAudioCommentCreateRequest(request: Request): Promise<AudioCommentCreateRequest | null> {
|
||||
return parseJsonRequest(request, audioCommentCreateRequestSchema);
|
||||
}
|
||||
|
||||
export async function parseCommunityCommentCreateRequest(request: Request): Promise<CommunityCommentCreateRequest | null> {
|
||||
return parseJsonRequest(request, communityCommentCreateRequestSchema);
|
||||
}
|
||||
|
||||
export async function parseCommentUpdateRequest(request: Request): Promise<CommentUpdateRequest | null> {
|
||||
return parseJsonRequest(request, commentUpdateRequestSchema);
|
||||
}
|
||||
|
||||
async function parseJsonRequest<Data>(request: Request, schema: z.ZodType<Data>): Promise<Data | null> {
|
||||
try {
|
||||
return schema.parse(await request.json());
|
||||
} catch (parseError) {
|
||||
if (parseError instanceof SyntaxError || parseError instanceof z.ZodError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw parseError;
|
||||
}
|
||||
}
|
||||
|
||||
function getTargetId(target: CommentTargetRef): string {
|
||||
switch (target.kind) {
|
||||
case "audio":
|
||||
return target.contentId;
|
||||
case "community":
|
||||
return target.postId;
|
||||
}
|
||||
}
|
||||
|
||||
function toCommentRecord(comment: MockComment): CommentRecord {
|
||||
const record = {
|
||||
id: comment.id,
|
||||
writerId: comment.writerId,
|
||||
nickname: comment.nickname,
|
||||
profileUrl: comment.profileUrl,
|
||||
comment: comment.comment,
|
||||
isSecret: comment.isSecret,
|
||||
date: comment.date,
|
||||
replyCount: comment.replyCount,
|
||||
languageCode: comment.languageCode,
|
||||
donationCan: comment.donationCan,
|
||||
} satisfies CommentRecord;
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
function toMutableComment(comment: (typeof mockComments)[number]): MockComment {
|
||||
return { ...comment };
|
||||
}
|
||||
77
src/shared/mocks/community-post-fixtures.ts
Normal file
77
src/shared/mocks/community-post-fixtures.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { CommunityPostListItem } from "@/features/community-posts/model/types";
|
||||
import { previewAudioUrl } from "@/shared/mocks/audio-content-fixtures";
|
||||
|
||||
export const previewCommunityPostImageUrl = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='128' height='128' viewBox='0 0 128 128'%3E%3Crect width='128' height='128' rx='20' fill='%23D9F6FF'/%3E%3Cpath d='M34 40h60v10H34zM34 62h42v10H34zM34 84h54v10H34z' fill='%23062B36'/%3E%3C/svg%3E";
|
||||
export const previewCommunityPostAudioUrl = previewAudioUrl;
|
||||
|
||||
export const mockCommunityPostListItems = [
|
||||
{
|
||||
postId: 7001,
|
||||
creatorId: 101,
|
||||
creatorNickname: "루나",
|
||||
creatorProfileUrl: previewCommunityPostImageUrl,
|
||||
imageUrl: null,
|
||||
audioUrl: null,
|
||||
content: "오늘의 상담 기록입니다.",
|
||||
price: 0,
|
||||
date: "2026-07-28 10:00:00",
|
||||
dateUtc: "2026-07-28T01:00:00Z",
|
||||
isCommentAvailable: true,
|
||||
isAdult: false,
|
||||
isFixed: true,
|
||||
isLike: false,
|
||||
existOrdered: false,
|
||||
likeCount: 3,
|
||||
commentCount: 1,
|
||||
firstComment: {
|
||||
id: 8001,
|
||||
writerId: 301,
|
||||
nickname: "팬",
|
||||
profileUrl: previewCommunityPostImageUrl,
|
||||
comment: "좋아요",
|
||||
isSecret: false,
|
||||
date: "2026-07-28 11:00:00",
|
||||
replyCount: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
postId: 7002,
|
||||
creatorId: 101,
|
||||
creatorNickname: "루나",
|
||||
creatorProfileUrl: previewCommunityPostImageUrl,
|
||||
imageUrl: previewCommunityPostImageUrl,
|
||||
audioUrl: null,
|
||||
content: "이미지로 남긴 공지입니다.",
|
||||
price: 0,
|
||||
date: "2026-07-27 09:00:00",
|
||||
dateUtc: "2026-07-27T00:00:00Z",
|
||||
isCommentAvailable: false,
|
||||
isAdult: false,
|
||||
isFixed: false,
|
||||
isLike: false,
|
||||
existOrdered: false,
|
||||
likeCount: 1,
|
||||
commentCount: 0,
|
||||
firstComment: null,
|
||||
},
|
||||
{
|
||||
postId: 7003,
|
||||
creatorId: 101,
|
||||
creatorNickname: "루나",
|
||||
creatorProfileUrl: previewCommunityPostImageUrl,
|
||||
imageUrl: null,
|
||||
audioUrl: previewCommunityPostAudioUrl,
|
||||
content: "오디오가 포함된 커뮤니티 게시글입니다.",
|
||||
price: 100,
|
||||
date: "2026-07-26 08:00:00",
|
||||
dateUtc: "2026-07-25T23:00:00Z",
|
||||
isCommentAvailable: true,
|
||||
isAdult: true,
|
||||
isFixed: false,
|
||||
isLike: false,
|
||||
existOrdered: false,
|
||||
likeCount: 5,
|
||||
commentCount: 0,
|
||||
firstComment: null,
|
||||
},
|
||||
] as const satisfies readonly CommunityPostListItem[];
|
||||
91
src/shared/mocks/community-post-handlers.ts
Normal file
91
src/shared/mocks/community-post-handlers.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import type { RequestHandler } from "msw";
|
||||
|
||||
import type { CommunityPostCreateMutation, CommunityPostUpdateMutation } from "@/shared/mocks/community-post-mock-store";
|
||||
import { error, ok } from "@/shared/mocks/contract";
|
||||
|
||||
type AccessResponse = (request: Request) => Response | null;
|
||||
|
||||
type CommunityPostStore = {
|
||||
readonly createCommunityPost: (characterId: string, mutation: CommunityPostCreateMutation) => boolean;
|
||||
readonly listCommunityPosts: (characterId: string, page: number, size: number) => unknown | null;
|
||||
readonly updateCommunityPost: (characterId: string, postId: string, mutation: CommunityPostUpdateMutation) => boolean;
|
||||
};
|
||||
|
||||
type CommunityPostMockHandlerOptions = {
|
||||
readonly apiBaseUrl: string;
|
||||
readonly invalidRequestMessage: string;
|
||||
readonly parseCreateRequest: (request: Request) => Promise<CommunityPostCreateMutation | null>;
|
||||
readonly parseUpdateRequest: (request: Request) => Promise<CommunityPostUpdateMutation | null>;
|
||||
};
|
||||
|
||||
function endpointUrl(apiBaseUrl: string, path: string): string {
|
||||
return new URL(path, apiBaseUrl).toString();
|
||||
}
|
||||
|
||||
function parseListQuery(request: Request): { readonly page: number; readonly size: number } | null {
|
||||
const url = new URL(request.url);
|
||||
const page = Number(url.searchParams.get("page") ?? "0");
|
||||
const size = Number(url.searchParams.get("size") ?? "20");
|
||||
const queryKeys = [...url.searchParams.keys()];
|
||||
if (!queryKeys.every((key) => key === "page" || key === "size") || !Number.isInteger(page) || page < 0 || !Number.isInteger(size) || size < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { page, size };
|
||||
}
|
||||
|
||||
export function createCommunityPostMockHandlers(store: CommunityPostStore, accessResponse: AccessResponse, options: CommunityPostMockHandlerOptions): readonly RequestHandler[] {
|
||||
return [
|
||||
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/community-posts"), ({ params, request }) => {
|
||||
const deniedResponse = accessResponse(request);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
const query = parseListQuery(request);
|
||||
if (query === null) {
|
||||
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
const characterId = typeof params.characterId === "string" ? params.characterId : "";
|
||||
const posts = store.listCommunityPosts(characterId, query.page, query.size);
|
||||
if (posts === null) {
|
||||
return HttpResponse.json(error("AI 캐릭터를 찾을 수 없습니다."), { status: 404 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok(posts));
|
||||
}),
|
||||
http.put(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/community-posts/:postId"), async ({ params, request }) => {
|
||||
const deniedResponse = accessResponse(request);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
const mutation = await options.parseUpdateRequest(request);
|
||||
if (mutation === null) {
|
||||
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
const characterId = typeof params.characterId === "string" ? params.characterId : "";
|
||||
const postId = typeof params.postId === "string" ? params.postId : "";
|
||||
if (!store.updateCommunityPost(characterId, postId, mutation)) {
|
||||
return HttpResponse.json(error("커뮤니티 게시글을 찾을 수 없습니다."), { status: 404 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok(null));
|
||||
}),
|
||||
http.post(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/community-posts"), async ({ params, request }) => {
|
||||
const deniedResponse = accessResponse(request);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
const mutation = await options.parseCreateRequest(request);
|
||||
if (mutation === null) {
|
||||
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
const characterId = typeof params.characterId === "string" ? params.characterId : "";
|
||||
if (!store.createCommunityPost(characterId, mutation)) {
|
||||
return HttpResponse.json(error("AI 캐릭터를 찾을 수 없습니다."), { status: 404 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok(null));
|
||||
}),
|
||||
];
|
||||
}
|
||||
256
src/shared/mocks/community-post-mock-store.ts
Normal file
256
src/shared/mocks/community-post-mock-store.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { CharacterDetail } from "@/features/characters/model/types";
|
||||
import { communityPostCreateRequestSchema, communityPostUpdateRequestSchema } from "@/features/community-posts/model/types";
|
||||
import type { CommunityPostCreateRequest, CommunityPostListItem, CommunityPostUpdateRequest } from "@/features/community-posts/model/types";
|
||||
import { mockCommunityPostListItems, previewCommunityPostAudioUrl, previewCommunityPostImageUrl } from "@/shared/mocks/community-post-fixtures";
|
||||
|
||||
type GetCharacter = (characterId: string) => CharacterDetail | null;
|
||||
|
||||
export type CommunityPostCreateMutation = {
|
||||
readonly hasAudio: boolean;
|
||||
readonly hasImage: boolean;
|
||||
readonly request: CommunityPostCreateRequest;
|
||||
};
|
||||
|
||||
export type CommunityPostUpdateMutation = {
|
||||
readonly hasImage: boolean;
|
||||
readonly request: CommunityPostUpdateRequest;
|
||||
};
|
||||
|
||||
export class CommunityPostMockStore {
|
||||
#posts: CommunityPostListItem[] = mockCommunityPostListItems.map(toMutableCommunityPost);
|
||||
readonly #getCharacter: GetCharacter;
|
||||
|
||||
constructor(getCharacter: GetCharacter) {
|
||||
this.#getCharacter = getCharacter;
|
||||
}
|
||||
|
||||
listCommunityPosts(characterId: string, page: number, size: number) {
|
||||
const character = this.#getCharacter(characterId);
|
||||
if (character === null) {
|
||||
return null;
|
||||
}
|
||||
const start = page * size;
|
||||
const posts = this.#posts.filter((post) => post.creatorId === character.id);
|
||||
|
||||
return { totalCount: posts.length, page, size, hasNext: start + size < posts.length, items: posts.slice(start, start + size) };
|
||||
}
|
||||
|
||||
updateCommunityPost(characterId: string, postId: string, mutation: CommunityPostUpdateMutation): boolean {
|
||||
const character = this.#getCharacter(characterId);
|
||||
if (character === null || !this.#posts.some((post) => String(post.postId) === postId && post.creatorId === character.id)) {
|
||||
return false;
|
||||
}
|
||||
const request = mutation.request;
|
||||
if (request.isActive === false) {
|
||||
this.#posts = this.#posts.filter((post) => String(post.postId) !== postId || post.creatorId !== character.id);
|
||||
return true;
|
||||
}
|
||||
this.#posts = this.#posts.map((post) => String(post.postId) === postId && post.creatorId === character.id ? {
|
||||
...post,
|
||||
content: request.content ?? post.content,
|
||||
imageUrl: mutation.hasImage ? previewCommunityPostImageUrl : post.imageUrl,
|
||||
isAdult: request.isAdult ?? post.isAdult,
|
||||
isCommentAvailable: request.isCommentAvailable ?? post.isCommentAvailable,
|
||||
isFixed: request.isFixed ?? post.isFixed,
|
||||
} : post);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
createCommunityPost(characterId: string, mutation: CommunityPostCreateMutation): boolean {
|
||||
const character = this.#getCharacter(characterId);
|
||||
if (character === null) {
|
||||
return false;
|
||||
}
|
||||
const nextPostId = Math.max(0, ...this.#posts.map((post) => post.postId)) + 1;
|
||||
this.#posts = [
|
||||
{
|
||||
postId: nextPostId,
|
||||
creatorId: character.id,
|
||||
creatorNickname: character.name,
|
||||
creatorProfileUrl: character.imageUrl ?? previewCommunityPostImageUrl,
|
||||
imageUrl: mutation.hasImage ? previewCommunityPostImageUrl : null,
|
||||
audioUrl: mutation.hasAudio ? previewCommunityPostAudioUrl : null,
|
||||
content: mutation.request.content,
|
||||
price: mutation.request.price ?? 0,
|
||||
date: "2026-07-28 12:00:00",
|
||||
dateUtc: "2026-07-28T03:00:00Z",
|
||||
isCommentAvailable: mutation.request.isCommentAvailable,
|
||||
isAdult: mutation.request.isAdult,
|
||||
isFixed: false,
|
||||
isLike: false,
|
||||
existOrdered: false,
|
||||
likeCount: 0,
|
||||
commentCount: 0,
|
||||
firstComment: null,
|
||||
},
|
||||
...this.#posts,
|
||||
];
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export async function parseCommunityPostCreateRequest(request: Request): Promise<CommunityPostCreateMutation | null> {
|
||||
try {
|
||||
const body = await parseMultipart(request, ["audioFile", "postImage"]);
|
||||
if (body === null || body.requestParts.length !== 1 || body.audioFileCount > 1 || body.postImageCount > 1) {
|
||||
return null;
|
||||
}
|
||||
const requestPart = body.requestParts[0];
|
||||
if (requestPart === undefined) {
|
||||
return null;
|
||||
}
|
||||
const mutation = communityPostCreateRequestSchema.parse(JSON.parse(await readTextPart(requestPart)));
|
||||
|
||||
return { hasAudio: body.audioFileCount === 1, hasImage: body.postImageCount === 1, request: mutation };
|
||||
} catch (parseError) {
|
||||
if (parseError instanceof Error || parseError instanceof z.ZodError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw parseError;
|
||||
}
|
||||
}
|
||||
|
||||
export async function parseCommunityPostUpdateRequest(request: Request): Promise<CommunityPostUpdateMutation | null> {
|
||||
try {
|
||||
const body = await parseMultipart(request, ["postImage"]);
|
||||
if (body === null || body.requestParts.length !== 1 || body.postImageCount > 1) {
|
||||
return null;
|
||||
}
|
||||
const requestPart = body.requestParts[0];
|
||||
if (requestPart === undefined) {
|
||||
return null;
|
||||
}
|
||||
const mutation = communityPostUpdateRequestSchema.parse(JSON.parse(await readTextPart(requestPart)));
|
||||
|
||||
return { hasImage: body.postImageCount === 1, request: mutation };
|
||||
} catch (parseError) {
|
||||
if (parseError instanceof Error || parseError instanceof z.ZodError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw parseError;
|
||||
}
|
||||
}
|
||||
|
||||
type MultipartParts = {
|
||||
readonly audioFileCount: number;
|
||||
readonly postImageCount: number;
|
||||
readonly requestParts: readonly MultipartRequestPart[];
|
||||
};
|
||||
|
||||
type MultipartRequestPart = Blob | string;
|
||||
|
||||
async function parseMultipart(request: Request, allowedUploads: readonly string[]): Promise<MultipartParts | null> {
|
||||
const contentType = request.headers.get("content-type");
|
||||
const boundary = extractMultipartBoundary(contentType);
|
||||
if (boundary === null) {
|
||||
return null;
|
||||
}
|
||||
const clonedRequest = request.clone();
|
||||
const formData = await request.formData().catch(() => null);
|
||||
if (formData === null) {
|
||||
return parseRawMultipart(await clonedRequest.text(), allowedUploads, boundary);
|
||||
}
|
||||
let audioFileCount = 0;
|
||||
let postImageCount = 0;
|
||||
const requestParts: MultipartRequestPart[] = [];
|
||||
|
||||
for (const [name, value] of formData.entries()) {
|
||||
if (name !== "request" && !allowedUploads.includes(name)) {
|
||||
return null;
|
||||
}
|
||||
switch (name) {
|
||||
case "audioFile":
|
||||
if (!(value instanceof File)) {
|
||||
return null;
|
||||
}
|
||||
audioFileCount += 1;
|
||||
break;
|
||||
case "postImage":
|
||||
if (!(value instanceof File)) {
|
||||
return null;
|
||||
}
|
||||
postImageCount += 1;
|
||||
break;
|
||||
case "request":
|
||||
requestParts.push(value);
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return { audioFileCount, postImageCount, requestParts };
|
||||
}
|
||||
|
||||
function extractMultipartBoundary(contentType: string | null): string | null {
|
||||
if (!contentType?.toLowerCase().startsWith("multipart/form-data")) {
|
||||
return null;
|
||||
}
|
||||
const boundary = contentType.match(/boundary=("[^"]+"|[^;]+)/)?.[1]?.replace(/^"|"$/g, "").trim();
|
||||
|
||||
return boundary === undefined || boundary.length === 0 ? null : boundary;
|
||||
}
|
||||
|
||||
function parseRawMultipart(body: string, allowedUploads: readonly string[], boundary: string): MultipartParts | null {
|
||||
if (!body.startsWith(`--${boundary}\r\n`) || (!body.endsWith(`--${boundary}--\r\n`) && !body.endsWith(`--${boundary}--`))) {
|
||||
return null;
|
||||
}
|
||||
const boundaryLines = body.match(/^--[^\r\n]+$/gm) ?? [];
|
||||
if (boundaryLines.length === 0 || boundaryLines.some((line) => line !== `--${boundary}` && line !== `--${boundary}--`)) {
|
||||
return null;
|
||||
}
|
||||
const parts = [...body.matchAll(/Content-Disposition: form-data; name="([^"]+)"(; filename="[^"]*")?\r\n(?:Content-Type: [^\r\n]+\r\n)?\r\n([\s\S]*?)(?=\r\n--)/g)];
|
||||
let audioFileCount = 0;
|
||||
let postImageCount = 0;
|
||||
const requestParts: string[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
const name = part[1];
|
||||
const filename = part[2];
|
||||
const value = part[3];
|
||||
if (name === undefined || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (name !== "request" && !allowedUploads.includes(name)) {
|
||||
return null;
|
||||
}
|
||||
switch (name) {
|
||||
case "audioFile":
|
||||
if (filename === undefined) {
|
||||
return null;
|
||||
}
|
||||
audioFileCount += 1;
|
||||
break;
|
||||
case "postImage":
|
||||
if (filename === undefined) {
|
||||
return null;
|
||||
}
|
||||
postImageCount += 1;
|
||||
break;
|
||||
case "request":
|
||||
requestParts.push(value);
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return { audioFileCount, postImageCount, requestParts };
|
||||
}
|
||||
|
||||
function readTextPart(part: MultipartRequestPart): Promise<string> | string {
|
||||
return typeof part === "string" ? part : part.text();
|
||||
}
|
||||
|
||||
function toMutableCommunityPost(post: (typeof mockCommunityPostListItems)[number]): CommunityPostListItem {
|
||||
return {
|
||||
...post,
|
||||
firstComment: post.firstComment === null ? null : { ...post.firstComment },
|
||||
};
|
||||
}
|
||||
31
src/shared/mocks/fan-talk-fixtures.ts
Normal file
31
src/shared/mocks/fan-talk-fixtures.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { FanTalkListItem } from "@/features/fan-talks/model/types";
|
||||
|
||||
export const mockFanTalkListItems = [
|
||||
{
|
||||
fanTalkId: 7001,
|
||||
writerId: 4001,
|
||||
writerNickname: "달팬",
|
||||
writerProfileImageUrl: "https://cdn.example.com/fans/moon.png",
|
||||
content: "첫 번째 응원입니다.",
|
||||
createdAtUtc: "2026-07-28T01:00:00Z",
|
||||
creatorReplies: [],
|
||||
},
|
||||
{
|
||||
fanTalkId: 7002,
|
||||
writerId: 4002,
|
||||
writerNickname: "별팬",
|
||||
writerProfileImageUrl: "https://cdn.example.com/fans/star.png",
|
||||
content: "두 번째로 온 응원입니다.",
|
||||
createdAtUtc: "2026-07-28T02:00:00Z",
|
||||
creatorReplies: [
|
||||
{
|
||||
fanTalkId: 7102,
|
||||
writerId: 101,
|
||||
writerNickname: "루나",
|
||||
writerProfileImageUrl: "https://cdn.example.com/characters/luna.png",
|
||||
content: "이미 답변했습니다.",
|
||||
createdAtUtc: "2026-07-28T03:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const satisfies readonly FanTalkListItem[];
|
||||
127
src/shared/mocks/fan-talk-handlers.ts
Normal file
127
src/shared/mocks/fan-talk-handlers.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import type { RequestHandler } from "msw";
|
||||
import { z } from "zod";
|
||||
|
||||
import { error, ok } from "@/shared/mocks/contract";
|
||||
|
||||
type AccessResponse = (request: Request) => Response | null;
|
||||
|
||||
type FanTalkStore = {
|
||||
readonly createFanTalkReply: (characterId: string, fanTalkId: string, content: string) => unknown | null;
|
||||
readonly deleteFanTalk: (characterId: string, fanTalkId: string) => boolean;
|
||||
readonly listFanTalks: (characterId: string, page: number, size: number) => unknown | null;
|
||||
readonly updateFanTalkReply: (characterId: string, fanTalkId: string, replyId: string, content: string) => unknown | null;
|
||||
};
|
||||
|
||||
type FanTalkMockHandlerOptions = {
|
||||
readonly apiBaseUrl: string;
|
||||
readonly invalidRequestMessage: string;
|
||||
};
|
||||
|
||||
const fanTalkReplyCreateRequestSchema = z.strictObject({ content: z.string() });
|
||||
|
||||
function endpointUrl(apiBaseUrl: string, path: string): string {
|
||||
return new URL(path, apiBaseUrl).toString();
|
||||
}
|
||||
|
||||
function parseListQuery(request: Request): { readonly page: number; readonly size: number } | null {
|
||||
const url = new URL(request.url);
|
||||
const page = Number(url.searchParams.get("page") ?? "0");
|
||||
const size = Number(url.searchParams.get("size") ?? "20");
|
||||
const queryKeys = [...url.searchParams.keys()];
|
||||
if (!queryKeys.every((key) => key === "page" || key === "size") || !Number.isInteger(page) || page < 0 || !Number.isInteger(size) || size < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { page, size };
|
||||
}
|
||||
|
||||
async function parseReplyRequest(request: Request): Promise<string | null> {
|
||||
if (request.headers.get("Content-Type")?.toLowerCase().split(";")[0]?.trim() !== "application/json") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return fanTalkReplyCreateRequestSchema.parse(await request.json()).content;
|
||||
} catch (parseError) {
|
||||
if (parseError instanceof SyntaxError || parseError instanceof z.ZodError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw parseError;
|
||||
}
|
||||
}
|
||||
|
||||
export function createFanTalkMockHandlers(store: FanTalkStore, accessResponse: AccessResponse, options: FanTalkMockHandlerOptions): readonly RequestHandler[] {
|
||||
return [
|
||||
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/fan-talks"), ({ params, request }) => {
|
||||
const deniedResponse = accessResponse(request);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
const query = parseListQuery(request);
|
||||
if (query === null) {
|
||||
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
const characterId = typeof params.characterId === "string" ? params.characterId : "";
|
||||
const fanTalks = store.listFanTalks(characterId, query.page, query.size);
|
||||
if (fanTalks === null) {
|
||||
return HttpResponse.json(error("AI 캐릭터를 찾을 수 없습니다."), { status: 404 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok(fanTalks));
|
||||
}),
|
||||
http.post(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/fan-talks/:fanTalkId/replies"), async ({ params, request }) => {
|
||||
const deniedResponse = accessResponse(request);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
const content = await parseReplyRequest(request);
|
||||
if (content === null) {
|
||||
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
const characterId = typeof params.characterId === "string" ? params.characterId : "";
|
||||
const fanTalkId = typeof params.fanTalkId === "string" ? params.fanTalkId : "";
|
||||
const reply = store.createFanTalkReply(characterId, fanTalkId, content);
|
||||
if (reply === null) {
|
||||
return HttpResponse.json(error("FanTalk 답변을 생성할 수 없습니다."), { status: 409 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok(reply));
|
||||
}),
|
||||
http.put(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/fan-talks/:fanTalkId/replies/:replyId"), async ({ params, request }) => {
|
||||
const deniedResponse = accessResponse(request);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
const content = await parseReplyRequest(request);
|
||||
if (content === null) {
|
||||
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
const characterId = typeof params.characterId === "string" ? params.characterId : "";
|
||||
const fanTalkId = typeof params.fanTalkId === "string" ? params.fanTalkId : "";
|
||||
const replyId = typeof params.replyId === "string" ? params.replyId : "";
|
||||
const reply = store.updateFanTalkReply(characterId, fanTalkId, replyId, content);
|
||||
if (reply === null) {
|
||||
return HttpResponse.json(error("FanTalk 답변을 수정할 수 없습니다."), { status: 404 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok(reply));
|
||||
}),
|
||||
http.delete(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/fan-talks/:fanTalkId"), async ({ params, request }) => {
|
||||
const deniedResponse = accessResponse(request);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
if ((await request.text()) !== "") {
|
||||
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
const characterId = typeof params.characterId === "string" ? params.characterId : "";
|
||||
const fanTalkId = typeof params.fanTalkId === "string" ? params.fanTalkId : "";
|
||||
if (!store.deleteFanTalk(characterId, fanTalkId)) {
|
||||
return HttpResponse.json(error("FanTalk을 삭제할 수 없습니다."), { status: 404 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok(null));
|
||||
}),
|
||||
];
|
||||
}
|
||||
95
src/shared/mocks/fan-talk-mock-store.ts
Normal file
95
src/shared/mocks/fan-talk-mock-store.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { CharacterDetail } from "@/features/characters/model/types";
|
||||
import type { FanTalkCreatorReply, FanTalkListItem } from "@/features/fan-talks/model/types";
|
||||
import type { FanTalkReplyResponse } from "@/features/fan-talks/schemas/fan-talk-reply-schema";
|
||||
import { mockFanTalkListItems } from "@/shared/mocks/fan-talk-fixtures";
|
||||
|
||||
type GetCharacter = (characterId: string) => CharacterDetail | null;
|
||||
|
||||
export class FanTalkMockStore {
|
||||
#fanTalks: FanTalkListItem[] = mockFanTalkListItems.map(toMutableFanTalk);
|
||||
#nextReplyId = 9001;
|
||||
readonly #getCharacter: GetCharacter;
|
||||
|
||||
constructor(getCharacter: GetCharacter) {
|
||||
this.#getCharacter = getCharacter;
|
||||
}
|
||||
|
||||
listFanTalks(characterId: string, page: number, size: number) {
|
||||
if (this.#getCharacter(characterId) === null) {
|
||||
return null;
|
||||
}
|
||||
const start = page * size;
|
||||
|
||||
return {
|
||||
fanTalkCount: this.#fanTalks.length,
|
||||
fanTalks: this.#fanTalks.slice(start, start + size),
|
||||
page,
|
||||
size,
|
||||
hasNext: start + size < this.#fanTalks.length,
|
||||
};
|
||||
}
|
||||
|
||||
createFanTalkReply(characterId: string, fanTalkId: string, content: string): FanTalkReplyResponse | null {
|
||||
const character = this.#getCharacter(characterId);
|
||||
const fanTalk = this.#fanTalks.find((item) => String(item.fanTalkId) === fanTalkId);
|
||||
if (character === null || fanTalk === undefined || fanTalk.creatorReplies.length > 0) {
|
||||
return null;
|
||||
}
|
||||
const reply = {
|
||||
fanTalkId: fanTalk.fanTalkId,
|
||||
replyId: this.#nextReplyId,
|
||||
creatorMemberId: character.id,
|
||||
content,
|
||||
createdAtUtc: "2026-07-28T04:00:00Z",
|
||||
} satisfies FanTalkReplyResponse;
|
||||
this.#nextReplyId += 1;
|
||||
this.#fanTalks = this.#fanTalks.map((item) => item.fanTalkId === fanTalk.fanTalkId ? {
|
||||
...item,
|
||||
creatorReplies: [{ fanTalkId: reply.replyId, writerId: character.id, writerNickname: character.name, writerProfileImageUrl: character.imageUrl ?? "", content: reply.content, createdAtUtc: reply.createdAtUtc }],
|
||||
} : item);
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
updateFanTalkReply(characterId: string, fanTalkId: string, replyId: string, content: string): FanTalkListItem | null {
|
||||
const character = this.#getCharacter(characterId);
|
||||
const fanTalk = this.#fanTalks.find((item) => String(item.fanTalkId) === fanTalkId);
|
||||
const reply = fanTalk?.creatorReplies[0];
|
||||
if (character === null || fanTalk === undefined || reply === undefined || String(reply.fanTalkId) !== replyId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updatedReply: FanTalkCreatorReply = { ...reply, content, createdAtUtc: "2026-07-28T05:00:00Z" };
|
||||
this.#fanTalks = this.#fanTalks.map((item) => item.fanTalkId === fanTalk.fanTalkId ? {
|
||||
...item,
|
||||
creatorReplies: [updatedReply],
|
||||
} : item);
|
||||
|
||||
return {
|
||||
fanTalkId: updatedReply.fanTalkId,
|
||||
writerId: updatedReply.writerId,
|
||||
writerNickname: updatedReply.writerNickname,
|
||||
writerProfileImageUrl: updatedReply.writerProfileImageUrl,
|
||||
content,
|
||||
createdAtUtc: updatedReply.createdAtUtc,
|
||||
creatorReplies: [],
|
||||
} satisfies FanTalkListItem;
|
||||
}
|
||||
|
||||
deleteFanTalk(characterId: string, fanTalkId: string): boolean {
|
||||
if (this.#getCharacter(characterId) === null) {
|
||||
return false;
|
||||
}
|
||||
const beforeCount = this.#fanTalks.length;
|
||||
this.#fanTalks = this.#fanTalks.filter((item) => String(item.fanTalkId) !== fanTalkId);
|
||||
|
||||
return this.#fanTalks.length < beforeCount;
|
||||
}
|
||||
}
|
||||
|
||||
function toMutableFanTalk(fanTalk: (typeof mockFanTalkListItems)[number]): FanTalkListItem {
|
||||
return {
|
||||
...fanTalk,
|
||||
creatorReplies: fanTalk.creatorReplies.map((reply) => ({ ...reply })),
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,16 @@ import { http, HttpResponse } from "msw";
|
||||
import type { RequestHandler } from "msw";
|
||||
import { z } from "zod";
|
||||
|
||||
import { CharacterMockStore, parseAudioContentCreateRequest, parseAudioContentDeactivateRequest, parseAudioContentUpdateRequest, parseCharacterMutationRequest } from "@/shared/mocks/character-mock-store";
|
||||
import { createAudioContentMockHandlers } from "@/shared/mocks/audio-content-handlers";
|
||||
import { createCommentMockHandlers } from "@/shared/mocks/comment-handlers";
|
||||
import { CommentMockStore, parseAudioCommentCreateRequest, parseCommentUpdateRequest, parseCommunityCommentCreateRequest } from "@/shared/mocks/comment-mock-store";
|
||||
import { createCommunityPostMockHandlers } from "@/shared/mocks/community-post-handlers";
|
||||
import { parseCommunityPostCreateRequest, parseCommunityPostUpdateRequest } from "@/shared/mocks/community-post-mock-store";
|
||||
import { error, ok } from "@/shared/mocks/contract";
|
||||
import { createFanTalkMockHandlers } from "@/shared/mocks/fan-talk-handlers";
|
||||
import { createSeriesMockHandlers } from "@/shared/mocks/series-handlers";
|
||||
import { parseSeriesCreateRequest, parseSeriesUpdateRequest } from "@/shared/mocks/series-mock-store";
|
||||
|
||||
const adminToken = "mock-admin-jwt";
|
||||
const memberToken = "mock-member-jwt";
|
||||
@@ -16,15 +25,8 @@ const loginRequestSchema = z.strictObject({
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
const aiCharactersPreview = {
|
||||
totalCount: 0,
|
||||
page: 0,
|
||||
size: 20,
|
||||
hasNext: false,
|
||||
items: [],
|
||||
} as const;
|
||||
|
||||
class MockStore {
|
||||
class MockStore extends CharacterMockStore {
|
||||
readonly #commentStore = new CommentMockStore((characterId) => this.getCharacter(characterId));
|
||||
readonly #revokedTokens = new Set<string>();
|
||||
|
||||
activate(token: string): void {
|
||||
@@ -45,6 +47,26 @@ class MockStore {
|
||||
|
||||
return "unauthorized";
|
||||
}
|
||||
|
||||
listRootComments(target: Parameters<CommentMockStore["listRootComments"]>[0], page: number, size: number) {
|
||||
return this.#commentStore.listRootComments(target, page, size);
|
||||
}
|
||||
|
||||
listReplies(target: Parameters<CommentMockStore["listReplies"]>[0], commentId: number, page: number, size: number) {
|
||||
return this.#commentStore.listReplies(target, commentId, page, size);
|
||||
}
|
||||
|
||||
createComment(target: Parameters<CommentMockStore["createComment"]>[0], request: Parameters<CommentMockStore["createComment"]>[1]): boolean {
|
||||
return this.#commentStore.createComment(target, request);
|
||||
}
|
||||
|
||||
updateComment(target: Parameters<CommentMockStore["updateComment"]>[0], commentId: number, request: Parameters<CommentMockStore["updateComment"]>[2]): boolean {
|
||||
return this.#commentStore.updateComment(target, commentId, request);
|
||||
}
|
||||
|
||||
deleteComment(target: Parameters<CommentMockStore["deleteComment"]>[0], commentId: number): boolean {
|
||||
return this.#commentStore.deleteComment(target, commentId);
|
||||
}
|
||||
}
|
||||
|
||||
export type MockFixtureStore = MockStore;
|
||||
@@ -142,11 +164,75 @@ export function createMockHandlers(
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.searchParams.get("page") !== "0" || url.searchParams.get("size") !== "20") {
|
||||
const page = Number(url.searchParams.get("page") ?? "0");
|
||||
const size = Number(url.searchParams.get("size") ?? "20");
|
||||
if (!Number.isInteger(page) || page < 0 || !Number.isInteger(size) || size < 1) {
|
||||
return HttpResponse.json(error(invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok(aiCharactersPreview));
|
||||
return HttpResponse.json(ok(store.listCharacters(url.searchParams.get("searchTerm"), page, size)));
|
||||
}),
|
||||
http.get(endpointUrl(apiBaseUrl, "/api/v2/admin/ai-characters/original-works/search"), ({ request }) => {
|
||||
const deniedResponse = accessResponse(store, request);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
|
||||
const searchTerm = new URL(request.url).searchParams.get("searchTerm");
|
||||
if (searchTerm === null || searchTerm.trim().length === 0) {
|
||||
return HttpResponse.json(error(invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok(store.searchOriginalWorks(searchTerm)));
|
||||
}),
|
||||
http.post(endpointUrl(apiBaseUrl, "/api/v2/admin/ai-characters"), async ({ request }) => {
|
||||
const deniedResponse = accessResponse(store, request);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
const mutation = await parseCharacterMutationRequest(request);
|
||||
if (mutation === null) {
|
||||
return HttpResponse.json(error(invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
|
||||
store.createCharacter(mutation);
|
||||
return HttpResponse.json(ok(null));
|
||||
}),
|
||||
...createAudioContentMockHandlers(store, (request) => accessResponse(store, request), { apiBaseUrl, invalidRequestMessage, parseCreateRequest: parseAudioContentCreateRequest, parseDeactivateRequest: parseAudioContentDeactivateRequest, parseUpdateRequest: parseAudioContentUpdateRequest }),
|
||||
...createCommunityPostMockHandlers(store, (request) => accessResponse(store, request), { apiBaseUrl, invalidRequestMessage, parseCreateRequest: parseCommunityPostCreateRequest, parseUpdateRequest: parseCommunityPostUpdateRequest }),
|
||||
...createCommentMockHandlers(store, (request) => accessResponse(store, request), { apiBaseUrl, invalidRequestMessage, parseAudioCreateRequest: parseAudioCommentCreateRequest, parseCommunityCreateRequest: parseCommunityCommentCreateRequest, parseUpdateRequest: parseCommentUpdateRequest }),
|
||||
...createFanTalkMockHandlers(store, (request) => accessResponse(store, request), { apiBaseUrl, invalidRequestMessage }),
|
||||
...createSeriesMockHandlers(store, (request) => accessResponse(store, request), { apiBaseUrl, invalidRequestMessage, parseCreateRequest: parseSeriesCreateRequest, parseUpdateRequest: parseSeriesUpdateRequest }),
|
||||
http.get(endpointUrl(apiBaseUrl, "/api/v2/admin/ai-characters/:characterId"), ({ params, request }) => {
|
||||
const deniedResponse = accessResponse(store, request);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
|
||||
const characterId = typeof params.characterId === "string" ? params.characterId : "";
|
||||
const character = store.getCharacter(characterId);
|
||||
if (character === null) {
|
||||
return HttpResponse.json(error("AI 캐릭터를 찾을 수 없습니다."), { status: 404 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok(character));
|
||||
}),
|
||||
http.put(endpointUrl(apiBaseUrl, "/api/v2/admin/ai-characters/:characterId"), async ({ params, request }) => {
|
||||
const deniedResponse = accessResponse(store, request);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
const mutation = await parseCharacterMutationRequest(request);
|
||||
if (mutation === null) {
|
||||
return HttpResponse.json(error(invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
|
||||
const characterId = typeof params.characterId === "string" ? params.characterId : "";
|
||||
if (!store.updateCharacter(characterId, mutation)) {
|
||||
return HttpResponse.json(error("AI 캐릭터를 찾을 수 없습니다."), { status: 404 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok(null));
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
40
src/shared/mocks/series-fixtures.ts
Normal file
40
src/shared/mocks/series-fixtures.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { SeriesGenreItem, SeriesListItem } from "@/features/series/model/types";
|
||||
|
||||
const previewCoverImageUrl = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='128' height='128' viewBox='0 0 128 128'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop stop-color='%23D9F6FF'/%3E%3Cstop offset='1' stop-color='%2300BDF7'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='128' height='128' rx='20' fill='url(%23g)'/%3E%3Cpath d='M38 36h52v10H38zM38 59h52v10H38zM38 82h34v10H38z' fill='%23062B36'/%3E%3C/svg%3E";
|
||||
|
||||
export const mockSeriesListItems = [
|
||||
{
|
||||
seriesId: 5001,
|
||||
title: "달빛 상담 시리즈",
|
||||
introduction: "밤마다 이어지는 상담 에피소드",
|
||||
coverImageUrl: previewCoverImageUrl,
|
||||
publishedDaysOfWeek: ["SUN", "WED"],
|
||||
genreId: 77,
|
||||
isAdult: false,
|
||||
state: "PROCEEDING",
|
||||
isActive: true,
|
||||
writer: "스튜디오 루나",
|
||||
studio: "소다랩",
|
||||
},
|
||||
{
|
||||
seriesId: 5002,
|
||||
title: "아침 루틴 시리즈",
|
||||
introduction: "하루 시작을 돕는 짧은 안내",
|
||||
coverImageUrl: previewCoverImageUrl,
|
||||
publishedDaysOfWeek: ["RANDOM"],
|
||||
genreId: 88,
|
||||
isAdult: false,
|
||||
state: "SUSPEND",
|
||||
isActive: true,
|
||||
writer: null,
|
||||
studio: "소다랩",
|
||||
},
|
||||
] as const satisfies readonly SeriesListItem[];
|
||||
|
||||
export const mockSeriesGenres = [
|
||||
{ id: 77, genre: "로맨스", isAdult: false },
|
||||
{ id: 88, genre: "일상", isAdult: false },
|
||||
{ id: 99, genre: "성인 로맨스", isAdult: true },
|
||||
] as const satisfies readonly SeriesGenreItem[];
|
||||
|
||||
export const mockSeriesCoverImageUrl = previewCoverImageUrl;
|
||||
231
src/shared/mocks/series-handlers.ts
Normal file
231
src/shared/mocks/series-handlers.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import type { RequestHandler } from "msw";
|
||||
import { z } from "zod";
|
||||
|
||||
import { error, ok } from "@/shared/mocks/contract";
|
||||
import type { SeriesCreateMutation, SeriesUpdateMutation } from "@/shared/mocks/series-mock-store";
|
||||
|
||||
type AccessResponse = (request: Request) => Response | null;
|
||||
|
||||
type SeriesStore = {
|
||||
readonly addSeriesContents: (characterId: string, seriesId: string, contentIdList: readonly number[]) => boolean;
|
||||
readonly createSeries: (characterId: string, mutation: SeriesCreateMutation) => boolean;
|
||||
readonly getSeriesDetail: (characterId: string, seriesId: string) => unknown | null;
|
||||
readonly listSeriesGenres: () => unknown;
|
||||
readonly listSeriesContents: (characterId: string, seriesId: string, page: number, size: number) => unknown | null;
|
||||
readonly listSeries: (characterId: string, page: number, size: number) => unknown | null;
|
||||
readonly removeSeriesContent: (characterId: string, seriesId: string, contentId: number) => boolean;
|
||||
readonly searchUnlinkedSeriesContents: (characterId: string, seriesId: string, searchWord: string) => unknown | null;
|
||||
readonly updateSeries: (characterId: string, seriesId: string, mutation: SeriesUpdateMutation) => boolean;
|
||||
readonly updateSeriesOrder: (characterId: string, ids: readonly number[]) => boolean;
|
||||
};
|
||||
|
||||
type SeriesMockHandlerOptions = {
|
||||
readonly apiBaseUrl: string;
|
||||
readonly invalidRequestMessage: string;
|
||||
readonly parseCreateRequest: (request: Request) => Promise<SeriesCreateMutation | null>;
|
||||
readonly parseUpdateRequest: (request: Request) => Promise<SeriesUpdateMutation | null>;
|
||||
};
|
||||
|
||||
function endpointUrl(apiBaseUrl: string, path: string): string {
|
||||
return new URL(path, apiBaseUrl).toString();
|
||||
}
|
||||
|
||||
function parseListQuery(request: Request): { readonly page: number; readonly size: number } | null {
|
||||
const url = new URL(request.url);
|
||||
const page = Number(url.searchParams.get("page") ?? "0");
|
||||
const size = Number(url.searchParams.get("size") ?? "20");
|
||||
const queryKeys = [...url.searchParams.keys()];
|
||||
if (!queryKeys.every((key) => key === "page" || key === "size") || !Number.isInteger(page) || page < 0 || !Number.isInteger(size) || size < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { page, size };
|
||||
}
|
||||
|
||||
const seriesContentAddRequestSchema = z.strictObject({ contentIdList: z.array(z.number().int()) });
|
||||
const seriesOrderUpdateRequestSchema = z.strictObject({ ids: z.array(z.number().int()) });
|
||||
|
||||
async function parseJsonRequest<T>(request: Request, schema: z.ZodType<T>): Promise<T | null> {
|
||||
if (request.headers.get("Content-Type")?.toLowerCase().split(";")[0]?.trim() !== "application/json") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return schema.parse(await request.json());
|
||||
} catch (parseError) {
|
||||
if (parseError instanceof SyntaxError || parseError instanceof z.ZodError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw parseError;
|
||||
}
|
||||
}
|
||||
|
||||
export function createSeriesMockHandlers(store: SeriesStore, accessResponse: AccessResponse, options: SeriesMockHandlerOptions): readonly RequestHandler[] {
|
||||
return [
|
||||
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/series-genres"), ({ request }) => {
|
||||
const deniedResponse = accessResponse(request);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
if (new URL(request.url).search !== "") {
|
||||
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok(store.listSeriesGenres()));
|
||||
}),
|
||||
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/series"), ({ params, request }) => {
|
||||
const deniedResponse = accessResponse(request);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
const query = parseListQuery(request);
|
||||
if (query === null) {
|
||||
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
|
||||
const characterId = typeof params.characterId === "string" ? params.characterId : "";
|
||||
const series = store.listSeries(characterId, query.page, query.size);
|
||||
if (series === null) {
|
||||
return HttpResponse.json(error("AI 캐릭터를 찾을 수 없습니다."), { status: 404 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok(series));
|
||||
}),
|
||||
http.post(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/series"), async ({ params, request }) => {
|
||||
const deniedResponse = accessResponse(request);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
const body = await options.parseCreateRequest(request);
|
||||
if (body === null) {
|
||||
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
const characterId = typeof params.characterId === "string" ? params.characterId : "";
|
||||
if (!store.createSeries(characterId, body)) {
|
||||
return HttpResponse.json(error("AI 캐릭터를 찾을 수 없습니다."), { status: 404 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok(null));
|
||||
}),
|
||||
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/series/:seriesId"), ({ params, request }) => {
|
||||
const deniedResponse = accessResponse(request);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
|
||||
const characterId = typeof params.characterId === "string" ? params.characterId : "";
|
||||
const seriesId = typeof params.seriesId === "string" ? params.seriesId : "";
|
||||
const series = store.getSeriesDetail(characterId, seriesId);
|
||||
if (series === null) {
|
||||
return HttpResponse.json(error("시리즈를 찾을 수 없습니다."), { status: 404 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok(series));
|
||||
}),
|
||||
http.put(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/series/orders"), async ({ params, request }) => {
|
||||
const deniedResponse = accessResponse(request);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
const body = await parseJsonRequest(request, seriesOrderUpdateRequestSchema);
|
||||
if (body === null) {
|
||||
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
const characterId = typeof params.characterId === "string" ? params.characterId : "";
|
||||
if (!store.updateSeriesOrder(characterId, body.ids)) {
|
||||
return HttpResponse.json(error("시리즈 순서가 최신 목록과 맞지 않습니다."), { status: 409 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok(null));
|
||||
}),
|
||||
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/series/:seriesId/contents"), ({ params, request }) => {
|
||||
const deniedResponse = accessResponse(request);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
const query = parseListQuery(request);
|
||||
if (query === null) {
|
||||
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
const characterId = typeof params.characterId === "string" ? params.characterId : "";
|
||||
const seriesId = typeof params.seriesId === "string" ? params.seriesId : "";
|
||||
const contents = store.listSeriesContents(characterId, seriesId, query.page, query.size);
|
||||
if (contents === null) {
|
||||
return HttpResponse.json(error("시리즈를 찾을 수 없습니다."), { status: 404 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok(contents));
|
||||
}),
|
||||
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/series/:seriesId/contents/search"), ({ params, request }) => {
|
||||
const deniedResponse = accessResponse(request);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
const url = new URL(request.url);
|
||||
const searchWord = url.searchParams.get("search_word");
|
||||
if (searchWord === null || [...url.searchParams.keys()].some((key) => key !== "search_word")) {
|
||||
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
const characterId = typeof params.characterId === "string" ? params.characterId : "";
|
||||
const seriesId = typeof params.seriesId === "string" ? params.seriesId : "";
|
||||
const contents = store.searchUnlinkedSeriesContents(characterId, seriesId, searchWord);
|
||||
if (contents === null) {
|
||||
return HttpResponse.json(error("시리즈를 찾을 수 없습니다."), { status: 404 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok(contents));
|
||||
}),
|
||||
http.post(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/series/:seriesId/contents"), async ({ params, request }) => {
|
||||
const deniedResponse = accessResponse(request);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
const body = await parseJsonRequest(request, seriesContentAddRequestSchema);
|
||||
if (body === null) {
|
||||
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
const characterId = typeof params.characterId === "string" ? params.characterId : "";
|
||||
const seriesId = typeof params.seriesId === "string" ? params.seriesId : "";
|
||||
if (!store.addSeriesContents(characterId, seriesId, body.contentIdList)) {
|
||||
return HttpResponse.json(error("시리즈 또는 오디오 콘텐츠를 찾을 수 없습니다."), { status: 404 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok(null));
|
||||
}),
|
||||
http.delete(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/series/:seriesId/contents/:contentId"), async ({ params, request }) => {
|
||||
const deniedResponse = accessResponse(request);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
if ((await request.text()) !== "") {
|
||||
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
const characterId = typeof params.characterId === "string" ? params.characterId : "";
|
||||
const seriesId = typeof params.seriesId === "string" ? params.seriesId : "";
|
||||
const contentId = Number(typeof params.contentId === "string" ? params.contentId : "");
|
||||
if (!Number.isInteger(contentId) || !store.removeSeriesContent(characterId, seriesId, contentId)) {
|
||||
return HttpResponse.json(error("시리즈 또는 오디오 콘텐츠를 찾을 수 없습니다."), { status: 404 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok(null));
|
||||
}),
|
||||
http.put(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/series/:seriesId"), async ({ params, request }) => {
|
||||
const deniedResponse = accessResponse(request);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
const body = await options.parseUpdateRequest(request);
|
||||
if (body === null) {
|
||||
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
const characterId = typeof params.characterId === "string" ? params.characterId : "";
|
||||
const seriesId = typeof params.seriesId === "string" ? params.seriesId : "";
|
||||
if (!store.updateSeries(characterId, seriesId, body)) {
|
||||
return HttpResponse.json(error("시리즈를 찾을 수 없습니다."), { status: 404 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok(null));
|
||||
}),
|
||||
];
|
||||
}
|
||||
189
src/shared/mocks/series-mock-store.ts
Normal file
189
src/shared/mocks/series-mock-store.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { CharacterDetail } from "@/features/characters/model/types";
|
||||
import type { SeriesContentListItem, SeriesContentSearchItem, SeriesListItem } from "@/features/series/model/types";
|
||||
import type { SeriesCreateRequest, SeriesDeactivateRequest, SeriesUpdateRequest } from "@/features/series/schemas/series-schema";
|
||||
import { seriesCreateRequestSchema, seriesDeactivateRequestSchema, seriesUpdateRequestSchema } from "@/features/series/schemas/series-schema";
|
||||
import { mockAudioContentListItems } from "@/shared/mocks/audio-content-fixtures";
|
||||
import { mockSeriesCoverImageUrl, mockSeriesGenres, mockSeriesListItems } from "@/shared/mocks/series-fixtures";
|
||||
|
||||
type GetCharacter = (characterId: string) => CharacterDetail | null;
|
||||
export type SeriesCreateMutation = { readonly image: File | null; readonly request: SeriesCreateRequest };
|
||||
export type SeriesUpdateMutation = { readonly image: File | null; readonly request: SeriesUpdateRequest | SeriesDeactivateRequest };
|
||||
|
||||
export class SeriesMockStore {
|
||||
#seriesListItems: SeriesListItem[] = mockSeriesListItems.map(toMutableSeriesListItem);
|
||||
#seriesContentIds = new Map<string, readonly number[]>([["5001", [9001]]]);
|
||||
readonly #getCharacter: GetCharacter;
|
||||
#nextSeriesId = 6000;
|
||||
|
||||
constructor(getCharacter: GetCharacter) {
|
||||
this.#getCharacter = getCharacter;
|
||||
}
|
||||
|
||||
listSeries(characterId: string, page: number, size: number) {
|
||||
if (this.#getCharacter(characterId) === null) {
|
||||
return null;
|
||||
}
|
||||
const start = page * size;
|
||||
|
||||
return {
|
||||
totalCount: this.#seriesListItems.length,
|
||||
items: this.#seriesListItems.slice(start, start + size),
|
||||
};
|
||||
}
|
||||
|
||||
listSeriesGenres() {
|
||||
return mockSeriesGenres.map((genre) => ({ ...genre }));
|
||||
}
|
||||
|
||||
getSeriesDetail(characterId: string, seriesId: string) {
|
||||
if (this.#getCharacter(characterId) === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.#seriesListItems.find((series) => String(series.seriesId) === seriesId) ?? null;
|
||||
}
|
||||
|
||||
createSeries(characterId: string, mutation: SeriesCreateMutation): boolean {
|
||||
if (this.#getCharacter(characterId) === null || mutation.image === null) {
|
||||
return false;
|
||||
}
|
||||
const seriesId = this.#nextSeriesId;
|
||||
this.#nextSeriesId += 1;
|
||||
this.#seriesListItems = [...this.#seriesListItems, { seriesId, title: mutation.request.title, introduction: mutation.request.introduction, coverImageUrl: mockSeriesCoverImageUrl, publishedDaysOfWeek: [...mutation.request.publishedDaysOfWeek], genreId: mutation.request.genreId, isAdult: mutation.request.isAdult ?? false, state: "PROCEEDING", isActive: true, writer: mutation.request.writer ?? null, studio: mutation.request.studio ?? null }];
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
updateSeries(characterId: string, seriesId: string, mutation: SeriesUpdateMutation): boolean {
|
||||
if (this.#getCharacter(characterId) === null) {
|
||||
return false;
|
||||
}
|
||||
const current = this.getSeriesDetail(characterId, seriesId);
|
||||
if (current === null) {
|
||||
return false;
|
||||
}
|
||||
if ("isActive" in mutation.request) {
|
||||
this.#seriesListItems = this.#seriesListItems.filter((item) => item.seriesId !== current.seriesId);
|
||||
return true;
|
||||
}
|
||||
const next = { ...current, title: mutation.request.title ?? current.title, introduction: mutation.request.introduction ?? current.introduction, coverImageUrl: mutation.image === null ? current.coverImageUrl : mockSeriesCoverImageUrl, publishedDaysOfWeek: mutation.request.publishedDaysOfWeek ?? current.publishedDaysOfWeek, genreId: mutation.request.genreId ?? current.genreId, isAdult: mutation.request.isAdult ?? current.isAdult, state: mutation.request.state ?? current.state, writer: mutation.request.writer ?? current.writer, studio: mutation.request.studio ?? current.studio };
|
||||
this.#seriesListItems = this.#seriesListItems.map((item) => (item.seriesId === next.seriesId ? next : item));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
listSeriesContents(characterId: string, seriesId: string, page: number, size: number) {
|
||||
if (this.getSeriesDetail(characterId, seriesId) === null) {
|
||||
return null;
|
||||
}
|
||||
const linkedIds = this.#seriesContentIds.get(seriesId) ?? [];
|
||||
const items = linkedIds.map(toSeriesContentItem).filter((item) => item !== null);
|
||||
const start = page * size;
|
||||
|
||||
return { totalCount: items.length, items: items.slice(start, start + size) };
|
||||
}
|
||||
|
||||
searchUnlinkedSeriesContents(characterId: string, seriesId: string, searchWord: string) {
|
||||
if (this.getSeriesDetail(characterId, seriesId) === null) {
|
||||
return null;
|
||||
}
|
||||
const linkedIds = new Set(this.#seriesContentIds.get(seriesId) ?? []);
|
||||
const normalizedSearch = searchWord.trim().toLowerCase();
|
||||
|
||||
return mockAudioContentListItems
|
||||
.filter((item) => !linkedIds.has(item.audioContentId))
|
||||
.filter((item) => `${item.title} ${item.detail} ${item.theme} ${item.tags}`.toLowerCase().includes(normalizedSearch))
|
||||
.map(toSeriesContentSearchItemFromAudio);
|
||||
}
|
||||
|
||||
addSeriesContents(characterId: string, seriesId: string, contentIdList: readonly number[]): boolean {
|
||||
if (this.getSeriesDetail(characterId, seriesId) === null) {
|
||||
return false;
|
||||
}
|
||||
const contentIds = new Set<number>(mockAudioContentListItems.map((item) => item.audioContentId));
|
||||
if (!contentIdList.every((contentId) => contentIds.has(contentId))) {
|
||||
return false;
|
||||
}
|
||||
const currentIds = this.#seriesContentIds.get(seriesId) ?? [];
|
||||
const nextIds = [...currentIds, ...contentIdList.filter((contentId) => !currentIds.includes(contentId))];
|
||||
this.#seriesContentIds.set(seriesId, nextIds);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
removeSeriesContent(characterId: string, seriesId: string, contentId: number): boolean {
|
||||
if (this.getSeriesDetail(characterId, seriesId) === null) {
|
||||
return false;
|
||||
}
|
||||
const currentIds = this.#seriesContentIds.get(seriesId) ?? [];
|
||||
this.#seriesContentIds.set(seriesId, currentIds.filter((id) => id !== contentId));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
updateSeriesOrder(characterId: string, ids: readonly number[]): boolean {
|
||||
if (this.#getCharacter(characterId) === null || ids.length !== this.#seriesListItems.length) {
|
||||
return false;
|
||||
}
|
||||
const currentIds = new Set(this.#seriesListItems.map((series) => series.seriesId));
|
||||
if (!ids.every((id) => currentIds.has(id))) {
|
||||
return false;
|
||||
}
|
||||
this.#seriesListItems = ids.map((id) => this.#seriesListItems.find((series) => series.seriesId === id)).filter((series) => series !== undefined);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function toSeriesContentItem(contentId: number): SeriesContentListItem | null {
|
||||
const audio = mockAudioContentListItems.find((item) => item.audioContentId === contentId);
|
||||
|
||||
return audio === undefined ? null : toSeriesContentListItemFromAudio(audio);
|
||||
}
|
||||
|
||||
function toSeriesContentListItemFromAudio(audio: (typeof mockAudioContentListItems)[number]): SeriesContentListItem {
|
||||
return { contentId: audio.audioContentId, title: audio.title, coverImage: audio.coverImageUrl, isAdult: audio.isAdult };
|
||||
}
|
||||
|
||||
function toSeriesContentSearchItemFromAudio(audio: (typeof mockAudioContentListItems)[number]): SeriesContentSearchItem {
|
||||
return { contentId: audio.audioContentId, title: audio.title, coverImage: audio.coverImageUrl };
|
||||
}
|
||||
|
||||
function toMutableSeriesListItem(series: (typeof mockSeriesListItems)[number]): SeriesListItem {
|
||||
return {
|
||||
...series,
|
||||
publishedDaysOfWeek: [...series.publishedDaysOfWeek],
|
||||
};
|
||||
}
|
||||
|
||||
export async function parseSeriesCreateRequest(request: Request): Promise<SeriesCreateMutation | null> {
|
||||
return parseSeriesMutationRequest(request, seriesCreateRequestSchema);
|
||||
}
|
||||
|
||||
export async function parseSeriesUpdateRequest(request: Request): Promise<SeriesUpdateMutation | null> {
|
||||
return parseSeriesMutationRequest(request, z.union([seriesDeactivateRequestSchema, seriesUpdateRequestSchema]));
|
||||
}
|
||||
|
||||
async function parseSeriesMutationRequest<MutationRequest extends object>(request: Request, schema: z.ZodType<MutationRequest>): Promise<{ readonly image: File | null; readonly request: MutationRequest } | null> {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const requestPart = formData.get("request");
|
||||
if (!(requestPart instanceof Blob)) {
|
||||
return null;
|
||||
}
|
||||
const image = formData.get("image");
|
||||
if (image !== null && !(image instanceof File)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { image, request: schema.parse(JSON.parse(await requestPart.text())) };
|
||||
} catch (parseError) {
|
||||
if (parseError instanceof SyntaxError || parseError instanceof z.ZodError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw parseError;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,9 @@ test("ConfirmDeactivateDialog confirms deactivation with target and impact copy,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("alertdialog", { name: "루나 비활성화 확인" })).toHaveTextContent("사용자는 이 캐릭터를 더 이상 선택할 수 없습니다.");
|
||||
expect(screen.getByRole("alertdialog", { name: "루나 비활성화 확인" })).not.toHaveTextContent("완전 삭제");
|
||||
expect(screen.queryByRole("switch")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "비활성화" })).toHaveClass("bg-destructive");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "취소" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "비활성화" }));
|
||||
@@ -28,6 +30,40 @@ test("ConfirmDeactivateDialog confirms deactivation with target and impact copy,
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("ConfirmDeactivateDialog exposes pending and error state while keeping retry available", () => {
|
||||
const onConfirm = vi.fn();
|
||||
|
||||
const { rerender } = render(
|
||||
<ConfirmDeactivateDialog
|
||||
errorMessage="비활성화하지 못했습니다. 다시 시도하세요."
|
||||
impactDescription="사용자는 이 캐릭터를 더 이상 선택할 수 없습니다."
|
||||
isPending
|
||||
onCancel={vi.fn()}
|
||||
onConfirm={onConfirm}
|
||||
open
|
||||
targetName="루나"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("alertdialog", { name: "루나 비활성화 확인" })).toHaveAttribute("aria-busy", "true");
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("비활성화하지 못했습니다. 다시 시도하세요.");
|
||||
fireEvent.click(screen.getByRole("button", { name: "처리 중" }));
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
|
||||
rerender(
|
||||
<ConfirmDeactivateDialog
|
||||
errorMessage="비활성화하지 못했습니다. 다시 시도하세요."
|
||||
impactDescription="사용자는 이 캐릭터를 더 이상 선택할 수 없습니다."
|
||||
onCancel={vi.fn()}
|
||||
onConfirm={onConfirm}
|
||||
open
|
||||
targetName="루나"
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "비활성화" }));
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("ConfirmDeactivateDialog traps focus and returns it to the trigger after cancel", async () => {
|
||||
function Harness() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -23,6 +23,7 @@ test("FileField exposes label, description, error, accept guidance, keyboard fil
|
||||
expect(input).toHaveAttribute("accept", "image/png");
|
||||
expect(input).toHaveAttribute("aria-invalid", "true");
|
||||
expect(input).toHaveAccessibleDescription("프로필 이미지를 선택하세요. PNG만 업로드할 수 있습니다. 파일이 너무 큽니다.");
|
||||
expect(screen.getByRole("button", { name: "대표 이미지 파일 선택" })).toBeInTheDocument();
|
||||
expect(screen.getByText("profile.png")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "선택 취소" })).toBeInTheDocument();
|
||||
});
|
||||
@@ -39,3 +40,16 @@ test("FileField emits File or null and clear selection without owning upload pol
|
||||
fireEvent.click(screen.getByRole("button", { name: "선택 취소" }));
|
||||
expect(onChange).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
test("FileField gives same visible file buttons field-specific accessible names", () => {
|
||||
render(
|
||||
<>
|
||||
<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오 파일" onChange={vi.fn()} value={null} />
|
||||
<FileField accept="image/png" acceptDescription="PNG" label="커버 이미지" onChange={vi.fn()} value={null} />
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByText("파일 선택")).toHaveLength(2);
|
||||
expect(screen.getByRole("button", { name: "오디오 파일 파일 선택" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "커버 이미지 파일 선택" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -11,6 +11,51 @@ const image = {
|
||||
width: 600,
|
||||
};
|
||||
|
||||
function setPreviewFrameSize(width: number, height: number): void {
|
||||
Object.defineProperty(HTMLImageElement.prototype, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: () => ({ bottom: height, height, left: 0, right: width, top: 0, width, x: 0, y: 0, toJSON: () => ({}) }),
|
||||
});
|
||||
}
|
||||
|
||||
function rect(width: number, height: number): DOMRect {
|
||||
return { bottom: height, height, left: 0, right: width, top: 0, width, x: 0, y: 0, toJSON: () => ({}) };
|
||||
}
|
||||
|
||||
async function withElementRects(testBody: () => Promise<void>): Promise<void> {
|
||||
const originalElementRect = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "getBoundingClientRect");
|
||||
const originalImageRect = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, "getBoundingClientRect");
|
||||
|
||||
Object.defineProperty(HTMLElement.prototype, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value(this: HTMLElement) {
|
||||
if (this.getAttribute("aria-label") === "이미지 crop viewport") {
|
||||
return rect(181, 256);
|
||||
}
|
||||
|
||||
return rect(0, 0);
|
||||
},
|
||||
});
|
||||
Object.defineProperty(HTMLImageElement.prototype, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: () => rect(384, 384),
|
||||
});
|
||||
|
||||
return testBody().finally(() => {
|
||||
if (originalElementRect === undefined) {
|
||||
Reflect.deleteProperty(HTMLElement.prototype, "getBoundingClientRect");
|
||||
} else {
|
||||
Object.defineProperty(HTMLElement.prototype, "getBoundingClientRect", originalElementRect);
|
||||
}
|
||||
|
||||
if (originalImageRect === undefined) {
|
||||
Reflect.deleteProperty(HTMLImageElement.prototype, "getBoundingClientRect");
|
||||
} else {
|
||||
Object.defineProperty(HTMLImageElement.prototype, "getBoundingClientRect", originalImageRect);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("ImageCropDialog provides move, zoom, reset, preview, cancel, and apply controls", async () => {
|
||||
const onApply = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
@@ -20,7 +65,7 @@ test("ImageCropDialog provides move, zoom, reset, preview, cancel, and apply con
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "오른쪽으로 이동" }));
|
||||
fireEvent.change(screen.getByRole("slider", { name: "확대 비율" }), { target: { value: "1.5" } });
|
||||
expect(screen.getByText("예상 결과 600 × 600px")).toBeInTheDocument();
|
||||
expect(screen.getByText("예상 결과 400 × 400px")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "초기화" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||
@@ -42,8 +87,118 @@ test("ImageCropDialog supports keyboard movement and no-upscale sizing", async (
|
||||
fireEvent.keyDown(preview, { key: "+" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||
|
||||
expect(await screen.findByText("예상 결과 600 × 300px")).toBeInTheDocument();
|
||||
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetX: 10, outputHeight: 300, outputWidth: 600, zoom: 1.1 }));
|
||||
expect(await screen.findByText("예상 결과 545 × 272px")).toBeInTheDocument();
|
||||
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetX: 10, outputHeight: 272, outputWidth: 545, zoom: 1.1 }));
|
||||
});
|
||||
|
||||
test("ImageCropDialog sends preview frame dimensions with crop offsets", async () => {
|
||||
setPreviewFrameSize(256, 256);
|
||||
const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([`${request.offsetX}`], "crop.png", { type: "image/png" })));
|
||||
|
||||
render(<ImageCropDialog image={{ ...image, height: 3000, width: 4000 }} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: 1, maxWidth: 1000, noUpscale: true }} renderCrop={renderCrop} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "오른쪽으로 이동" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||
|
||||
await screen.findByText("예상 결과 1000 × 1000px");
|
||||
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetX: 10, previewFrameHeight: 256, previewFrameWidth: 256, sourceHeight: 3000, sourceWidth: 4000 }));
|
||||
});
|
||||
|
||||
test("ImageCropDialog measures the visible crop viewport instead of the transformed image", async () => {
|
||||
await withElementRects(async () => {
|
||||
const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([`${request.offsetX}`], "crop.png", { type: "image/png" })));
|
||||
|
||||
render(<ImageCropDialog image={{ ...image, height: 3000, width: 4000 }} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: 210 / 297, maxWidth: 1000, noUpscale: true }} renderCrop={renderCrop} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "오른쪽으로 이동" }));
|
||||
fireEvent.change(screen.getByRole("slider", { name: "확대 비율" }), { target: { value: "1.5" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||
|
||||
await screen.findByText("예상 결과 1000 × 1414px");
|
||||
expect(screen.getByLabelText("이미지 crop viewport")).toHaveStyle({ aspectRatio: `${210 / 297}` });
|
||||
expect(screen.getByAltText("선택한 이미지 미리보기")).toHaveClass("h-full", "w-auto", "max-w-none");
|
||||
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ previewFrameHeight: 256, previewFrameWidth: 181, zoom: 1.5 }));
|
||||
});
|
||||
});
|
||||
|
||||
test("ImageCropDialog clamps movement on axes without crop overhang", async () => {
|
||||
await withElementRects(async () => {
|
||||
const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([`${request.offsetY}`], "crop.png", { type: "image/png" })));
|
||||
|
||||
render(<ImageCropDialog image={{ ...image, height: 3000, width: 4000 }} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: 210 / 297, maxWidth: 1000, noUpscale: true }} renderCrop={renderCrop} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "아래로 이동" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||
|
||||
expect(screen.getByAltText("선택한 이미지 미리보기")).toHaveStyle({ transform: "translate(-50%, -50%) translate(0px, 0px) scale(1)" });
|
||||
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetY: 0 }));
|
||||
});
|
||||
});
|
||||
|
||||
test("ImageCropDialog keeps apply single-flight and allows retry after render failure", async () => {
|
||||
const onApply = vi.fn();
|
||||
let rejectCrop: (error: Error) => void = () => undefined;
|
||||
const renderCrop = vi.fn(() => new Promise<File>((_resolve, reject) => {
|
||||
rejectCrop = reject;
|
||||
}));
|
||||
|
||||
render(<ImageCropDialog image={image} onApply={onApply} onCancel={vi.fn()} open policy={{ aspect: 1, maxWidth: 800, noUpscale: true }} renderCrop={renderCrop} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||
await screen.findByRole("status");
|
||||
expect(renderCrop).toHaveBeenCalledTimes(1);
|
||||
|
||||
rejectCrop(new Error("render failed"));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("이미지 crop을 적용하지 못했습니다.");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||
expect(renderCrop).toHaveBeenCalledTimes(2);
|
||||
expect(onApply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("ImageCropDialog changes zoom with a two pointer pinch", async () => {
|
||||
const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([String(request.zoom)], "crop.png", { type: "image/png" })));
|
||||
|
||||
render(<ImageCropDialog image={image} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: 1, maxWidth: 800, noUpscale: true }} renderCrop={renderCrop} />);
|
||||
|
||||
const preview = screen.getByRole("application", { name: "이미지 crop 미리보기" });
|
||||
fireEvent.pointerDown(preview, { clientX: 100, clientY: 100, pointerId: 1 });
|
||||
fireEvent.pointerDown(preview, { clientX: 200, clientY: 100, pointerId: 2 });
|
||||
fireEvent.pointerMove(preview, { clientX: 250, clientY: 100, pointerId: 2 });
|
||||
fireEvent.pointerUp(preview, { pointerId: 1 });
|
||||
fireEvent.pointerUp(preview, { pointerId: 2 });
|
||||
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||
|
||||
expect(await screen.findByText("예상 결과 400 × 400px")).toBeInTheDocument();
|
||||
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ outputHeight: 400, outputWidth: 400, zoom: 1.5 }));
|
||||
expect(screen.getByRole("slider", { name: "확대 비율" })).toHaveValue("1.5");
|
||||
});
|
||||
|
||||
test("ImageCropDialog disables native touch gestures on the crop preview", () => {
|
||||
render(<ImageCropDialog image={image} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: 1, maxWidth: 800, noUpscale: true }} />);
|
||||
|
||||
expect(screen.getByRole("application", { name: "이미지 crop 미리보기" })).toHaveStyle({ touchAction: "none" });
|
||||
});
|
||||
|
||||
test("ImageCropDialog closes itself on Escape without bubbling to parent dialogs", () => {
|
||||
const onCancel = vi.fn();
|
||||
const onParentEscape = vi.fn();
|
||||
|
||||
render(
|
||||
<div onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
onParentEscape();
|
||||
}
|
||||
}}>
|
||||
<ImageCropDialog image={image} onApply={vi.fn()} onCancel={onCancel} open policy={{ aspect: 1, maxWidth: 800, noUpscale: true }} />
|
||||
</div>,
|
||||
);
|
||||
|
||||
fireEvent.keyDown(screen.getByRole("dialog", { name: "이미지 crop" }), { key: "Escape" });
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
expect(onParentEscape).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("ImageCropDialog supports free ratio output and pointer drag movement", async () => {
|
||||
@@ -52,6 +207,7 @@ test("ImageCropDialog supports free ratio output and pointer drag movement", asy
|
||||
render(<ImageCropDialog image={{ ...image, height: 600, width: 1200 }} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: "free", maxWidth: 800, noUpscale: true }} renderCrop={renderCrop} />);
|
||||
|
||||
const preview = screen.getByRole("application", { name: "이미지 crop 미리보기" });
|
||||
fireEvent.change(screen.getByRole("slider", { name: "확대 비율" }), { target: { value: "1.5" } });
|
||||
fireEvent.pointerDown(preview, { clientX: 100, clientY: 100, pointerId: 1 });
|
||||
fireEvent.pointerMove(preview, { clientX: 130, clientY: 115, pointerId: 1 });
|
||||
fireEvent.pointerUp(preview, { pointerId: 1 });
|
||||
|
||||
@@ -9,7 +9,9 @@ describe("MockModeBanner", () => {
|
||||
render(<MockModeBanner apiMode="mock" />);
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("status", { name: "Mock Preview" })).toHaveTextContent("Mock Preview");
|
||||
const banner = screen.getByRole("status", { name: "Mock Preview" });
|
||||
expect(banner).toHaveTextContent("Mock Preview");
|
||||
expect(banner).toHaveClass("break-keep");
|
||||
});
|
||||
|
||||
test("does not render in server mode", () => {
|
||||
|
||||
@@ -40,3 +40,19 @@ test("ResourcePagination disables unavailable previous and next actions", () =>
|
||||
expect(screen.getByRole("button", { name: "이전 페이지" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "다음 페이지" })).toBeDisabled();
|
||||
});
|
||||
|
||||
test("ResourcePagination connects each page size label to a unique select", () => {
|
||||
const { container } = render(
|
||||
<>
|
||||
<ResourcePagination data={pageData} onPageChange={vi.fn()} onSizeChange={vi.fn()} />
|
||||
<ResourcePagination data={{ ...pageData, page: 0 }} onPageChange={vi.fn()} onSizeChange={vi.fn()} />
|
||||
</>,
|
||||
);
|
||||
|
||||
const selects = screen.getAllByLabelText("페이지 크기");
|
||||
const selectIds = selects.map((select) => select.id);
|
||||
const labels = Array.from(container.querySelectorAll("label"));
|
||||
|
||||
expect(new Set(selectIds).size).toBe(selectIds.length);
|
||||
expect(labels.map((label) => label.control)).toEqual(selects);
|
||||
});
|
||||
|
||||
@@ -7,33 +7,33 @@ afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("SearchToolbar does not emit a query before user input", () => {
|
||||
vi.useFakeTimers();
|
||||
const onQueryChange = vi.fn();
|
||||
render(<SearchToolbar onQueryChange={onQueryChange} search="루나" />);
|
||||
|
||||
act(() => vi.advanceTimersByTime(300));
|
||||
|
||||
expect(onQueryChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("SearchToolbar keeps search controlled, renders filters, and emits only a debounced generic query", () => {
|
||||
vi.useFakeTimers();
|
||||
const onQueryChange = vi.fn();
|
||||
const onSearchChange = vi.fn();
|
||||
const { rerender } = render(
|
||||
render(
|
||||
<SearchToolbar
|
||||
filters={<select aria-label="상태 필터"><option>전체</option></select>}
|
||||
onQueryChange={onQueryChange}
|
||||
onSearchChange={onSearchChange}
|
||||
search=""
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByRole("searchbox", { name: "검색어" }), { target: { value: "루나" } });
|
||||
|
||||
expect(onSearchChange).toHaveBeenCalledWith("루나");
|
||||
expect(screen.getByRole("searchbox", { name: "검색어" })).toHaveValue("루나");
|
||||
expect(screen.getByLabelText("상태 필터")).toBeInTheDocument();
|
||||
expect(onQueryChange).not.toHaveBeenCalled();
|
||||
|
||||
rerender(
|
||||
<SearchToolbar
|
||||
filters={<select aria-label="상태 필터"><option>전체</option></select>}
|
||||
onQueryChange={onQueryChange}
|
||||
onSearchChange={onSearchChange}
|
||||
search="루나"
|
||||
/>,
|
||||
);
|
||||
act(() => vi.advanceTimersByTime(299));
|
||||
expect(onQueryChange).not.toHaveBeenCalled();
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ export function AdminAudioPlayer({ playerId, src, title }: AdminAudioPlayerProps
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label={`${title} 오디오 플레이어`} className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4" onKeyDown={handleKeyDown} role="group" tabIndex={0}>
|
||||
<section aria-label={`${title} 오디오 플레이어`} className="flex min-w-0 flex-col gap-3 rounded-lg border border-border bg-card p-4" onKeyDown={handleKeyDown} role="group" tabIndex={0}>
|
||||
<audio
|
||||
controlsList="nodownload"
|
||||
onDurationChange={(event) => setDuration(event.currentTarget.duration)}
|
||||
@@ -118,7 +118,7 @@ export function AdminAudioPlayer({ playerId, src, title }: AdminAudioPlayerProps
|
||||
</label>
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">
|
||||
볼륨
|
||||
<input aria-label="볼륨" max="1" min="0" onChange={(event) => changeVolume(Number(event.currentTarget.value))} step="0.05" type="range" />
|
||||
<input aria-label="볼륨" defaultValue="1" max="1" min="0" onChange={(event) => changeVolume(Number(event.currentTarget.value))} step="0.05" type="range" />
|
||||
</label>
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">
|
||||
재생 속도
|
||||
@@ -131,8 +131,8 @@ export function AdminAudioPlayer({ playerId, src, title }: AdminAudioPlayerProps
|
||||
</select>
|
||||
</label>
|
||||
{hasError ? (
|
||||
<div className="flex flex-col gap-2 rounded-md border border-destructive bg-card p-3 text-sm text-destructive" role="alert">
|
||||
<p>오디오를 재생할 수 없습니다. 페이지 새로고침 후 다시 시도하세요.</p>
|
||||
<div className="flex min-w-0 flex-col gap-2 rounded-md border border-destructive bg-card p-3 text-sm text-destructive" role="alert">
|
||||
<p className="break-words">오디오를 재생할 수 없습니다. 페이지 새로고침 후 다시 시도하세요.</p>
|
||||
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={retry} type="button">오디오 다시 시도</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useModalFocus } from "@/shared/ui/use-modal-focus";
|
||||
|
||||
export type ConfirmDeactivateDialogProps = {
|
||||
readonly confirmLabel?: string;
|
||||
readonly errorMessage?: string;
|
||||
readonly impactDescription: string;
|
||||
readonly isPending?: boolean;
|
||||
readonly onCancel: () => void;
|
||||
readonly onConfirm: () => void;
|
||||
readonly open: boolean;
|
||||
readonly targetName: string;
|
||||
};
|
||||
|
||||
export function ConfirmDeactivateDialog({ impactDescription, onCancel, onConfirm, open, targetName }: ConfirmDeactivateDialogProps) {
|
||||
export function ConfirmDeactivateDialog({ confirmLabel = "비활성화", errorMessage, impactDescription, isPending = false, onCancel, onConfirm, open, targetName }: ConfirmDeactivateDialogProps) {
|
||||
const { dialogRef, trapFocus } = useModalFocus<HTMLElement>(open);
|
||||
|
||||
if (!open) {
|
||||
@@ -19,17 +22,18 @@ export function ConfirmDeactivateDialog({ impactDescription, onCancel, onConfirm
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-modal grid place-items-center bg-background/80 p-4">
|
||||
<section aria-modal="true" className="flex w-full max-w-sm flex-col gap-4 rounded-lg border border-border bg-card p-6" onKeyDown={trapFocus} ref={dialogRef} role="alertdialog" aria-label={title}>
|
||||
<section aria-busy={isPending ? true : undefined} aria-modal="true" className="flex w-full max-w-sm flex-col gap-4 rounded-lg border border-border bg-card p-6" onKeyDown={trapFocus} ref={dialogRef} role="alertdialog" aria-label={title}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-xl font-semibold">{title}</h2>
|
||||
<p className="text-sm text-muted-foreground">{impactDescription}</p>
|
||||
</div>
|
||||
{errorMessage === undefined ? null : <p className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" role="alert">{errorMessage}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={onCancel} type="button">
|
||||
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isPending} onClick={onCancel} type="button">
|
||||
취소
|
||||
</button>
|
||||
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={onConfirm} type="button">
|
||||
비활성화
|
||||
<button className="rounded-md border border-destructive bg-destructive px-4 py-2 font-semibold text-white hover:bg-destructive/90 active:bg-destructive disabled:opacity-60" disabled={isPending} onClick={onConfirm} type="button">
|
||||
{isPending ? "처리 중" : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -35,7 +35,8 @@ export function FileField({ accept, acceptDescription, description, error, label
|
||||
<label className="text-sm font-semibold" htmlFor={inputId}>{label}</label>
|
||||
{description === undefined ? null : <p className="text-sm text-muted-foreground" id={descriptionId}>{description}</p>}
|
||||
<p className="text-sm text-muted-foreground" id={acceptId}>{acceptDescription}</p>
|
||||
<input accept={accept} aria-describedby={describedBy} aria-invalid={error === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base text-foreground" id={inputId} onChange={handleChange} ref={inputRef} type="file" />
|
||||
<input accept={accept} aria-describedby={describedBy} aria-invalid={error === undefined ? undefined : true} className="sr-only" id={inputId} onChange={handleChange} ref={inputRef} type="file" />
|
||||
<button aria-label={`${label} 파일 선택`} className="w-fit rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => inputRef.current?.click()} type="button">파일 선택</button>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">{value === null ? "선택된 파일 없음" : value.name}</span>
|
||||
{value === null ? null : (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
import { calculateCropOutputSize, createCroppedImageFile } from "@/shared/lib/crop-image";
|
||||
import type { CropRenderRequest } from "@/shared/lib/crop-image";
|
||||
@@ -8,6 +8,7 @@ export type CropSourceImage = {
|
||||
readonly file: File;
|
||||
readonly height: number;
|
||||
readonly previewUrl: string;
|
||||
readonly release?: () => void;
|
||||
readonly width: number;
|
||||
};
|
||||
|
||||
@@ -29,13 +30,54 @@ export type ImageCropDialogProps = {
|
||||
const MOVE_STEP = 10;
|
||||
const ZOOM_STEP = 0.1;
|
||||
|
||||
type PointerPoint = {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
};
|
||||
|
||||
type PinchState = {
|
||||
readonly distance: number;
|
||||
readonly zoom: number;
|
||||
};
|
||||
|
||||
type CropOffset = {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
};
|
||||
|
||||
type CropFrameSize = {
|
||||
readonly height: number;
|
||||
readonly width: number;
|
||||
};
|
||||
|
||||
export function ImageCropDialog({ image, onApply, onCancel, open, policy, renderCrop = createCroppedImageFile }: ImageCropDialogProps) {
|
||||
const [offsetX, setOffsetX] = useState(0);
|
||||
const [offsetY, setOffsetY] = useState(0);
|
||||
const [applyError, setApplyError] = useState<string | null>(null);
|
||||
const [isApplying, setIsApplying] = useState(false);
|
||||
const [viewportSize, setViewportSize] = useState<CropFrameSize | null>(null);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const dragPointRef = useRef<{ readonly x: number; readonly y: number } | null>(null);
|
||||
const pinchRef = useRef<PinchState | null>(null);
|
||||
const cropViewportRef = useRef<HTMLDivElement>(null);
|
||||
const previewImageRef = useRef<HTMLImageElement>(null);
|
||||
const pointersRef = useRef(new Map<number, PointerPoint>());
|
||||
const { dialogRef, trapFocus } = useModalFocus<HTMLElement>(open);
|
||||
const outputSize = calculateCropOutputSize({ aspect: policy.aspect, maxWidth: policy.maxWidth, noUpscale: policy.noUpscale, sourceHeight: image.height, sourceWidth: image.width });
|
||||
const outputSize = calculateCropOutputSize({ aspect: policy.aspect, maxWidth: policy.maxWidth, noUpscale: policy.noUpscale, sourceHeight: image.height, sourceWidth: image.width, zoom });
|
||||
const cropFrameAspect = policy.aspect === "free" ? image.width / image.height : policy.aspect;
|
||||
const sourceAspect = image.width / image.height;
|
||||
const coverImageClass = sourceAspect > cropFrameAspect ? "h-full w-auto max-w-none" : "h-auto w-full max-w-none";
|
||||
const clampedOffset = clampOffset({ x: offsetX, y: offsetY }, viewportSize);
|
||||
const setCropViewportNode = useCallback((node: HTMLDivElement | null) => {
|
||||
cropViewportRef.current = node;
|
||||
if (node === null) {
|
||||
setViewportSize(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = node.getBoundingClientRect();
|
||||
setViewportSize({ height: rect.height, width: rect.width });
|
||||
}, []);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
@@ -56,7 +98,45 @@ export function ImageCropDialog({ image, onApply, onCancel, open, policy, render
|
||||
setZoom(Math.min(3, Math.max(1, Number(nextZoom.toFixed(1)))));
|
||||
}
|
||||
|
||||
function handleKeyDown(event: React.KeyboardEvent<HTMLElement>) {
|
||||
function clampOffset(offset: CropOffset, frameSize: CropFrameSize | null): CropOffset {
|
||||
if (frameSize === null || frameSize.width <= 0 || frameSize.height <= 0) {
|
||||
return offset;
|
||||
}
|
||||
|
||||
const coverWidthRatio = sourceAspect > cropFrameAspect ? sourceAspect / cropFrameAspect : 1;
|
||||
const coverHeightRatio = sourceAspect > cropFrameAspect ? 1 : cropFrameAspect / sourceAspect;
|
||||
const maxX = Math.max(0, (frameSize.width * coverWidthRatio * zoom - frameSize.width) / 2);
|
||||
const maxY = Math.max(0, (frameSize.height * coverHeightRatio * zoom - frameSize.height) / 2);
|
||||
|
||||
return {
|
||||
x: Math.min(Math.max(offset.x, -maxX), maxX),
|
||||
y: Math.min(Math.max(offset.y, -maxY), maxY),
|
||||
};
|
||||
}
|
||||
|
||||
function getPinchDistance() {
|
||||
const points = Array.from(pointersRef.current.values());
|
||||
const first = points[0];
|
||||
const second = points[1];
|
||||
if (first === undefined || second === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.hypot(second.x - first.x, second.y - first.y);
|
||||
}
|
||||
|
||||
function handleDialogKeyDown(event: React.KeyboardEvent<HTMLElement>) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
trapFocus(event);
|
||||
}
|
||||
|
||||
function handlePreviewKeyDown(event: React.KeyboardEvent<HTMLElement>) {
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
@@ -87,11 +167,32 @@ export function ImageCropDialog({ image, onApply, onCancel, open, policy, render
|
||||
}
|
||||
|
||||
function startDrag(event: React.PointerEvent<HTMLElement>) {
|
||||
dragPointRef.current = { x: event.clientX, y: event.clientY };
|
||||
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
if (pointersRef.current.size === 1) {
|
||||
dragPointRef.current = { x: event.clientX, y: event.clientY };
|
||||
return;
|
||||
}
|
||||
|
||||
const distance = getPinchDistance();
|
||||
if (distance !== null) {
|
||||
pinchRef.current = { distance, zoom };
|
||||
dragPointRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
function drag(event: React.PointerEvent<HTMLElement>) {
|
||||
if (pointersRef.current.has(event.pointerId)) {
|
||||
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
}
|
||||
|
||||
const pinch = pinchRef.current;
|
||||
const distance = getPinchDistance();
|
||||
if (pinch !== null && distance !== null) {
|
||||
changeZoom(pinch.zoom * (distance / pinch.distance));
|
||||
return;
|
||||
}
|
||||
|
||||
const dragPoint = dragPointRef.current;
|
||||
if (dragPoint === null) {
|
||||
return;
|
||||
@@ -101,51 +202,78 @@ export function ImageCropDialog({ image, onApply, onCancel, open, policy, render
|
||||
dragPointRef.current = { x: event.clientX, y: event.clientY };
|
||||
}
|
||||
|
||||
function stopDrag() {
|
||||
function stopDrag(event: React.PointerEvent<HTMLElement>) {
|
||||
pointersRef.current.delete(event.pointerId);
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||
pinchRef.current = null;
|
||||
dragPointRef.current = null;
|
||||
}
|
||||
|
||||
async function applyCrop() {
|
||||
const file = await renderCrop({
|
||||
aspect: policy.aspect,
|
||||
file: image.file,
|
||||
offsetX,
|
||||
offsetY,
|
||||
outputHeight: outputSize.height,
|
||||
outputWidth: outputSize.width,
|
||||
previewUrl: image.previewUrl,
|
||||
sourceHeight: image.height,
|
||||
sourceWidth: image.width,
|
||||
zoom,
|
||||
});
|
||||
onApply(file);
|
||||
if (isApplying) {
|
||||
return;
|
||||
}
|
||||
setApplyError(null);
|
||||
setIsApplying(true);
|
||||
const currentViewportRect = cropViewportRef.current?.getBoundingClientRect();
|
||||
const previewRect = previewImageRef.current?.getBoundingClientRect();
|
||||
const frameRect = currentViewportRect !== undefined && currentViewportRect.width > 0 && currentViewportRect.height > 0 ? currentViewportRect : previewRect;
|
||||
const cropOffset = clampOffset({ x: offsetX, y: offsetY }, frameRect === undefined ? null : { height: frameRect.height, width: frameRect.width });
|
||||
try {
|
||||
const file = await renderCrop({
|
||||
aspect: policy.aspect,
|
||||
file: image.file,
|
||||
offsetX: cropOffset.x,
|
||||
offsetY: cropOffset.y,
|
||||
outputHeight: outputSize.height,
|
||||
outputWidth: outputSize.width,
|
||||
previewFrameHeight: frameRect?.height,
|
||||
previewFrameWidth: frameRect?.width,
|
||||
previewUrl: image.previewUrl,
|
||||
sourceHeight: image.height,
|
||||
sourceWidth: image.width,
|
||||
zoom,
|
||||
});
|
||||
onApply(file);
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof Error)) {
|
||||
throw error;
|
||||
}
|
||||
setApplyError("이미지 crop을 적용하지 못했습니다.");
|
||||
} finally {
|
||||
setIsApplying(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-modal grid place-items-center bg-background/80 p-4">
|
||||
<section aria-label="이미지 crop" aria-modal="true" className="flex w-full max-w-lg flex-col gap-4 rounded-lg border border-border bg-card p-6" onKeyDown={trapFocus} ref={dialogRef} role="dialog">
|
||||
<section aria-label="이미지 crop" aria-modal="true" className="flex w-full max-w-lg flex-col gap-4 rounded-lg border border-border bg-card p-6" onKeyDown={handleDialogKeyDown} ref={dialogRef} role="dialog">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-xl font-semibold">이미지 crop</h2>
|
||||
<p className="text-sm text-muted-foreground">버튼, 범위 입력, 방향키로 위치와 확대를 조정한 뒤 적용합니다.</p>
|
||||
</div>
|
||||
<div aria-label="이미지 crop 미리보기" className="overflow-hidden rounded-lg border border-border bg-muted p-4" onKeyDown={handleKeyDown} onPointerDown={startDrag} onPointerLeave={stopDrag} onPointerMove={drag} onPointerUp={stopDrag} role="application" tabIndex={0}>
|
||||
<img alt="선택한 이미지 미리보기" className="mx-auto max-h-64 max-w-full" src={image.previewUrl} style={{ transform: `translate(${offsetX}px, ${offsetY}px) scale(${zoom})` }} />
|
||||
<div aria-label="이미지 crop 미리보기" className="overflow-hidden rounded-lg border border-border bg-muted p-4" onKeyDown={handlePreviewKeyDown} onPointerCancel={stopDrag} onPointerDown={startDrag} onPointerLeave={stopDrag} onPointerMove={drag} onPointerUp={stopDrag} role="application" style={{ touchAction: "none" }} tabIndex={0}>
|
||||
<div aria-label="이미지 crop viewport" className="relative mx-auto overflow-hidden rounded-md border border-info/70 bg-background" ref={setCropViewportNode} style={{ aspectRatio: String(cropFrameAspect), width: `${Math.min(16 * cropFrameAspect, 32)}rem`, maxWidth: "100%" }}>
|
||||
<img alt="선택한 이미지 미리보기" className={`absolute left-1/2 top-1/2 ${coverImageClass}`} ref={previewImageRef} src={image.previewUrl} style={{ transform: `translate(-50%, -50%) translate(${clampedOffset.x}px, ${clampedOffset.y}px) scale(${zoom})` }} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-info">예상 결과 {outputSize.width} × {outputSize.height}px</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => move(0, -MOVE_STEP)} type="button">위로 이동</button>
|
||||
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => move(0, MOVE_STEP)} type="button">아래로 이동</button>
|
||||
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => move(-MOVE_STEP, 0)} type="button">왼쪽으로 이동</button>
|
||||
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => move(MOVE_STEP, 0)} type="button">오른쪽으로 이동</button>
|
||||
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isApplying} onClick={() => move(0, -MOVE_STEP)} type="button">위로 이동</button>
|
||||
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isApplying} onClick={() => move(0, MOVE_STEP)} type="button">아래로 이동</button>
|
||||
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isApplying} onClick={() => move(-MOVE_STEP, 0)} type="button">왼쪽으로 이동</button>
|
||||
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isApplying} onClick={() => move(MOVE_STEP, 0)} type="button">오른쪽으로 이동</button>
|
||||
</div>
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">
|
||||
확대 비율
|
||||
<input aria-label="확대 비율" max="3" min="1" onChange={(event) => changeZoom(Number(event.currentTarget.value))} step="0.1" type="range" value={zoom} />
|
||||
<input aria-label="확대 비율" disabled={isApplying} max="3" min="1" onChange={(event) => changeZoom(Number(event.currentTarget.value))} step="0.1" type="range" value={zoom} />
|
||||
</label>
|
||||
{applyError === null ? null : <p className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" role="alert">{applyError}</p>}
|
||||
{isApplying ? <p className="rounded-md border border-border bg-card p-3 text-sm font-semibold" role="status">이미지 crop을 적용하는 중</p> : null}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={resetCrop} type="button">초기화</button>
|
||||
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={onCancel} type="button">취소</button>
|
||||
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={() => void applyCrop()} type="button">적용</button>
|
||||
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isApplying} onClick={resetCrop} type="button">초기화</button>
|
||||
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isApplying} onClick={onCancel} type="button">취소</button>
|
||||
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60" disabled={isApplying} onClick={() => void applyCrop()} type="button">적용</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ export function MockModeBanner({ apiMode }: { readonly apiMode: ApiMode }) {
|
||||
return (
|
||||
<aside
|
||||
aria-label="Mock Preview"
|
||||
className="sticky top-0 z-sticky border-b border-border bg-warning-surface px-4 py-2 text-sm font-semibold text-warning"
|
||||
className="sticky top-0 z-sticky break-keep border-b border-border bg-warning-surface px-4 py-2 text-sm font-semibold text-warning"
|
||||
role="status"
|
||||
>
|
||||
<span className="mr-2">Mock Preview</span>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PageData } from "@/shared/api/pagination";
|
||||
import { useId } from "react";
|
||||
|
||||
export type ResourcePaginationProps = {
|
||||
readonly data: PageData<unknown>;
|
||||
@@ -8,14 +9,16 @@ export type ResourcePaginationProps = {
|
||||
};
|
||||
|
||||
export function ResourcePagination({ data, onPageChange, onSizeChange, sizeOptions = [20, 50] }: ResourcePaginationProps) {
|
||||
const pageSizeId = useId();
|
||||
|
||||
return (
|
||||
<nav aria-label="페이지" className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm font-semibold text-muted-foreground">총 {data.totalCount.toLocaleString("ko-KR")}개 · {data.page + 1}페이지</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label className="text-sm font-semibold" htmlFor="resource-page-size">
|
||||
<label className="text-sm font-semibold" htmlFor={pageSizeId}>
|
||||
페이지 크기
|
||||
</label>
|
||||
<select className="rounded-md border border-input bg-card px-3 py-2 text-base" id="resource-page-size" onChange={(event) => onSizeChange(Number(event.currentTarget.value))} value={data.size}>
|
||||
<select className="rounded-md border border-input bg-card px-3 py-2 text-base" id={pageSizeId} onChange={(event) => onSizeChange(Number(event.currentTarget.value))} value={data.size}>
|
||||
{sizeOptions.map((size) => (
|
||||
<option key={size} value={size}>{size}개</option>
|
||||
))}
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
import { useEffect, useId, useRef } from "react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type SearchToolbarProps = {
|
||||
readonly filters?: ReactNode;
|
||||
readonly onQueryChange: (query: string) => void;
|
||||
readonly onSearchChange: (search: string) => void;
|
||||
readonly search: string;
|
||||
};
|
||||
|
||||
export function SearchToolbar({ filters, onQueryChange, onSearchChange, search }: SearchToolbarProps) {
|
||||
export function SearchToolbar({ filters, onQueryChange, search }: SearchToolbarProps) {
|
||||
const searchId = useId();
|
||||
const didMountRef = useRef(false);
|
||||
const hasUserInputRef = useRef(false);
|
||||
const [draftSearch, setDraftSearch] = useState(search);
|
||||
|
||||
useEffect(() => {
|
||||
if (!didMountRef.current) {
|
||||
didMountRef.current = true;
|
||||
if (!hasUserInputRef.current) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => onQueryChange(search), 300);
|
||||
const timeoutId = window.setTimeout(() => onQueryChange(draftSearch), 300);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [onQueryChange, search]);
|
||||
}, [draftSearch, onQueryChange]);
|
||||
|
||||
return (
|
||||
<section aria-label="검색 도구" className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
@@ -29,7 +28,16 @@ export function SearchToolbar({ filters, onQueryChange, onSearchChange, search }
|
||||
<label className="text-sm font-semibold" htmlFor={searchId}>
|
||||
검색어
|
||||
</label>
|
||||
<input className="rounded-md border border-input bg-card px-3 py-2 text-base text-foreground" id={searchId} onChange={(event) => onSearchChange(event.currentTarget.value)} type="search" value={search} />
|
||||
<input
|
||||
className="rounded-md border border-input bg-card px-3 py-2 text-base text-foreground"
|
||||
id={searchId}
|
||||
onChange={(event) => {
|
||||
hasUserInputRef.current = true;
|
||||
setDraftSearch(event.currentTarget.value);
|
||||
}}
|
||||
type="search"
|
||||
value={draftSearch}
|
||||
/>
|
||||
</div>
|
||||
{filters === undefined ? null : <div className="flex flex-col gap-2 sm:min-w-48">{filters}</div>}
|
||||
</section>
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
|
||||
18
src/shared/validation/can-price.test.ts
Normal file
18
src/shared/validation/can-price.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { CAN_PRICE_MAX, canPriceSchema, formatCanPriceInput, parseCanPriceInput } from "@/shared/validation/can-price";
|
||||
|
||||
describe("canPriceSchema", () => {
|
||||
test.each([0, CAN_PRICE_MAX])("accepts CAN price boundary %i", (price) => {
|
||||
expect(canPriceSchema.parse(price)).toBe(price);
|
||||
});
|
||||
|
||||
test.each([-1, CAN_PRICE_MAX + 1, 1.5])("rejects invalid CAN price %i", (price) => {
|
||||
expect(() => canPriceSchema.parse(price)).toThrow();
|
||||
});
|
||||
|
||||
test.each([["", null, ""], ["0", 0, "0캔"], ["99,999캔", CAN_PRICE_MAX, "99,999캔"], ["-1", null, "-1"], ["1.5", null, "1.5"]] as const)("parses raw CAN price input %s", (raw, parsed, formatted) => {
|
||||
expect(parseCanPriceInput(raw)).toBe(parsed);
|
||||
expect(formatCanPriceInput(raw)).toBe(formatted);
|
||||
});
|
||||
});
|
||||
20
src/shared/validation/can-price.ts
Normal file
20
src/shared/validation/can-price.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const CAN_PRICE_MAX = 99_999;
|
||||
|
||||
export const canPriceSchema = z.number().int().min(0).max(CAN_PRICE_MAX);
|
||||
|
||||
export function parseCanPriceInput(value: string): number | null {
|
||||
const withoutUnit = value.trim().replace(/캔$/, "").trim();
|
||||
if (withoutUnit.length === 0 || !/^\d+$|^\d{1,3}(,\d{3})+$/.test(withoutUnit)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Number(withoutUnit.replace(/,/g, ""));
|
||||
}
|
||||
|
||||
export function formatCanPriceInput(value: string): string {
|
||||
const price = parseCanPriceInput(value);
|
||||
|
||||
return price === null ? value : `${price.toLocaleString("ko-KR")}캔`;
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user