From 344674c756ddd74b34b6cb2a767807b354aa49d4 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Mon, 3 Aug 2026 13:08:12 +0900 Subject: [PATCH] =?UTF-8?q?fix(ai-character):=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EA=B6=8C=ED=95=9C=20=ED=99=95=EC=9D=B8=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=9A=94=EC=B2=AD=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/App.protected-errors.test.tsx | 27 ++++++++------- src/app/App.protected-shell.test.tsx | 28 ++++++++++++--- src/app/App.tsx | 34 +++++-------------- .../tests/character-mutation-reload.test.tsx | 2 +- 4 files changed, 49 insertions(+), 42 deletions(-) diff --git a/src/app/App.protected-errors.test.tsx b/src/app/App.protected-errors.test.tsx index c2afdf7..816670d 100644 --- a/src/app/App.protected-errors.test.tsx +++ b/src/app/App.protected-errors.test.tsx @@ -3,7 +3,7 @@ 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 { apiBaseUrl, saveAdminSession, useAiCharacterDetailResponse, 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"; @@ -142,7 +142,7 @@ test("retries a protected route 404 and reveals the shell only after the current window.history.pushState({}, "", "/ai-characters"); render(); const alert = await screen.findByRole("alert"); - const retryButton = screen.getByRole("button", { name: "보호 route 다시 시도" }); + const retryButton = screen.getByRole("button", { name: "관리자 권한 다시 확인" }); retryButton.focus(); expect(alert).toHaveTextContent("없습니다."); expect(retryButton).toHaveFocus(); @@ -153,7 +153,7 @@ test("retries a protected route 404 and reveals the shell only after the current const finishRetry = await retryReady; expect(requestCount).toBe(2); - expect(screen.getByRole("status")).toHaveTextContent("보호 route 확인 중"); + expect(screen.getByRole("status")).toHaveTextContent("관리자 권한 확인 중"); expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "로그아웃" })).not.toBeInTheDocument(); finishRetry(); @@ -170,37 +170,40 @@ test("keeps retry available and the protected shell hidden when a network retry })); window.history.pushState({}, "", "/ai-characters"); render(); - const retryButton = await screen.findByRole("button", { name: "보호 route 다시 시도" }); + const retryButton = await screen.findByRole("button", { name: "관리자 권한 다시 확인" }); 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(await screen.findByRole("button", { name: "관리자 권한 다시 확인" })).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 () => { +test("reuses successful protected route verification when the same session re-enters", async () => { saveAdminSession(); - useAiCharactersResponse(); + const verificationRequests: Request[] = []; + useAiCharactersResponse(200, (request) => verificationRequests.push(request)); + useAiCharacterDetailResponse("101"); window.history.pushState({}, "", "/ai-characters"); render(); expect(await screen.findByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument(); + await waitFor(() => expect(verificationRequests).toHaveLength(2)); window.history.pushState({}, "", "/login"); fireEvent.popState(window); await waitFor(() => expect(screen.getByRole("heading", { name: "관리자 로그인" })).toBeInTheDocument()); - useAiCharactersFailure(404); - window.history.pushState({}, "", "/ai-characters"); + window.history.pushState({}, "", "/ai-characters/101/edit"); 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(); + expect(await screen.findByRole("heading", { name: "AI 캐릭터 수정" })).toBeInTheDocument(); + expect(verificationRequests).toHaveLength(2); + expect(screen.getByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); }); test("keeps the protected shell hidden while a stale ADMIN probe is pending and then denied", async () => { diff --git a/src/app/App.protected-shell.test.tsx b/src/app/App.protected-shell.test.tsx index 13b002c..214822a 100644 --- a/src/app/App.protected-shell.test.tsx +++ b/src/app/App.protected-shell.test.tsx @@ -3,7 +3,7 @@ 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 { 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"; @@ -41,6 +41,26 @@ test("renders the protected admin shell for an existing ADMIN session", async () 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(); + 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; @@ -59,7 +79,7 @@ test("shows an accessible status while the initial protected route probe is pend render(); const finishProbe = await probeReady; - expect(screen.getByRole("status")).toHaveTextContent("보호 route 확인 중"); + expect(screen.getByRole("status")).toHaveTextContent("관리자 권한 확인 중"); expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "로그아웃" })).not.toBeInTheDocument(); finishProbe(); @@ -84,7 +104,7 @@ test("keeps malformed protected routes behind the ADMIN probe", async () => { render(); const finishProbe = await probeReady; - expect(screen.getByRole("status")).toHaveTextContent("보호 route 확인 중"); + expect(screen.getByRole("status")).toHaveTextContent("관리자 권한 확인 중"); expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument(); finishProbe(); expect(await screen.findByRole("heading", { name: "접근 권한이 없습니다" })).toBeInTheDocument(); @@ -122,7 +142,7 @@ test.each([ render(); const finishProbe = await probeReady; - expect(screen.getByRole("status")).toHaveTextContent("보호 route 확인 중"); + expect(screen.getByRole("status")).toHaveTextContent("관리자 권한 확인 중"); expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "로그아웃" })).not.toBeInTheDocument(); finishProbe(); diff --git a/src/app/App.tsx b/src/app/App.tsx index e7e7825..a4302fe 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -22,13 +22,6 @@ const sessionExpiredNotice = "세션이 만료되었습니다. 다시 로그인 type ProtectedRouteError = { readonly message: string; readonly session: NonNullable["session"]>; - readonly routeVisitKey: number; - readonly protectedRouteRetryKey: number; -}; - -type ProtectedRouteVerification = { - readonly session: ProtectedRouteError["session"]; - readonly routeVisitKey: number; readonly protectedRouteRetryKey: number; }; @@ -44,7 +37,7 @@ function ProtectedRouteErrorPage({ message, onRetry }: { readonly message: strin onClick={onRetry} type="button" > - 보호 route 다시 시도 + 관리자 권한 다시 확인 @@ -74,7 +67,7 @@ function AppShell({ apiMode }: { readonly apiMode: ApiMode }) { ); const location = useBrowserLocation(); const [routeError, setRouteError] = useState(null); - const [verifiedProtectedRouteSession, setVerifiedProtectedRouteSession] = useState(null); + const [verifiedProtectedRouteSession, setVerifiedProtectedRouteSession] = useState(null); const [protectedRouteRetryKey, setProtectedRouteRetryKey] = useState(0); useEffect(() => { @@ -84,13 +77,12 @@ function AppShell({ apiMode }: { readonly apiMode: ApiMode }) { }, [auth.session, location.path]); useEffect(() => { - if (!isAiCharactersRoute(location.path) || auth.session === null) { + if (!isAiCharactersRoute(location.path) || auth.session === null || verifiedProtectedRouteSession === auth.session) { return undefined; } let isCurrent = true; const session = auth.session; - const routeVisitKey = location.visitKey; const currentProtectedRouteRetryKey = protectedRouteRetryKey; void protectedRouteApiClient .request({ @@ -101,7 +93,7 @@ function AppShell({ apiMode }: { readonly apiMode: ApiMode }) { .then(() => { if (isCurrent) { setRouteError(null); - setVerifiedProtectedRouteSession({ session, routeVisitKey, protectedRouteRetryKey: currentProtectedRouteRetryKey }); + setVerifiedProtectedRouteSession(session); } }) .catch((error: unknown) => { @@ -115,9 +107,8 @@ function AppShell({ apiMode }: { readonly apiMode: ApiMode }) { } setRouteError({ - message: error instanceof ApiError ? error.message : "보호 route 확인에 실패했습니다.", + message: error instanceof ApiError ? error.message : "관리자 권한을 확인하지 못했습니다.", session, - routeVisitKey, protectedRouteRetryKey: currentProtectedRouteRetryKey, }); }); @@ -125,7 +116,7 @@ function AppShell({ apiMode }: { readonly apiMode: ApiMode }) { return () => { isCurrent = false; }; - }, [auth.session, location.path, location.visitKey, protectedRouteApiClient, protectedRouteRetryKey]); + }, [auth.session, location.path, protectedRouteApiClient, protectedRouteRetryKey, verifiedProtectedRouteSession]); if (location.path === routePaths.login) { return ( @@ -154,22 +145,15 @@ function AppShell({ apiMode }: { readonly apiMode: ApiMode }) { } const currentRouteError = - routeError?.session === auth.session && - routeError.routeVisitKey === location.visitKey && - routeError.protectedRouteRetryKey === protectedRouteRetryKey + routeError?.session === auth.session && routeError.protectedRouteRetryKey === protectedRouteRetryKey ? routeError.message : null; - if ( - isAiCharactersRoute(location.path) && - (verifiedProtectedRouteSession?.session !== auth.session || - verifiedProtectedRouteSession.routeVisitKey !== location.visitKey || - verifiedProtectedRouteSession.protectedRouteRetryKey !== protectedRouteRetryKey) - ) { + if (isAiCharactersRoute(location.path) && verifiedProtectedRouteSession !== auth.session) { return currentRouteError === null ? (
- +
) : ( diff --git a/src/features/characters/tests/character-mutation-reload.test.tsx b/src/features/characters/tests/character-mutation-reload.test.tsx index 3e106f6..f5692a8 100644 --- a/src/features/characters/tests/character-mutation-reload.test.tsx +++ b/src/features/characters/tests/character-mutation-reload.test.tsx @@ -165,5 +165,5 @@ test("soft delete success re-enters the list route and fetches the list", async await waitFor(() => expect(window.location.pathname).toBe("/ai-characters")); expect(await screen.findByRole("status", { name: "작업 성공" })).toHaveTextContent("AI 캐릭터를 비활성화했습니다."); expect(await screen.findByRole("heading", { name: "AI 캐릭터" })).toBeInTheDocument(); - await waitFor(() => expect(listRequests).toBeGreaterThanOrEqual(3)); + await waitFor(() => expect(listRequests).toBe(2)); });