feat(ai-character): Mock Preview 모드 구현
This commit is contained in:
@@ -35,6 +35,74 @@ function useAiCharactersResponse(status: 200 | 401 | 403 = 200, onRequest: (requ
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -56,10 +124,10 @@ test("redirects an unauthenticated direct visit to /ai-characters without exposi
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(screen.queryByText("Phase 2에서 AI 캐릭터 목록이 연결됩니다.")).not.toBeInTheDocument();
|
||||
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 2에서 AI 캐릭터 목록이 연결됩니다.")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Phase 3에서 AI 캐릭터 목록이 연결됩니다.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders the existing login page at /login", () => {
|
||||
@@ -71,6 +139,30 @@ test("renders the existing login page at /login", () => {
|
||||
expect(screen.getByRole("button", { name: "로그인" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
test("navigates to /ai-characters after a successful login", async () => {
|
||||
window.history.pushState({}, "", "/login");
|
||||
useAiCharactersResponse();
|
||||
@@ -111,10 +203,94 @@ test("renders the protected admin shell for an existing ADMIN session", async ()
|
||||
expect(screen.getByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("navigation", { name: "데스크톱 주 메뉴" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("navigation", { name: "브레드크럼" })).toHaveTextContent("AI 캐릭터");
|
||||
expect(screen.getByText("Phase 2에서 AI 캐릭터 목록이 연결됩니다.")).toBeInTheDocument();
|
||||
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();
|
||||
@@ -123,7 +299,7 @@ test("composes Task 1.5 shared empty state in the real admin shell without domai
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Phase 2에서 AI 캐릭터 목록이 연결됩니다.");
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Phase 3에서 AI 캐릭터 목록이 연결됩니다.");
|
||||
expect(screen.queryByText("루나")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -138,7 +314,7 @@ test("clears the session and routes to login when the protected route request re
|
||||
expect(authSessionStorage.read()).toBeNull();
|
||||
expect(screen.getByRole("heading", { name: "관리자 로그인" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("세션이 만료되었습니다. 다시 로그인하세요.");
|
||||
expect(screen.queryByText("Phase 2에서 AI 캐릭터 목록이 연결됩니다.")).not.toBeInTheDocument();
|
||||
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 () => {
|
||||
@@ -154,6 +330,184 @@ test("routes to access denied without clearing the session when the protected ro
|
||||
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");
|
||||
@@ -180,7 +534,7 @@ test("keeps the protected shell hidden while a stale ADMIN probe is pending and
|
||||
|
||||
const triggerDenyProbe = await denyProbeReady;
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Phase 2에서 AI 캐릭터 목록이 연결됩니다.")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Phase 3에서 AI 캐릭터 목록이 연결됩니다.")).not.toBeInTheDocument();
|
||||
triggerDenyProbe();
|
||||
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/access-denied"));
|
||||
|
||||
278
src/app/App.tsx
278
src/app/App.tsx
@@ -1,161 +1,66 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { LoginPage } from "@/features/auth/pages/LoginPage";
|
||||
import { AuthSessionProvider } from "@/features/auth/model/auth-session";
|
||||
import { useAuthSession } from "@/features/auth/model/auth-session-context";
|
||||
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
|
||||
import { AccessDeniedPage, AiCharactersPage } from "@/app/admin-pages";
|
||||
import { AccessDeniedPage } from "@/app/admin-pages";
|
||||
import { routePaths } from "@/app/route-paths";
|
||||
import { navigateTo, replaceWith, useBrowserLocation } from "@/app/browser-location";
|
||||
import { AccessDeniedError } from "@/shared/api/api-error";
|
||||
import { ProtectedAdminShell } from "@/app/protected-admin-shell";
|
||||
import { AccessDeniedError, ApiError } from "@/shared/api/api-error";
|
||||
import { createApiClient } from "@/shared/api/client";
|
||||
import type { ApiMode } from "@/shared/config/env";
|
||||
import { getRuntimeEnv } from "@/shared/config/env";
|
||||
import { MockModeBanner } from "@/shared/ui/mock-mode-banner";
|
||||
import { PageState } from "@/shared/ui/page-state";
|
||||
const aiCharactersRouteResponseSchema = z.unknown();
|
||||
const sessionExpiredNotice = "세션이 만료되었습니다. 다시 로그인하세요.";
|
||||
const focusableSelector = "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])";
|
||||
|
||||
function NavLink() {
|
||||
type ProtectedRouteError = {
|
||||
readonly message: string;
|
||||
readonly session: NonNullable<ReturnType<typeof useAuthSession>["session"]>;
|
||||
readonly routeVisitKey: number;
|
||||
readonly protectedRouteRetryKey: number;
|
||||
};
|
||||
|
||||
type ProtectedRouteVerification = {
|
||||
readonly session: ProtectedRouteError["session"];
|
||||
readonly routeVisitKey: number;
|
||||
readonly protectedRouteRetryKey: number;
|
||||
};
|
||||
|
||||
function ProtectedRouteErrorPage({ message, onRetry }: { readonly message: string; readonly onRetry: () => void }) {
|
||||
return (
|
||||
<a
|
||||
className="rounded-md px-3 py-2 text-sm font-semibold text-accent-foreground hover:bg-accent"
|
||||
href={routePaths.aiCharacters}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
navigateTo(routePaths.aiCharacters);
|
||||
}}
|
||||
>
|
||||
AI 캐릭터
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function ProtectedAdminShell({ routeError }: { readonly routeError: string | null }) {
|
||||
const auth = useAuthSession();
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const mobileMenuRef = useRef<HTMLElement>(null);
|
||||
const shouldRestoreMenuFocusRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobileMenuOpen || !shouldRestoreMenuFocusRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
shouldRestoreMenuFocusRef.current = false;
|
||||
menuButtonRef.current?.focus();
|
||||
}, [isMobileMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobileMenuOpen) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
closeButtonRef.current?.focus();
|
||||
|
||||
function closeOnEscape(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
shouldRestoreMenuFocusRef.current = true;
|
||||
setIsMobileMenuOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", closeOnEscape);
|
||||
|
||||
return () => window.removeEventListener("keydown", closeOnEscape);
|
||||
}, [isMobileMenuOpen]);
|
||||
|
||||
function keepFocusInMobileMenu(event: React.KeyboardEvent<HTMLElement>) {
|
||||
if (event.key !== "Tab") {
|
||||
return;
|
||||
}
|
||||
|
||||
const focusableElements = Array.from(mobileMenuRef.current?.querySelectorAll<HTMLElement>(focusableSelector) ?? []);
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements.at(-1);
|
||||
|
||||
if (firstElement === undefined || lastElement === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.shiftKey && document.activeElement === firstElement) {
|
||||
event.preventDefault();
|
||||
lastElement.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.shiftKey && document.activeElement === lastElement) {
|
||||
event.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function closeMobileMenu() {
|
||||
shouldRestoreMenuFocusRef.current = true;
|
||||
setIsMobileMenuOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[100dvh] bg-background text-foreground">
|
||||
<div aria-hidden={isMobileMenuOpen} className="contents" inert={isMobileMenuOpen}>
|
||||
<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>
|
||||
<aside className="hidden w-60 shrink-0 border-r border-border bg-card p-4 lg:block">
|
||||
<p className="mb-4 text-xs font-semibold text-info">AI CHARACTER ADMIN</p>
|
||||
<nav aria-label="데스크톱 주 메뉴" className="flex flex-col gap-2">
|
||||
<NavLink />
|
||||
</nav>
|
||||
</aside>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex min-h-14 items-center justify-between gap-3 border-b border-border bg-card px-4" role="banner">
|
||||
<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"
|
||||
onClick={() => setIsMobileMenuOpen(true)}
|
||||
ref={menuButtonRef}
|
||||
type="button"
|
||||
>
|
||||
모바일 메뉴 열기
|
||||
</button>
|
||||
<nav aria-label="브레드크럼" className="text-sm text-muted-foreground">
|
||||
<ol className="flex items-center gap-2">
|
||||
<li>홈</li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li className="font-semibold text-foreground">AI 캐릭터</li>
|
||||
</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>
|
||||
</header>
|
||||
<main aria-label="AI 캐릭터 관리" className="min-h-0 flex-1 overflow-auto p-4" id="app-main">
|
||||
<AiCharactersPage routeError={routeError} />
|
||||
</main>
|
||||
</div>
|
||||
<main className="flex min-h-[100dvh] items-center justify-center bg-background px-4 text-foreground">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" role="alert">
|
||||
{message}
|
||||
</p>
|
||||
<button
|
||||
className="min-h-11 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={onRetry}
|
||||
type="button"
|
||||
>
|
||||
보호 route 다시 시도
|
||||
</button>
|
||||
</div>
|
||||
{isMobileMenuOpen ? (
|
||||
<div className="fixed inset-0 z-overlay bg-background/80 lg:hidden">
|
||||
<nav
|
||||
aria-label="모바일 주 메뉴"
|
||||
className="flex min-h-[100dvh] w-[min(15rem,50vw)] flex-col gap-3 border-r border-border bg-card p-4"
|
||||
onKeyDown={keepFocusInMobileMenu}
|
||||
ref={mobileMenuRef}
|
||||
>
|
||||
<button className="rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold" onClick={closeMobileMenu} ref={closeButtonRef} type="button">
|
||||
모바일 메뉴 닫기
|
||||
</button>
|
||||
<NavLink />
|
||||
</nav>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function AppShell() {
|
||||
function RouteFrame({ apiMode, children }: { readonly apiMode: ApiMode; readonly children: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<MockModeBanner apiMode={apiMode} />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AppShell({ apiMode }: { readonly apiMode: ApiMode }) {
|
||||
const auth = useAuthSession();
|
||||
const protectedRouteApiClient = useMemo(
|
||||
() =>
|
||||
@@ -167,22 +72,25 @@ function AppShell() {
|
||||
[auth],
|
||||
);
|
||||
const location = useBrowserLocation();
|
||||
const [routeError, setRouteError] = useState<string | null>(null);
|
||||
const [verifiedProtectedRouteToken, setVerifiedProtectedRouteToken] = useState<string | null>(null);
|
||||
const [routeError, setRouteError] = useState<ProtectedRouteError | null>(null);
|
||||
const [verifiedProtectedRouteSession, setVerifiedProtectedRouteSession] = useState<ProtectedRouteVerification | null>(null);
|
||||
const [protectedRouteRetryKey, setProtectedRouteRetryKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (location !== routePaths.login && auth.session === null) {
|
||||
if (location.path !== routePaths.login && auth.session === null) {
|
||||
replaceWith(routePaths.login);
|
||||
}
|
||||
}, [auth.session, location]);
|
||||
}, [auth.session, location.path]);
|
||||
|
||||
useEffect(() => {
|
||||
if (location !== routePaths.aiCharacters || auth.session === null) {
|
||||
if (location.path !== routePaths.aiCharacters || auth.session === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let isCurrent = true;
|
||||
const sessionToken = auth.session.token;
|
||||
const session = auth.session;
|
||||
const routeVisitKey = location.visitKey;
|
||||
const currentProtectedRouteRetryKey = protectedRouteRetryKey;
|
||||
void protectedRouteApiClient
|
||||
.request({
|
||||
path: "/api/v2/admin/ai-characters?page=0&size=20",
|
||||
@@ -192,7 +100,7 @@ function AppShell() {
|
||||
.then(() => {
|
||||
if (isCurrent) {
|
||||
setRouteError(null);
|
||||
setVerifiedProtectedRouteToken(sessionToken);
|
||||
setVerifiedProtectedRouteSession({ session, routeVisitKey, protectedRouteRetryKey: currentProtectedRouteRetryKey });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
@@ -205,23 +113,30 @@ function AppShell() {
|
||||
return;
|
||||
}
|
||||
|
||||
setRouteError("보호 route 확인에 실패했습니다.");
|
||||
setRouteError({
|
||||
message: error instanceof ApiError ? error.message : "보호 route 확인에 실패했습니다.",
|
||||
session,
|
||||
routeVisitKey,
|
||||
protectedRouteRetryKey: currentProtectedRouteRetryKey,
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCurrent = false;
|
||||
};
|
||||
}, [auth.session, location, protectedRouteApiClient]);
|
||||
}, [auth.session, location.path, location.visitKey, protectedRouteApiClient, protectedRouteRetryKey]);
|
||||
|
||||
if (location === routePaths.login) {
|
||||
if (location.path === routePaths.login) {
|
||||
return (
|
||||
<LoginPage
|
||||
notice={auth.loginNotice}
|
||||
onSubmit={async (credentials) => {
|
||||
await auth.login(credentials);
|
||||
navigateTo(routePaths.aiCharacters);
|
||||
}}
|
||||
/>
|
||||
<RouteFrame apiMode={apiMode}>
|
||||
<LoginPage
|
||||
notice={auth.loginNotice}
|
||||
onSubmit={async (credentials) => {
|
||||
await auth.login(credentials);
|
||||
navigateTo(routePaths.aiCharacters);
|
||||
}}
|
||||
/>
|
||||
</RouteFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -229,18 +144,53 @@ function AppShell() {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (location === routePaths.accessDenied) {
|
||||
return <AccessDeniedPage />;
|
||||
if (location.path === routePaths.accessDenied) {
|
||||
return (
|
||||
<RouteFrame apiMode={apiMode}>
|
||||
<AccessDeniedPage />
|
||||
</RouteFrame>
|
||||
);
|
||||
}
|
||||
|
||||
if (location === routePaths.aiCharacters && verifiedProtectedRouteToken !== auth.session.token) {
|
||||
return null;
|
||||
const currentRouteError =
|
||||
routeError?.session === auth.session &&
|
||||
routeError.routeVisitKey === location.visitKey &&
|
||||
routeError.protectedRouteRetryKey === protectedRouteRetryKey
|
||||
? routeError.message
|
||||
: null;
|
||||
|
||||
if (
|
||||
location.path === routePaths.aiCharacters &&
|
||||
(verifiedProtectedRouteSession?.session !== auth.session ||
|
||||
verifiedProtectedRouteSession.routeVisitKey !== location.visitKey ||
|
||||
verifiedProtectedRouteSession.protectedRouteRetryKey !== protectedRouteRetryKey)
|
||||
) {
|
||||
return currentRouteError === null ? (
|
||||
<RouteFrame apiMode={apiMode}>
|
||||
<main className="min-h-[100dvh] bg-background p-4 text-foreground">
|
||||
<PageState state="loading" title="보호 route 확인 중" description="관리자 권한을 확인하는 동안 잠시 기다려 주세요." />
|
||||
</main>
|
||||
</RouteFrame>
|
||||
) : (
|
||||
<RouteFrame apiMode={apiMode}>
|
||||
<ProtectedRouteErrorPage
|
||||
message={currentRouteError}
|
||||
onRetry={() => {
|
||||
setRouteError(null);
|
||||
setProtectedRouteRetryKey((retryKey) => retryKey + 1);
|
||||
}}
|
||||
/>
|
||||
</RouteFrame>
|
||||
);
|
||||
}
|
||||
|
||||
return <ProtectedAdminShell routeError={routeError} />;
|
||||
return (
|
||||
<ProtectedAdminShell apiMode={apiMode} routeError={currentRouteError} />
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const runtimeEnv = getRuntimeEnv();
|
||||
const apiClient = useMemo(
|
||||
() =>
|
||||
createApiClient({
|
||||
@@ -257,7 +207,7 @@ export function App() {
|
||||
|
||||
return (
|
||||
<AuthSessionProvider apiClient={apiClient} onNavigateLogin={replaceWith}>
|
||||
<AppShell />
|
||||
<AppShell apiMode={runtimeEnv.apiMode} />
|
||||
</AuthSessionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,14 +24,14 @@ export function AiCharactersPage({ routeError }: { readonly routeError: string |
|
||||
<h1 className="text-2xl font-bold leading-tight" id="ai-characters-title">
|
||||
AI 캐릭터
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">캐릭터 목록과 생성 흐름은 Phase 2에서 연결합니다.</p>
|
||||
<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 2에서 AI 캐릭터 목록이 연결됩니다." />
|
||||
<PageState description="현재 route는 보호 shell과 권한 처리를 검증하는 명시적 빈 상태입니다." state="empty" title="Phase 3에서 AI 캐릭터 목록이 연결됩니다." />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,13 +2,25 @@ import { useSyncExternalStore } from "react";
|
||||
|
||||
import { routePaths, type RoutePath } from "@/app/route-paths";
|
||||
|
||||
function subscribe(onStoreChange: () => void): () => void {
|
||||
window.addEventListener("popstate", onStoreChange);
|
||||
export type BrowserLocationSnapshot = {
|
||||
readonly path: RoutePath;
|
||||
readonly visitKey: number;
|
||||
};
|
||||
|
||||
return () => window.removeEventListener("popstate", onStoreChange);
|
||||
let currentSnapshot: BrowserLocationSnapshot = { path: readRoutePath(), visitKey: 0 };
|
||||
|
||||
function subscribe(onStoreChange: () => void): () => void {
|
||||
function handlePopState() {
|
||||
currentSnapshot = { path: readRoutePath(), visitKey: currentSnapshot.visitKey + 1 };
|
||||
onStoreChange();
|
||||
}
|
||||
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
|
||||
return () => window.removeEventListener("popstate", handlePopState);
|
||||
}
|
||||
|
||||
function getSnapshot(): RoutePath {
|
||||
function readRoutePath(): RoutePath {
|
||||
const path = window.location.pathname;
|
||||
|
||||
if (path === routePaths.login || path === routePaths.aiCharacters || path === routePaths.accessDenied) {
|
||||
@@ -18,7 +30,16 @@ function getSnapshot(): RoutePath {
|
||||
return routePaths.aiCharacters;
|
||||
}
|
||||
|
||||
export function useBrowserLocation(): RoutePath {
|
||||
function getSnapshot(): BrowserLocationSnapshot {
|
||||
const path = readRoutePath();
|
||||
if (currentSnapshot.path !== path) {
|
||||
currentSnapshot = { path, visitKey: currentSnapshot.visitKey + 1 };
|
||||
}
|
||||
|
||||
return currentSnapshot;
|
||||
}
|
||||
|
||||
export function useBrowserLocation(): BrowserLocationSnapshot {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
|
||||
|
||||
176
src/app/protected-admin-shell.tsx
Normal file
176
src/app/protected-admin-shell.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { navigateTo } from "@/app/browser-location";
|
||||
import { AiCharactersPage } from "@/app/admin-pages";
|
||||
import { routePaths } from "@/app/route-paths";
|
||||
import { useAuthSession } from "@/features/auth/model/auth-session-context";
|
||||
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'])";
|
||||
|
||||
function NavLink() {
|
||||
return (
|
||||
<a
|
||||
className="rounded-md px-3 py-2 text-sm font-semibold text-accent-foreground hover:bg-accent"
|
||||
href={routePaths.aiCharacters}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
navigateTo(routePaths.aiCharacters);
|
||||
}}
|
||||
>
|
||||
AI 캐릭터
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProtectedAdminShell({ apiMode, routeError }: { readonly apiMode: ApiMode; readonly routeError: string | null }) {
|
||||
const auth = useAuthSession();
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const mobileMenuRef = useRef<HTMLElement>(null);
|
||||
const shouldRestoreMenuFocusRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.matchMedia === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const desktopMediaQuery = window.matchMedia("(min-width: 1024px)");
|
||||
|
||||
function closeOnDesktopMatch() {
|
||||
if (!desktopMediaQuery.matches) {
|
||||
return;
|
||||
}
|
||||
|
||||
shouldRestoreMenuFocusRef.current = false;
|
||||
setIsMobileMenuOpen(false);
|
||||
}
|
||||
|
||||
closeOnDesktopMatch();
|
||||
desktopMediaQuery.addEventListener("change", closeOnDesktopMatch);
|
||||
|
||||
return () => desktopMediaQuery.removeEventListener("change", closeOnDesktopMatch);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobileMenuOpen || !shouldRestoreMenuFocusRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
shouldRestoreMenuFocusRef.current = false;
|
||||
menuButtonRef.current?.focus();
|
||||
}, [isMobileMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobileMenuOpen) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
closeButtonRef.current?.focus();
|
||||
|
||||
function closeOnEscape(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
shouldRestoreMenuFocusRef.current = true;
|
||||
setIsMobileMenuOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", closeOnEscape);
|
||||
|
||||
return () => window.removeEventListener("keydown", closeOnEscape);
|
||||
}, [isMobileMenuOpen]);
|
||||
|
||||
function keepFocusInMobileMenu(event: React.KeyboardEvent<HTMLElement>) {
|
||||
if (event.key !== "Tab") {
|
||||
return;
|
||||
}
|
||||
|
||||
const focusableElements = Array.from(mobileMenuRef.current?.querySelectorAll<HTMLElement>(focusableSelector) ?? []);
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements.at(-1);
|
||||
|
||||
if (firstElement === undefined || lastElement === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.shiftKey && document.activeElement === firstElement) {
|
||||
event.preventDefault();
|
||||
lastElement.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.shiftKey && document.activeElement === lastElement) {
|
||||
event.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function closeMobileMenu() {
|
||||
shouldRestoreMenuFocusRef.current = true;
|
||||
setIsMobileMenuOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-[100dvh] bg-background text-foreground">
|
||||
<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>
|
||||
<aside className="hidden w-60 shrink-0 border-r border-border bg-card p-4 lg:block">
|
||||
<p className="mb-4 text-xs font-semibold text-info">AI CHARACTER ADMIN</p>
|
||||
<nav aria-label="데스크톱 주 메뉴" className="flex flex-col gap-2">
|
||||
<NavLink />
|
||||
</nav>
|
||||
</aside>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex min-h-14 items-center justify-between gap-3 border-b border-border bg-card px-4" role="banner">
|
||||
<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"
|
||||
onClick={() => setIsMobileMenuOpen(true)}
|
||||
ref={menuButtonRef}
|
||||
type="button"
|
||||
>
|
||||
모바일 메뉴 열기
|
||||
</button>
|
||||
<nav aria-label="브레드크럼" className="text-sm text-muted-foreground">
|
||||
<ol className="flex items-center gap-2">
|
||||
<li>홈</li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li className="font-semibold text-foreground">AI 캐릭터</li>
|
||||
</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>
|
||||
</header>
|
||||
<main aria-label="AI 캐릭터 관리" className="min-h-0 flex-1 overflow-auto p-4" id="app-main">
|
||||
<AiCharactersPage routeError={routeError} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isMobileMenuOpen ? (
|
||||
<div className="fixed inset-0 z-overlay bg-background/80 lg:hidden">
|
||||
<nav
|
||||
aria-label="모바일 주 메뉴"
|
||||
className="flex min-h-[100dvh] w-[min(15rem,50vw)] flex-col gap-3 border-r border-border bg-card p-4"
|
||||
onKeyDown={keepFocusInMobileMenu}
|
||||
ref={mobileMenuRef}
|
||||
>
|
||||
<button className="rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold" onClick={closeMobileMenu} ref={closeButtonRef} type="button">
|
||||
모바일 메뉴 닫기
|
||||
</button>
|
||||
<NavLink />
|
||||
</nav>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
src/main.tsx
31
src/main.tsx
@@ -7,18 +7,27 @@ import { queryClient } from "@/shared/api/query-client";
|
||||
import "@/styles/globals.css";
|
||||
import { getRuntimeEnv } from "@/shared/config/env";
|
||||
|
||||
getRuntimeEnv();
|
||||
const runtimeEnv = getRuntimeEnv();
|
||||
|
||||
const root = document.getElementById("root");
|
||||
async function bootstrap(): Promise<void> {
|
||||
if (import.meta.env.DEV && runtimeEnv.apiMode === "mock") {
|
||||
const { startMockWorker } = await import("@/shared/mocks/browser");
|
||||
await startMockWorker();
|
||||
}
|
||||
|
||||
if (!root) {
|
||||
throw new Error("Root element #root was not found");
|
||||
const root = document.getElementById("root");
|
||||
|
||||
if (!root) {
|
||||
throw new Error("Root element #root was not found");
|
||||
}
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
void bootstrap();
|
||||
|
||||
@@ -114,4 +114,21 @@ describe("API client", () => {
|
||||
// Then
|
||||
await expect(request).rejects.toBeInstanceOf(ApiError);
|
||||
});
|
||||
|
||||
test("surfaces a network failure without a mock response", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
const { client } = createTestClient();
|
||||
server.use(http.get(`${apiBaseUrl}/network-error`, () => HttpResponse.error()));
|
||||
|
||||
// When
|
||||
const request = client.request({
|
||||
path: "/network-error",
|
||||
responseSchema: valueSchema,
|
||||
authentication: "none",
|
||||
});
|
||||
|
||||
// Then
|
||||
await expect(request).rejects.toBeInstanceOf(TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,10 +7,56 @@ describe("getRuntimeEnv", () => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
test("returns the configured API base URL", () => {
|
||||
test("defaults API mode to server when VITE_API_MODE is unset", () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", "https://api.example.com");
|
||||
|
||||
expect(getRuntimeEnv()).toEqual({ apiBaseUrl: "https://api.example.com" });
|
||||
// When
|
||||
const environment = getRuntimeEnv();
|
||||
|
||||
// Then
|
||||
expect(environment).toEqual({ apiBaseUrl: "https://api.example.com", apiMode: "server" });
|
||||
});
|
||||
|
||||
test("accepts explicit mock API mode in development", () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", "https://api.example.com");
|
||||
vi.stubEnv("VITE_API_MODE", "mock");
|
||||
|
||||
// When
|
||||
const environment = getRuntimeEnv();
|
||||
|
||||
// Then
|
||||
expect(environment.apiMode).toBe("mock");
|
||||
});
|
||||
|
||||
test("blocks startup when VITE_API_MODE is not server or mock", () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", "https://api.example.com");
|
||||
vi.stubEnv("VITE_API_MODE", "preview");
|
||||
|
||||
// When
|
||||
const getEnvironment = () => getRuntimeEnv();
|
||||
|
||||
// Then
|
||||
expect(getEnvironment).toThrow("VITE_API_MODE must be either server or mock");
|
||||
});
|
||||
|
||||
test("blocks mock API mode outside development before bootstrap", () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", "https://api.example.com");
|
||||
vi.stubEnv("VITE_API_MODE", "mock");
|
||||
|
||||
// When
|
||||
const getEnvironment = () =>
|
||||
getRuntimeEnv({
|
||||
apiBaseUrl: "https://api.example.com",
|
||||
apiMode: "mock",
|
||||
isDevelopment: false,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(getEnvironment).toThrow("VITE_API_MODE=mock is only available during development");
|
||||
});
|
||||
|
||||
test("blocks startup when VITE_API_BASE_URL is missing", () => {
|
||||
|
||||
@@ -1,23 +1,53 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const apiModeSchema = z.enum(["server", "mock"]);
|
||||
|
||||
export type ApiMode = z.infer<typeof apiModeSchema>;
|
||||
|
||||
export type RuntimeEnv = {
|
||||
apiBaseUrl: string;
|
||||
readonly apiBaseUrl: string;
|
||||
readonly apiMode: ApiMode;
|
||||
};
|
||||
|
||||
export function getRuntimeEnv(): RuntimeEnv {
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
|
||||
type RuntimeEnvInput = {
|
||||
readonly apiBaseUrl: string | undefined;
|
||||
readonly apiMode: string | undefined;
|
||||
readonly isDevelopment: boolean;
|
||||
};
|
||||
|
||||
class RuntimeEnvError extends Error {
|
||||
override readonly name = "RuntimeEnvError";
|
||||
}
|
||||
|
||||
export function getRuntimeEnv(
|
||||
input: RuntimeEnvInput = {
|
||||
apiBaseUrl: import.meta.env.VITE_API_BASE_URL,
|
||||
apiMode: import.meta.env.VITE_API_MODE,
|
||||
isDevelopment: import.meta.env.DEV,
|
||||
},
|
||||
): RuntimeEnv {
|
||||
const apiBaseUrl = input.apiBaseUrl;
|
||||
const apiModeResult = apiModeSchema.safeParse(input.apiMode ?? "server");
|
||||
|
||||
if (!apiBaseUrl) {
|
||||
throw new Error("VITE_API_BASE_URL is required");
|
||||
throw new RuntimeEnvError("VITE_API_BASE_URL is required");
|
||||
}
|
||||
if (!apiModeResult.success) {
|
||||
throw new RuntimeEnvError("VITE_API_MODE must be either server or mock");
|
||||
}
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(apiBaseUrl);
|
||||
} catch {
|
||||
throw new Error("VITE_API_BASE_URL must be a valid http(s) URL");
|
||||
throw new RuntimeEnvError("VITE_API_BASE_URL must be a valid http(s) URL");
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error("VITE_API_BASE_URL must be a valid http(s) URL");
|
||||
throw new RuntimeEnvError("VITE_API_BASE_URL must be a valid http(s) URL");
|
||||
}
|
||||
if (!input.isDevelopment && apiModeResult.data === "mock") {
|
||||
throw new RuntimeEnvError("VITE_API_MODE=mock is only available during development");
|
||||
}
|
||||
|
||||
return { apiBaseUrl };
|
||||
return { apiBaseUrl, apiMode: apiModeResult.data };
|
||||
}
|
||||
|
||||
247
src/shared/mocks/__tests__/auth-handlers.test.ts
Normal file
247
src/shared/mocks/__tests__/auth-handlers.test.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { z } from "zod";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { login, logout } from "@/features/auth/api/auth-api";
|
||||
import { createApiClient } from "@/shared/api/client";
|
||||
import { createApiResponseSchema } from "@/shared/api/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 aiCharactersPreviewSchema = z.object({
|
||||
totalCount: z.number(),
|
||||
page: z.literal(0),
|
||||
size: z.literal(20),
|
||||
hasNext: z.boolean(),
|
||||
items: z.array(z.unknown()),
|
||||
});
|
||||
|
||||
function createClient(token: string | null = adminToken) {
|
||||
return createApiClient({
|
||||
getToken: () => token,
|
||||
clearSession: vi.fn(),
|
||||
onAuthExpired: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
function useMockHandlers(store = createMockStore()) {
|
||||
server.use(...createMockHandlers(store, apiBaseUrl));
|
||||
return store;
|
||||
}
|
||||
|
||||
describe("mock auth handlers", () => {
|
||||
test("use the production admin login endpoint, request body, headers, and response envelope", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
useMockHandlers();
|
||||
|
||||
// When
|
||||
const session = await login(createClient("caller-token"), { email: "admin@test.com", password: "password" });
|
||||
const invalidBodyResponse = await fetch(`${apiBaseUrl}/admin/member/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "admin@test.com" }),
|
||||
});
|
||||
const authorizationResponse = await fetch(`${apiBaseUrl}/admin/member/login`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${adminToken}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "admin@test.com", password: "password" }),
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(session).toEqual({ token: adminToken, role: "ADMIN" });
|
||||
await expect(invalidBodyResponse.json()).resolves.toEqual({
|
||||
success: false,
|
||||
message: "잘못된 요청입니다.",
|
||||
data: null,
|
||||
errorProperty: null,
|
||||
});
|
||||
expect(invalidBodyResponse.status).toBe(400);
|
||||
expect(authorizationResponse.status).toBe(400);
|
||||
});
|
||||
|
||||
test("does not handle login requests from a different API origin", async () => {
|
||||
// Given
|
||||
useMockHandlers();
|
||||
|
||||
// When
|
||||
const request = fetch("https://wrong-origin.example/admin/member/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "admin@test.com", password: "password" }),
|
||||
});
|
||||
|
||||
// Then
|
||||
await expect(request).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("requires Bearer and no body for production logout", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
useMockHandlers();
|
||||
|
||||
// When
|
||||
const missingBearerResponse = await fetch(`${apiBaseUrl}/member/logout`, { method: "POST" });
|
||||
const bodyResponse = await fetch(`${apiBaseUrl}/member/logout`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
body: "{}",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(missingBearerResponse.status).toBe(401);
|
||||
expect(bodyResponse.status).toBe(400);
|
||||
});
|
||||
|
||||
test("returns 403 when logout uses a non-ADMIN Bearer token", async () => {
|
||||
// Given
|
||||
useMockHandlers();
|
||||
|
||||
// When
|
||||
const response = await fetch(`${apiBaseUrl}/member/logout`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer mock-member-jwt" },
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(response.status).toBe(403);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
success: false,
|
||||
message: "접근 권한이 없습니다.",
|
||||
data: null,
|
||||
errorProperty: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns 415 when login uses a non-JSON media type", async () => {
|
||||
// Given
|
||||
useMockHandlers();
|
||||
|
||||
// When
|
||||
const response = await fetch(`${apiBaseUrl}/admin/member/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
body: JSON.stringify({ email: "admin@test.com", password: "password" }),
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(response.status).toBe(415);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
success: false,
|
||||
message: "지원하지 않는 미디어 타입입니다.",
|
||||
data: null,
|
||||
errorProperty: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns 401 when logout uses an invalid or already revoked token", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
useMockHandlers();
|
||||
|
||||
// When
|
||||
const invalidTokenResponse = await fetch(`${apiBaseUrl}/member/logout`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer invalid-token" },
|
||||
});
|
||||
await logout(createClient(adminToken));
|
||||
const revokedTokenResponse = await fetch(`${apiBaseUrl}/member/logout`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(invalidTokenResponse.status).toBe(401);
|
||||
expect(revokedTokenResponse.status).toBe(401);
|
||||
});
|
||||
|
||||
test("resets the in-memory auth store to seed when a fresh mock store is created", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
useMockHandlers();
|
||||
await logout(createClient(adminToken));
|
||||
|
||||
// When
|
||||
const staleStoreResponse = await fetch(`${apiBaseUrl}/api/v2/admin/ai-characters?page=0&size=20`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
useMockHandlers(createMockStore());
|
||||
const freshStoreResponse = await fetch(`${apiBaseUrl}/api/v2/admin/ai-characters?page=0&size=20`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
const parsedFreshResponse = createApiResponseSchema(aiCharactersPreviewSchema).parse(await freshStoreResponse.json());
|
||||
|
||||
// Then
|
||||
expect(staleStoreResponse.status).toBe(401);
|
||||
expect(freshStoreResponse.status).toBe(200);
|
||||
expect(parsedFreshResponse).toEqual({
|
||||
success: true,
|
||||
message: null,
|
||||
data: { totalCount: 0, page: 0, size: 20, hasNext: false, items: [] },
|
||||
errorProperty: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("reactivates the admin preview token after logout and login in the same store", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
useMockHandlers();
|
||||
await login(createClient("caller-token"), { email: "admin@test.com", password: "password" });
|
||||
await logout(createClient(adminToken));
|
||||
|
||||
// When
|
||||
await login(createClient("caller-token"), { email: "admin@test.com", password: "password" });
|
||||
const response = await fetch(`${apiBaseUrl}/api/v2/admin/ai-characters?page=0&size=20`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test("returns the contract 403 fixture for a non-ADMIN Bearer token", async () => {
|
||||
// Given
|
||||
useMockHandlers();
|
||||
|
||||
// When
|
||||
const response = await fetch(`${apiBaseUrl}/api/v2/admin/ai-characters?page=0&size=20`, {
|
||||
headers: { Authorization: "Bearer mock-member-jwt" },
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(response.status).toBe(403);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
success: false,
|
||||
message: "접근 권한이 없습니다.",
|
||||
data: null,
|
||||
errorProperty: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps mock fixture state out of browser persistent storage and logs", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
const indexedDbOpen = vi.fn();
|
||||
const consoleLog = vi.spyOn(console, "log");
|
||||
const consoleWarn = vi.spyOn(console, "warn");
|
||||
const consoleError = vi.spyOn(console, "error");
|
||||
vi.stubGlobal("indexedDB", { open: indexedDbOpen });
|
||||
const cookieBefore = document.cookie;
|
||||
useMockHandlers();
|
||||
|
||||
// When
|
||||
await login(createClient("caller-token"), { email: "admin@test.com", password: "password" });
|
||||
await logout(createClient(adminToken));
|
||||
|
||||
// Then
|
||||
expect(localStorage).toHaveLength(0);
|
||||
expect(sessionStorage).toHaveLength(0);
|
||||
expect(indexedDbOpen).not.toHaveBeenCalled();
|
||||
expect(document.cookie).toBe(cookieBefore);
|
||||
expect(consoleLog).not.toHaveBeenCalled();
|
||||
expect(consoleWarn).not.toHaveBeenCalled();
|
||||
expect(consoleError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
66
src/shared/mocks/__tests__/mock-preview-docs.test.ts
Normal file
66
src/shared/mocks/__tests__/mock-preview-docs.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import packageJson from "../../../../package.json";
|
||||
|
||||
const rootDir = process.cwd();
|
||||
|
||||
function projectFile(path: string): string {
|
||||
const absolutePath = join(rootDir, path);
|
||||
|
||||
expect(existsSync(absolutePath)).toBe(true);
|
||||
|
||||
return readFileSync(absolutePath, "utf8");
|
||||
}
|
||||
|
||||
function expectContainsEvery(source: string, tokens: readonly string[]): void {
|
||||
for (const token of tokens) {
|
||||
expect(source).toContain(token);
|
||||
}
|
||||
}
|
||||
|
||||
describe("mock preview documentation", () => {
|
||||
test("documents the actual npm scripts and mode boundary in README", () => {
|
||||
// Given
|
||||
const readme = projectFile("README.md");
|
||||
|
||||
// When
|
||||
const actualScripts = [
|
||||
`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, ["server mode", "mock mode", "VITE_API_MODE=server", "VITE_API_MODE=mock"]);
|
||||
expectContainsEvery(readme, ["mock data reset", "production", "no-auto-fallback"]);
|
||||
});
|
||||
|
||||
test("keeps agent environment and script guides synced with mock preview ownership rules", () => {
|
||||
// Given
|
||||
const environment = projectFile("docs/agent-guide/environment.md");
|
||||
const scripts = projectFile("docs/agent-guide/scripts.md");
|
||||
|
||||
// When, Then
|
||||
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, ["handler", "fixture", "mock E2E"]);
|
||||
});
|
||||
|
||||
test("keeps the Phase 2 plan files and progress synced with the implementation", () => {
|
||||
// Given
|
||||
const plan = projectFile("docs/20260725_AI캐릭터관리자웹/plan-task.md");
|
||||
|
||||
// When, Then
|
||||
expectContainsEvery(plan, [
|
||||
"Modify: `src/app/App.tsx`, `src/main.tsx`, `vite.config.ts`, `playwright.config.ts`",
|
||||
"Create: `src/shared/mocks/{browser,handlers,contract}.ts`",
|
||||
"Create: `src/shared/mocks/__tests__/{mode-boundary,auth-handlers,mock-preview-docs,production-graph}.test.ts`",
|
||||
"Create: `tests/e2e/{mock-mode-boundary,mock-preview-shell,server-mode-boundary}.spec.ts`",
|
||||
"### Phase 2 구현·Gate 완료 기록 — 2026-07-27",
|
||||
]);
|
||||
});
|
||||
});
|
||||
58
src/shared/mocks/__tests__/mode-boundary.test.ts
Normal file
58
src/shared/mocks/__tests__/mode-boundary.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import packageJson from "../../../../package.json";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
const playwrightConfig = readFileSync("playwright.config.ts", "utf8");
|
||||
|
||||
describe("mock API mode scripts", () => {
|
||||
test("keeps the default development server in server mode", () => {
|
||||
// Given
|
||||
const developmentScript = packageJson.scripts.dev;
|
||||
|
||||
// When
|
||||
const startsServerMode = developmentScript === "VITE_API_MODE=server vite --host 127.0.0.1 --port 8888 --strictPort";
|
||||
|
||||
// Then
|
||||
expect(startsServerMode).toBe(true);
|
||||
});
|
||||
|
||||
test("provides explicit development and Playwright mock mode commands", () => {
|
||||
// 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"];
|
||||
const e2eServerScript = packageJson.scripts.e2e;
|
||||
const e2eMockScript = packageJson.scripts["e2e:mock"];
|
||||
|
||||
// Then
|
||||
expect(developmentMockScript).toBe(expectedDevelopmentMockScript);
|
||||
expect(e2eServerScript).toBe(expectedE2eServerScript);
|
||||
expect(e2eMockScript).toBe(expectedE2eMockScript);
|
||||
});
|
||||
|
||||
test("keeps mode-specific E2E spec allowlists in Playwright config", () => {
|
||||
// Given
|
||||
const expectedServerSpecs = [
|
||||
"**/server-mode-boundary.spec.ts",
|
||||
"**/smoke.spec.ts",
|
||||
"**/auth.spec.ts",
|
||||
"**/accessibility-shell.spec.ts",
|
||||
];
|
||||
const expectedMockSpecs = ["**/mock-preview-shell.spec.ts", "**/mock-mode-boundary.spec.ts"];
|
||||
|
||||
// When, Then
|
||||
expectContainsEvery(playwrightConfig, expectedServerSpecs);
|
||||
expectContainsEvery(playwrightConfig, expectedMockSpecs);
|
||||
expect(playwrightConfig).toContain("testMatch");
|
||||
});
|
||||
});
|
||||
|
||||
function expectContainsEvery(source: string, tokens: readonly string[]): void {
|
||||
for (const token of tokens) {
|
||||
expect(source).toContain(token);
|
||||
}
|
||||
}
|
||||
46
src/shared/mocks/__tests__/production-graph.test.ts
Normal file
46
src/shared/mocks/__tests__/production-graph.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { build } from "vite";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
describe("production mock graph", () => {
|
||||
test("excludes the browser mock module from the production bundle", async () => {
|
||||
// Given
|
||||
const outDir = mkdtempSync(join(tmpdir(), "ai-character-admin-prod-"));
|
||||
const previousNodeEnv = process.env.NODE_ENV;
|
||||
|
||||
try {
|
||||
process.env.NODE_ENV = "production";
|
||||
|
||||
// When
|
||||
await build({
|
||||
build: { emptyOutDir: true, outDir },
|
||||
configFile: "vite.config.ts",
|
||||
logLevel: "silent",
|
||||
mode: "production",
|
||||
});
|
||||
const outputFiles = collectFiles(outDir);
|
||||
const output = outputFiles
|
||||
.filter((filePath) => filePath.endsWith(".js"))
|
||||
.map((filePath) => readFileSync(filePath, "utf8"))
|
||||
.join("\n");
|
||||
|
||||
// Then
|
||||
expect(outputFiles.some((filePath) => filePath.endsWith("mockServiceWorker.js"))).toBe(false);
|
||||
expect(output).not.toContain("mockServiceWorker.js");
|
||||
expect(output).not.toContain("startMockWorker");
|
||||
} finally {
|
||||
process.env.NODE_ENV = previousNodeEnv;
|
||||
rmSync(outDir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function collectFiles(directory: string): readonly string[] {
|
||||
return readdirSync(directory).flatMap((entry) => {
|
||||
const path = join(directory, entry);
|
||||
|
||||
return statSync(path).isDirectory() ? collectFiles(path) : [path];
|
||||
});
|
||||
}
|
||||
29
src/shared/mocks/browser.test.ts
Normal file
29
src/shared/mocks/browser.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
const { setupWorkerMock, workerStart } = vi.hoisted(() => {
|
||||
const workerStart = vi.fn();
|
||||
const setupWorkerMock = vi.fn(() => ({ start: workerStart }));
|
||||
|
||||
return { setupWorkerMock, workerStart };
|
||||
});
|
||||
|
||||
vi.mock("msw/browser", () => ({
|
||||
setupWorker: setupWorkerMock,
|
||||
}));
|
||||
|
||||
import { startMockWorker } from "./browser";
|
||||
|
||||
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" };
|
||||
|
||||
// When
|
||||
await startMockWorker();
|
||||
|
||||
// Then
|
||||
expect(setupWorkerMock).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything());
|
||||
expect(workerStart).toHaveBeenCalledWith(startOptions);
|
||||
});
|
||||
});
|
||||
11
src/shared/mocks/browser.ts
Normal file
11
src/shared/mocks/browser.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { setupWorker } from "msw/browser";
|
||||
|
||||
import { getRuntimeEnv } from "@/shared/config/env";
|
||||
import { createMockHandlers, createMockStore } from "@/shared/mocks/handlers";
|
||||
|
||||
let worker: ReturnType<typeof setupWorker> | null = null;
|
||||
|
||||
export async function startMockWorker(): Promise<void> {
|
||||
worker ??= setupWorker(...createMockHandlers(createMockStore(), getRuntimeEnv().apiBaseUrl));
|
||||
await worker.start({ onUnhandledRequest: "error" });
|
||||
}
|
||||
9
src/shared/mocks/contract.ts
Normal file
9
src/shared/mocks/contract.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { ApiErrorResponse, ApiSuccessResponse } from "@/shared/api/types";
|
||||
|
||||
export function ok<Data>(data: Data): ApiSuccessResponse<Data> {
|
||||
return { success: true, message: null, data, errorProperty: null };
|
||||
}
|
||||
|
||||
export function error(message: string): ApiErrorResponse {
|
||||
return { success: false, message, data: null, errorProperty: null };
|
||||
}
|
||||
152
src/shared/mocks/handlers.ts
Normal file
152
src/shared/mocks/handlers.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import type { RequestHandler } from "msw";
|
||||
import { z } from "zod";
|
||||
|
||||
import { error, ok } from "@/shared/mocks/contract";
|
||||
|
||||
const adminToken = "mock-admin-jwt";
|
||||
const memberToken = "mock-member-jwt";
|
||||
const invalidRequestMessage = "잘못된 요청입니다.";
|
||||
const missingCredentialMessage = "인증 정보가 없습니다.";
|
||||
const accessDeniedMessage = "접근 권한이 없습니다.";
|
||||
const unsupportedMediaTypeMessage = "지원하지 않는 미디어 타입입니다.";
|
||||
|
||||
const loginRequestSchema = z.strictObject({
|
||||
email: z.email(),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
const aiCharactersPreview = {
|
||||
totalCount: 0,
|
||||
page: 0,
|
||||
size: 20,
|
||||
hasNext: false,
|
||||
items: [],
|
||||
} as const;
|
||||
|
||||
class MockStore {
|
||||
readonly #revokedTokens = new Set<string>();
|
||||
|
||||
activate(token: string): void {
|
||||
this.#revokedTokens.delete(token);
|
||||
}
|
||||
|
||||
revoke(token: string): void {
|
||||
this.#revokedTokens.add(token);
|
||||
}
|
||||
|
||||
getTokenAccess(token: string): "admin" | "denied" | "unauthorized" {
|
||||
if (token === adminToken && !this.#revokedTokens.has(token)) {
|
||||
return "admin";
|
||||
}
|
||||
if (token === memberToken) {
|
||||
return "denied";
|
||||
}
|
||||
|
||||
return "unauthorized";
|
||||
}
|
||||
}
|
||||
|
||||
export type MockFixtureStore = MockStore;
|
||||
|
||||
export function createMockStore(): MockFixtureStore {
|
||||
return new MockStore();
|
||||
}
|
||||
|
||||
function getBearerToken(request: Request): string | null {
|
||||
const authorization = request.headers.get("Authorization");
|
||||
const prefix = "Bearer ";
|
||||
|
||||
return authorization?.startsWith(prefix) ? authorization.slice(prefix.length) : null;
|
||||
}
|
||||
|
||||
function endpointUrl(apiBaseUrl: string, path: string): string {
|
||||
return new URL(path, apiBaseUrl).toString();
|
||||
}
|
||||
|
||||
function accessResponse(store: MockFixtureStore, request: Request): Response | null {
|
||||
const token = getBearerToken(request);
|
||||
|
||||
if (token === null) {
|
||||
return HttpResponse.json(error(missingCredentialMessage), { status: 401 });
|
||||
}
|
||||
|
||||
const tokenAccess = store.getTokenAccess(token);
|
||||
|
||||
if (tokenAccess === "admin") {
|
||||
return null;
|
||||
}
|
||||
if (tokenAccess === "denied") {
|
||||
return HttpResponse.json(error(accessDeniedMessage), { status: 403 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(error(missingCredentialMessage), { status: 401 });
|
||||
}
|
||||
|
||||
async function parseLoginRequest(request: Request): Promise<boolean> {
|
||||
if (request.headers.has("Authorization")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return loginRequestSchema.safeParse(await request.json()).success;
|
||||
} catch (parseError) {
|
||||
if (parseError instanceof SyntaxError) {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw parseError;
|
||||
}
|
||||
}
|
||||
|
||||
export function createMockHandlers(
|
||||
store: MockFixtureStore,
|
||||
apiBaseUrl: string,
|
||||
): readonly RequestHandler[] {
|
||||
return [
|
||||
http.post(endpointUrl(apiBaseUrl, "/admin/member/login"), async ({ request }) => {
|
||||
if (request.headers.get("Content-Type")?.toLowerCase().split(";")[0]?.trim() !== "application/json") {
|
||||
return HttpResponse.json(error(unsupportedMediaTypeMessage), { status: 415 });
|
||||
}
|
||||
if (!(await parseLoginRequest(request))) {
|
||||
return HttpResponse.json(error(invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
|
||||
store.activate(adminToken);
|
||||
return HttpResponse.json(ok({ token: adminToken, role: "ADMIN" }));
|
||||
}),
|
||||
http.post(endpointUrl(apiBaseUrl, "/member/logout"), async ({ request }) => {
|
||||
const token = getBearerToken(request);
|
||||
|
||||
if (token === null) {
|
||||
return HttpResponse.json(error(missingCredentialMessage), { status: 401 });
|
||||
}
|
||||
const tokenAccess = store.getTokenAccess(token);
|
||||
if (tokenAccess === "denied") {
|
||||
return HttpResponse.json(error(accessDeniedMessage), { status: 403 });
|
||||
}
|
||||
if (tokenAccess !== "admin") {
|
||||
return HttpResponse.json(error(missingCredentialMessage), { status: 401 });
|
||||
}
|
||||
if ((await request.text()) !== "") {
|
||||
return HttpResponse.json(error(invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
|
||||
store.revoke(token);
|
||||
return HttpResponse.json(ok({}));
|
||||
}),
|
||||
http.get(endpointUrl(apiBaseUrl, "/api/v2/admin/ai-characters"), ({ request }) => {
|
||||
const deniedResponse = accessResponse(store, request);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.searchParams.get("page") !== "0" || url.searchParams.get("size") !== "20") {
|
||||
return HttpResponse.json(error(invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok(aiCharactersPreview));
|
||||
}),
|
||||
];
|
||||
}
|
||||
22
src/shared/ui/__tests__/mock-mode-banner.test.tsx
Normal file
22
src/shared/ui/__tests__/mock-mode-banner.test.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { MockModeBanner } from "@/shared/ui/mock-mode-banner";
|
||||
|
||||
describe("MockModeBanner", () => {
|
||||
test("shows an accessible persistent banner in mock mode", () => {
|
||||
// Given, When
|
||||
render(<MockModeBanner apiMode="mock" />);
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("status", { name: "Mock Preview" })).toHaveTextContent("Mock Preview");
|
||||
});
|
||||
|
||||
test("does not render in server mode", () => {
|
||||
// Given, When
|
||||
render(<MockModeBanner apiMode="server" />);
|
||||
|
||||
// Then
|
||||
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
18
src/shared/ui/mock-mode-banner.tsx
Normal file
18
src/shared/ui/mock-mode-banner.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { ApiMode } from "@/shared/config/env";
|
||||
|
||||
export function MockModeBanner({ apiMode }: { readonly apiMode: ApiMode }) {
|
||||
if (apiMode === "server") {
|
||||
return null;
|
||||
}
|
||||
|
||||
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"
|
||||
role="status"
|
||||
>
|
||||
<span className="mr-2">Mock Preview</span>
|
||||
<span>개발용 fixture로 표시 중입니다. 실제 서버 연동 완료가 아닙니다.</span>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user