240 lines
11 KiB
TypeScript
240 lines
11 KiB
TypeScript
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, useAiCharacterDetailResponse, 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")).toHaveClass("flex-wrap", "py-2");
|
|
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("reuses successful ADMIN verification during protected intra-app navigation", async () => {
|
|
saveAdminSession();
|
|
const verificationRequests: Request[] = [];
|
|
useAiCharactersResponse(200, (request) => verificationRequests.push(request));
|
|
useAiCharacterDetailResponse("101");
|
|
window.history.pushState({}, "", "/ai-characters");
|
|
|
|
render(<App />);
|
|
await screen.findByRole("main", { name: "AI 캐릭터 관리" });
|
|
await waitFor(() => expect(verificationRequests).toHaveLength(2));
|
|
|
|
window.history.pushState({}, "", "/ai-characters/101/edit");
|
|
fireEvent.popState(window);
|
|
|
|
expect(screen.getByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
|
|
expect(screen.queryByRole("heading", { name: "관리자 권한 확인 중" })).not.toBeInTheDocument();
|
|
expect(await screen.findByRole("heading", { name: "AI 캐릭터 수정" })).toBeInTheDocument();
|
|
expect(verificationRequests).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("관리자 권한 확인 중");
|
|
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("관리자 권한 확인 중");
|
|
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("관리자 권한 확인 중");
|
|
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();
|
|
});
|