feat(ai-character): 관리자 인증 셸 구현

This commit is contained in:
Yu Sung
2026-07-27 15:04:03 +09:00
parent ba43dd829e
commit 5356bd1e6a
82 changed files with 6017 additions and 18 deletions

1
.gitignore vendored
View File

@@ -10,6 +10,7 @@ mise.toml
node_modules/
dist/
playwright-report/
.playwright-mcp/
test-results/
coverage/
*.tsbuildinfo

196
DESIGN.md Normal file
View File

@@ -0,0 +1,196 @@
# AI Character Admin Design System
## 0. Research Log
- PRD baseline: adopted `10.1~10.9` as the visual contract because Task 1.1 is PRD-driven setup, not a new visual exploration.
- ui-ux-pro-max: ran `.opencode/skills/ui-ux-pro-max/scripts/search.py` with the PRD `10.9` design-system query. Adopted dense operational dashboard, subtle motion, status colors, focus visibility, reduced-motion, and no emoji icons. Excluded generated green palette, white on primary, dark mode support, Fira remote fonts, oversized landing typography, glass/continuous animation, and GSAP page transitions because they conflict with PRD `10.1~10.3`.
- ui-ux-pro-max UX: ran `.opencode/skills/ui-ux-pro-max/scripts/search.py "animation accessibility z-index loading" --domain ux -n 12`. Adopted loading feedback for waits over 300ms, semantic z-index scale, reduced motion, 150-300ms micro-interactions, and no decorative infinite animation. Deferred lazy-loaded media, loading buttons, skeletons, and route-level loading because Task 1.1 has no async page, media, form submit, or router surface yet.
- Existing UI: only the Phase 0 root shell exists, so this document defines the minimal system before new UI consumes it.
## 1. Atmosphere & Identity
A bright Korean operations console: dense, calm, and explicit. The signature is cyan as an operational signal, used for primary action, links, focus, and information states while surfaces stay quiet and readable.
## 2. Color
### Primitive Tokens
| Role | Token | Light | Usage |
|---|---|---:|---|
| Brand 50 | `--color-brand-50` | `#F0FBFF` | Faint emphasis background |
| Brand 100 | `--color-brand-100` | `#D9F6FF` | Selected row, accent surface |
| Brand 200 | `--color-brand-200` | `#B5EEFF` | Emphasis border |
| Brand 300 | `--color-brand-300` | `#7CE2FF` | Decorative low emphasis |
| Brand 400 | `--color-brand-400` | `#36D1FF` | Secondary accent |
| Brand 500 | `--color-brand-500` | `#00BDF7` | Fixed main color and primary background |
| Brand 600 | `--color-brand-600` | `#00A9DE` | Primary hover |
| Brand 700 | `--color-brand-700` | `#009DCE` | Primary active |
| Brand 800 | `--color-brand-800` | `#007EA8` | Link, focus ring, info |
| Brand 900 | `--color-brand-900` | `#086789` | Link hover |
| Brand 950 | `--color-brand-950` | `#063747` | Deep brand accent |
### Semantic Tokens
| Role | Token | Light | Usage |
|---|---|---:|---|
| Background | `--background` | `#F6FBFD` | Page background |
| Foreground | `--foreground` | `#102A33` | Main text |
| Card / Popover | `--card`, `--popover` | `#FFFFFF` | Surfaces and overlays |
| Muted | `--muted` | `#E9F4F7` | Muted surface |
| Muted foreground | `--muted-foreground` | `#425F69` | Secondary text |
| Secondary | `--secondary` | `#E1F5FA` | Secondary controls |
| Secondary foreground | `--secondary-foreground` | `#123E4B` | Text on secondary |
| Accent | `--accent` | `#D9F6FF` | Hover and selected surface |
| Accent foreground | `--accent-foreground` | `#0C566F` | Text on accent |
| Border | `--border` | `#D5E8EE` | Decorative separators |
| Input | `--input` | `#577581` | Required control boundary |
| Primary | `--primary` | `#00BDF7` | Main CTA |
| Primary foreground | `--primary-foreground` | `#062B36` | Text/icons on primary |
| Ring / Link | `--ring`, `--link` | `#007EA8` | Focus and link |
| Info | `--info` | `#086789` | Small informational labels |
| Success | `--success` | `#167347` | Open/success state |
| Success surface | `--success-surface` | `#EAF8F0` | Success Badge surface |
| Warning | `--warning` | `#9A5B00` | Scheduled/warning state |
| Warning surface | `--warning-surface` | `#FFF7E6` | Warning Badge surface |
| Destructive | `--destructive` | `#B42318` | Error/destructive state |
| Inactive | `--inactive` | `#52636A` | Inactive state |
| Inactive surface | `--inactive-surface` | `#EEF3F5` | Inactive Badge surface |
### Rules
- Feature components use semantic or component tokens, not raw hex.
- `#00BDF7` never uses white foreground; `--primary-foreground` is `#062B36`.
- Light theme only in this release. No `.dark`, theme provider, theme toggle, or system dark integration.
## 3. Typography
| Level | Size | Weight | Line Height | Usage |
|---|---:|---:|---:|---|
| Page title | `24px` | 700 | 1.3 | Main page title |
| Section title | `20px` | 650 | 1.4 | Section headers |
| Body | `14px` | 400 | 1.5 | Dense admin text and tables |
| Small | `13px` | 400 | 1.45 | Secondary metadata |
| Caption | `12px` | 600 | 1.4 | Labels and badges |
| Mobile input | `16px` | 400 | 1.5 | iOS-safe input text |
Primary font stack: `Pretendard`, `Noto Sans KR`, `Apple SD Gothic Neo`, `system-ui`, `sans-serif`.
## 4. Spacing & Layout
- Base spacing is an 8px grid, with 4px available only for tight icon-label gaps.
- Control target minimum is 44px.
- Initial shell remains simple: the document scrolls until Task 1.4 introduces the admin shell.
- Future desktop shell dimensions follow PRD: 240px sidebar and 56px header.
## 5. Components
### StatusBadge
- Structure: inline status container with a decorative dot and visible Korean text label.
- Variants: `OPEN`, `SCHEDULED`, `INACTIVE`.
- Spacing: `--space-1`, `--space-2`.
- States: static display only in Task 1.1.
- Accessibility: `aria-label="상태: {label}"`; color never carries status alone.
- Motion: none.
### IconOnlyAction
- Structure: button with icon slot, accessible name, and tooltip description.
- Variants: single icon-only control primitive.
- Spacing: 44px minimum target, centered icon.
- States: hover, active, focus-visible, disabled.
- Accessibility: `aria-label`, tooltip element, 3:1 control boundary and focus ring tokens. Tooltip text equal to the label is not wired as `aria-describedby` to avoid duplicate name/description.
- Motion: 150ms transform/color transition, disabled under reduced motion.
### PageState
- Structure: tokenized card surface for loading, empty, error, and content pass-through states.
- Variants: loading and empty use `role="status"`; error uses `role="alert"` and optional retry button.
- Accessibility: state meaning is visible Korean copy plus semantic role; retry is a native button.
- Motion: none.
### SearchToolbar
- Structure: controlled search input with optional filter slot inside a bordered card surface.
- Variants: generic query only; domain endpoint query names stay outside the primitive.
- Accessibility: search input has a visible label and mobile-safe input sizing.
- Motion: none.
### ResourcePagination
- Structure: `PageData` summary, page-size select, and native previous/next buttons.
- Variants: disabled previous/next derive from page bounds and `hasNext`.
- Accessibility: native controls expose Korean names and preserve keyboard behavior.
- Motion: none beyond control state color.
### ResponsiveResourceList
- Structure: desktop and mobile rendering slots inside one region; the primitive owns breakpoint visibility only.
- Variants: none; domain columns, DTOs, and action unions stay in consuming screens.
- Accessibility: region is named by the caller.
- Motion: none.
### ConfirmDeactivateDialog
- Structure: modal confirmation dialog with target name, impact copy, cancel, and destructive confirm action.
- Variants: confirmation only; never a switch replacement.
- Accessibility: `alertdialog`, visible title, native buttons.
- Motion: none.
### UnsavedChangesGuard
- Structure: render-prop guard for route-leave triggers that opens a confirmation dialog only while dirty.
- Variants: dirty blocks, safe/saved state lets the route callback run immediately.
- Accessibility: cancel keeps the user in context and returns focus to the trigger.
- Motion: none.
### FileField
- Structure: controlled `File | null` field with visible label, native file input, accept guidance, selected filename, and clear button.
- Variants: domain-neutral only; allowed extensions, MIME, and max bytes are injected by caller policy.
- Accessibility: label targets the input; description, accept guidance, and error are connected with `aria-describedby`; clear is a native button.
- Motion: none.
### ImageCropDialog
- Structure: modal crop surface with preview, output size, directional move buttons, zoom range, reset, cancel, and apply.
- Variants: caller injects `aspect`, `maxWidth`, and `noUpscale`; domain profile names and GIF exceptions stay outside the primitive.
- Accessibility: dialog has visible title, keyboard preview controls, range input, and button alternatives. No pointer-only requirement in Phase 1.6.
- Motion: transform-only preview adjustment. No crop dependency is added; Canvas is used only when generating the final `File`.
### UploadProgress
- Structure: status label, optional filename, progressbar, and optional cancel/retry buttons.
- Variants: display-only upload state; no upload client, request adapter, or domain form ownership.
- Accessibility: progressbar exposes `aria-valuenow`; cancel/retry are native buttons.
- Motion: none.
### AdminAudioPlayer
- Structure: native audio element wrapped with play/pause, seek, time, volume, speed, generic error, and manual retry controls.
- Variants: shared signed-URL player only; it never downloads, autoplays, auto-refetches, or infers signed URL expiry.
- Accessibility: player region is named by title, keyboard Space/Enter toggles play, seek/volume use range inputs, speed uses native select.
- Motion: none.
### AudioPlaybackProvider
- Structure: shared context coordinates active audio by player id so only one player continues at a time.
- Variants: stores only player ids in React state; signed URLs are not logged, persisted, or passed into the provider.
- Accessibility: no direct rendered surface.
- Motion: none.
## 6. Motion & Interaction
- Motion is limited to 150ms micro-interactions for real control state changes.
- Only `transform`, `opacity`, and color changes are used for Task 1.1 primitives.
- `prefers-reduced-motion: reduce` disables non-essential transitions and animations.
## 7. Depth & Surface
Strategy: mixed but restrained. Dense admin surfaces primarily use borders and tonal shifts; shadows are reserved for overlays in later phases.
## 8. Accessibility Constraints & Accepted Debt
- Target WCAG 2.2 AA: body text 4.5:1, control boundary/focus indicator 3:1, visible focus on every interactive element.
- Korean text must keep system fallbacks and inputs must stay at least 16px on mobile.
- Accepted debt: no component showcase route in Task 1.1 because the requested scope is token/base/component setup only; RTL tests exercise the primitive states.

21
components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/shared/ui",
"utils": "@/shared/lib/utils",
"ui": "@/shared/ui",
"lib": "@/shared/lib",
"hooks": "@/shared/hooks"
},
"iconLibrary": "lucide"
}

BIN
login-surface.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

1201
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,12 +15,16 @@
"e2e": "playwright test"
},
"dependencies": {
"@tanstack/react-query": "^5.101.4",
"react": "19.2.8",
"react-dom": "19.2.8"
"react-dom": "19.2.8",
"zod": "^4.4.3"
},
"devDependencies": {
"@axe-core/playwright": "^4.12.1",
"@eslint/js": "10.0.1",
"@playwright/test": "1.61.1",
"@tailwindcss/vite": "4.3.3",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "7.0.0",
"@testing-library/react": "16.3.2",
@@ -33,6 +37,8 @@
"eslint-plugin-react-refresh": "0.5.3",
"globals": "17.7.0",
"jsdom": "29.1.1",
"msw": "^2.15.0",
"tailwindcss": "4.3.3",
"typescript": "6.0.3",
"typescript-eslint": "8.65.0",
"vite": "8.1.5",

BIN
protected-shell-surface.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -1,10 +1,288 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { App } from "./App";
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
import { server } from "@/shared/test/server";
test("renders Korean root shell with a main landmark", () => {
const apiBaseUrl = "https://api.example.com";
function saveAdminSession() {
authSessionStorage.save({ token: "admin-token", role: "ADMIN" });
}
function useAiCharactersResponse(status: 200 | 401 | 403 = 200, onRequest: (request: Request) => void = () => undefined) {
server.use(
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, ({ request }) => {
onRequest(request);
if (status === 401) {
return HttpResponse.json(
{ success: false, message: "인증 정보가 없습니다.", data: null, errorProperty: null },
{ status },
);
}
if (status === 403) {
return HttpResponse.json(
{ success: false, message: "접근 권한이 없습니다.", data: null, errorProperty: null },
{ status },
);
}
return HttpResponse.json({ success: true, message: null, data: null, errorProperty: null });
}),
);
}
beforeEach(() => {
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
});
afterEach(() => {
vi.unstubAllEnvs();
window.history.replaceState({}, "", "/");
});
test("preserves the Korean document language", () => {
render(<App />);
expect(document.documentElement).toHaveAttribute("lang", "ko");
expect(screen.getByRole("main")).toHaveTextContent("AI 캐릭터 관리자");
expect(screen.getByRole("main")).toContainElement(screen.getByRole("heading", { name: "관리자 로그인" }));
});
test("redirects an unauthenticated direct visit to /ai-characters without exposing protected content", async () => {
window.history.pushState({}, "", "/ai-characters");
render(<App />);
expect(screen.queryByText("Phase 2에서 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();
});
test("renders the existing login page at /login", () => {
window.history.pushState({}, "", "/login");
render(<App />);
expect(screen.getByRole("heading", { name: "관리자 로그인" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "로그인" })).toBeInTheDocument();
});
test("navigates to /ai-characters after a successful login", async () => {
window.history.pushState({}, "", "/login");
useAiCharactersResponse();
server.use(
http.post(`${apiBaseUrl}/admin/member/login`, () =>
HttpResponse.json({
success: true,
message: null,
data: { token: "jwt-token", role: "ADMIN" },
errorProperty: null,
}),
),
);
render(<App />);
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(screen.getByRole("heading", { name: "AI 캐릭터" })).toBeInTheDocument();
});
test("renders the protected admin shell for an existing ADMIN session", async () => {
saveAdminSession();
const requests: Request[] = [];
useAiCharactersResponse(200, (request) => requests.push(request));
window.history.pushState({}, "", "/ai-characters");
render(<App />);
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
expect(await screen.findByRole("heading", { name: "AI 캐릭터" })).toBeInTheDocument();
expect(requests[0]?.url).toContain("size=20");
expect(screen.getByRole("link", { name: "본문으로 건너뛰기" })).toHaveAttribute("href", "#app-main");
expect(screen.getByRole("banner")).toBeInTheDocument();
expect(screen.getByRole("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.queryByText("루나")).not.toBeInTheDocument();
});
test("composes Task 1.5 shared empty state in the real admin shell without domain list data", async () => {
saveAdminSession();
useAiCharactersResponse();
window.history.pushState({}, "", "/ai-characters");
render(<App />);
expect(await screen.findByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent("Phase 2에서 AI 캐릭터 목록이 연결됩니다.");
expect(screen.queryByText("루나")).not.toBeInTheDocument();
});
test("clears the session and routes to login when the protected route request returns 401", async () => {
saveAdminSession();
useAiCharactersResponse(401);
window.history.pushState({}, "", "/ai-characters");
render(<App />);
await waitFor(() => expect(window.location.pathname).toBe("/login"));
expect(authSessionStorage.read()).toBeNull();
expect(screen.getByRole("heading", { name: "관리자 로그인" })).toBeInTheDocument();
expect(screen.getByRole("alert")).toHaveTextContent("세션이 만료되었습니다. 다시 로그인하세요.");
expect(screen.queryByText("Phase 2에서 AI 캐릭터 목록이 연결됩니다.")).not.toBeInTheDocument();
});
test("routes to access denied without clearing the session when the protected route request returns 403", async () => {
saveAdminSession();
useAiCharactersResponse(403);
window.history.pushState({}, "", "/ai-characters");
render(<App />);
await waitFor(() => expect(window.location.pathname).toBe("/access-denied"));
expect(authSessionStorage.read()).toEqual({ token: "admin-token", role: "ADMIN" });
expect(screen.getByRole("heading", { name: "접근 권한이 없습니다" })).toBeInTheDocument();
expect(screen.queryByText("루나")).not.toBeInTheDocument();
});
test("keeps the protected shell hidden while a stale ADMIN probe is pending and then denied", async () => {
saveAdminSession();
window.history.pushState({}, "", "/ai-characters");
let resolveDenyProbeReady: (denyProbe: () => void) => void = () => undefined;
const denyProbeReady = new Promise<() => void>((resolve) => {
resolveDenyProbeReady = resolve;
});
server.use(
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () =>
new Promise((resolve) => {
resolveDenyProbeReady(() =>
resolve(
HttpResponse.json(
{ success: false, message: "접근 권한이 없습니다.", data: null, errorProperty: null },
{ status: 403 },
),
),
);
}),
),
);
render(<App />);
const triggerDenyProbe = await denyProbeReady;
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
expect(screen.queryByText("Phase 2에서 AI 캐릭터 목록이 연결됩니다.")).not.toBeInTheDocument();
triggerDenyProbe();
await waitFor(() => expect(window.location.pathname).toBe("/access-denied"));
expect(screen.getByRole("heading", { name: "접근 권한이 없습니다" })).toBeInTheDocument();
});
test("keeps keyboard focus inside the mobile menu and returns focus to the trigger", async () => {
saveAdminSession();
useAiCharactersResponse();
window.history.pushState({}, "", "/ai-characters");
render(<App />);
await screen.findByRole("heading", { name: "AI 캐릭터" });
const menuButton = screen.getByRole("button", { name: "모바일 메뉴 열기" });
fireEvent.click(menuButton);
const menu = screen.getByRole("navigation", { name: "모바일 주 메뉴" });
const closeButton = screen.getByRole("button", { name: "모바일 메뉴 닫기" });
await waitFor(() => expect(closeButton).toHaveFocus());
fireEvent.keyDown(menu, { key: "Tab", shiftKey: true });
expect(screen.getByRole("link", { name: "AI 캐릭터" })).toHaveFocus();
fireEvent.keyDown(menu, { key: "Tab" });
expect(closeButton).toHaveFocus();
fireEvent.keyDown(window, { key: "Escape" });
expect(screen.queryByRole("navigation", { name: "모바일 주 메뉴" })).not.toBeInTheDocument();
expect(menuButton).toHaveFocus();
});
test("shows a login warning when server logout confirmation fails", async () => {
saveAdminSession();
useAiCharactersResponse();
window.history.pushState({}, "", "/ai-characters");
server.use(
http.post(`${apiBaseUrl}/member/logout`, () =>
HttpResponse.json(
{ success: false, message: "로그아웃 확인 실패", data: null, errorProperty: null },
{ status: 500 },
),
),
);
render(<App />);
fireEvent.click(await screen.findByRole("button", { name: "로그아웃" }));
await waitFor(() => expect(window.location.pathname).toBe("/login"));
expect(authSessionStorage.read()).toBeNull();
expect(screen.getByRole("alert")).toHaveTextContent("서버 로그아웃 확인에 실패했습니다.");
});
test("logs out from the protected shell and routes to login", async () => {
saveAdminSession();
useAiCharactersResponse();
window.history.pushState({}, "", "/ai-characters");
server.use(
http.post(`${apiBaseUrl}/member/logout`, ({ request }) => {
expect(request.headers.get("Authorization")).toBe("Bearer admin-token");
return HttpResponse.json({ success: true, message: null, data: {}, errorProperty: null });
}),
);
render(<App />);
fireEvent.click(await screen.findByRole("button", { name: "로그아웃" }));
await waitFor(() => expect(window.location.pathname).toBe("/login"));
expect(authSessionStorage.read()).toBeNull();
});
test("sends only one logout request while the first logout is in flight", async () => {
saveAdminSession();
useAiCharactersResponse();
window.history.pushState({}, "", "/ai-characters");
let logoutCount = 0;
let resolveLogoutReady: (finishLogout: () => void) => void = () => undefined;
const logoutReady = new Promise<() => void>((resolve) => {
resolveLogoutReady = resolve;
});
server.use(
http.post(`${apiBaseUrl}/member/logout`, () => {
logoutCount += 1;
return new Promise((resolve) => {
resolveLogoutReady(() => resolve(HttpResponse.json({ success: true, message: null, data: {}, errorProperty: null })));
});
}),
);
render(<App />);
const logoutButton = await screen.findByRole("button", { name: "로그아웃" });
fireEvent.click(logoutButton);
fireEvent.click(logoutButton);
await waitFor(() => expect(logoutCount).toBe(1));
const finishLogout = await logoutReady;
finishLogout();
await waitForElementToBeRemoved(logoutButton);
expect(window.location.pathname).toBe("/login");
});

View File

@@ -1,13 +1,263 @@
import { useEffect } from "react";
import { useEffect, useMemo, useRef, useState } 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 { routePaths } from "@/app/route-paths";
import { navigateTo, replaceWith, useBrowserLocation } from "@/app/browser-location";
import { AccessDeniedError } from "@/shared/api/api-error";
import { createApiClient } from "@/shared/api/client";
const aiCharactersRouteResponseSchema = z.unknown();
const sessionExpiredNotice = "세션이 만료되었습니다. 다시 로그인하세요.";
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>
);
}
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>
</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>
);
}
function AppShell() {
const auth = useAuthSession();
const protectedRouteApiClient = useMemo(
() =>
createApiClient({
getToken: () => authSessionStorage.read()?.token ?? null,
clearSession: () => auth.clearSession(sessionExpiredNotice),
onAuthExpired: () => replaceWith(routePaths.login),
}),
[auth],
);
const location = useBrowserLocation();
const [routeError, setRouteError] = useState<string | null>(null);
const [verifiedProtectedRouteToken, setVerifiedProtectedRouteToken] = useState<string | null>(null);
useEffect(() => {
if (location !== routePaths.login && auth.session === null) {
replaceWith(routePaths.login);
}
}, [auth.session, location]);
useEffect(() => {
if (location !== routePaths.aiCharacters || auth.session === null) {
return undefined;
}
let isCurrent = true;
const sessionToken = auth.session.token;
void protectedRouteApiClient
.request({
path: "/api/v2/admin/ai-characters?page=0&size=20",
responseSchema: aiCharactersRouteResponseSchema,
authentication: "required",
})
.then(() => {
if (isCurrent) {
setRouteError(null);
setVerifiedProtectedRouteToken(sessionToken);
}
})
.catch((error: unknown) => {
if (!isCurrent) {
return;
}
if (error instanceof AccessDeniedError) {
replaceWith(routePaths.accessDenied);
return;
}
setRouteError("보호 route 확인에 실패했습니다.");
});
return () => {
isCurrent = false;
};
}, [auth.session, location, protectedRouteApiClient]);
if (location === routePaths.login) {
return (
<LoginPage
notice={auth.loginNotice}
onSubmit={async (credentials) => {
await auth.login(credentials);
navigateTo(routePaths.aiCharacters);
}}
/>
);
}
if (auth.session === null) {
return null;
}
if (location === routePaths.accessDenied) {
return <AccessDeniedPage />;
}
if (location === routePaths.aiCharacters && verifiedProtectedRouteToken !== auth.session.token) {
return null;
}
return <ProtectedAdminShell routeError={routeError} />;
}
export function App() {
const apiClient = useMemo(
() =>
createApiClient({
getToken: () => authSessionStorage.read()?.token ?? null,
clearSession: authSessionStorage.remove,
onAuthExpired: () => replaceWith(routePaths.login),
}),
[],
);
useEffect(() => {
document.documentElement.lang = "ko";
}, []);
return (
<main>
<h1>AI </h1>
</main>
<AuthSessionProvider apiClient={apiClient} onNavigateLogin={replaceWith}>
<AppShell />
</AuthSessionProvider>
);
}

37
src/app/admin-pages.tsx Normal file
View File

@@ -0,0 +1,37 @@
import { routePaths } from "@/app/route-paths";
import { PageState } from "@/shared/ui/page-state";
export function AccessDeniedPage() {
return (
<main className="flex min-h-[100dvh] items-center justify-center bg-background px-4 text-foreground">
<section className="flex max-w-sm flex-col gap-4 rounded-lg border border-border bg-card p-6">
<p className="text-xs font-semibold text-info">ACCESS DENIED</p>
<h1 className="text-2xl font-bold leading-tight"> </h1>
<p className="text-sm text-muted-foreground">ADMIN .</p>
<a className="font-semibold text-link hover:text-link-hover" href={routePaths.login}>
</a>
</section>
</main>
);
}
export function AiCharactersPage({ routeError }: { readonly routeError: string | null }) {
return (
<section className="flex flex-col gap-4" aria-labelledby="ai-characters-title">
<div className="flex flex-col gap-2">
<p className="text-xs font-semibold text-info">AI CHARACTER ADMIN</p>
<h1 className="text-2xl font-bold leading-tight" id="ai-characters-title">
AI
</h1>
<p className="text-sm text-muted-foreground"> Phase 2 .</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 캐릭터 목록이 연결됩니다." />
</section>
);
}

View File

@@ -0,0 +1,33 @@
import { useSyncExternalStore } from "react";
import { routePaths, type RoutePath } from "@/app/route-paths";
function subscribe(onStoreChange: () => void): () => void {
window.addEventListener("popstate", onStoreChange);
return () => window.removeEventListener("popstate", onStoreChange);
}
function getSnapshot(): RoutePath {
const path = window.location.pathname;
if (path === routePaths.login || path === routePaths.aiCharacters || path === routePaths.accessDenied) {
return path;
}
return routePaths.aiCharacters;
}
export function useBrowserLocation(): RoutePath {
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
export function navigateTo(path: RoutePath): void {
window.history.pushState({}, "", path);
window.dispatchEvent(new PopStateEvent("popstate"));
}
export function replaceWith(path: RoutePath): void {
window.history.replaceState({}, "", path);
window.dispatchEvent(new PopStateEvent("popstate"));
}

7
src/app/route-paths.ts Normal file
View File

@@ -0,0 +1,7 @@
export const routePaths = {
accessDenied: "/access-denied",
login: "/login",
aiCharacters: "/ai-characters",
} as const;
export type RoutePath = (typeof routePaths)[keyof typeof routePaths];

View File

@@ -0,0 +1,31 @@
import { z } from "zod";
import type { AuthSessionRecord } from "@/features/auth/model/auth-session-storage";
import type { LoginCredentials } from "@/features/auth/schemas/login-schema";
import type { ApiClient } from "@/shared/api/client";
const loginResponseSchema = z.object({
token: z.string().min(1),
role: z.literal("ADMIN"),
});
const logoutResponseSchema = z.object({});
export function login(apiClient: ApiClient, credentials: LoginCredentials): Promise<AuthSessionRecord> {
return apiClient.request({
path: "/admin/member/login",
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: credentials.email, password: credentials.password }),
responseSchema: loginResponseSchema,
authentication: "none",
});
}
export async function logout(apiClient: ApiClient): Promise<void> {
await apiClient.request({
path: "/member/logout",
method: "POST",
responseSchema: logoutResponseSchema,
authentication: "required",
});
}

View File

@@ -0,0 +1,32 @@
import { createContext, useContext } from "react";
import type { AuthSessionRecord } from "@/features/auth/model/auth-session-storage";
import type { LoginCredentials } from "@/features/auth/schemas/login-schema";
class MissingAuthSessionProviderError extends Error {
override readonly name = "MissingAuthSessionProviderError";
constructor() {
super("AuthSessionProvider is required.");
}
}
export type AuthSessionContextValue = {
readonly session: AuthSessionRecord | null;
readonly loginNotice: string | null;
readonly login: (credentials: LoginCredentials) => Promise<void>;
readonly logout: () => Promise<void>;
readonly clearSession: (loginNotice?: string | null) => void;
};
export const AuthSessionContext = createContext<AuthSessionContextValue | null>(null);
export function useAuthSession(): AuthSessionContextValue {
const context = useContext(AuthSessionContext);
if (context === null) {
throw new MissingAuthSessionProviderError();
}
return context;
}

View File

@@ -0,0 +1,35 @@
import { describe, expect, test } from "vitest";
import {
authSessionStorage,
type AuthSessionRecord,
} from "./auth-session-storage";
describe("authSessionStorage", () => {
const session: AuthSessionRecord = {
token: "header.payload.signature",
role: "ADMIN",
};
test("reads a saved ADMIN session", () => {
// Given
authSessionStorage.save(session);
// When
const restoredSession = authSessionStorage.read();
// Then
expect(restoredSession).toEqual(session);
});
test("removes a saved session", () => {
// Given
authSessionStorage.save(session);
// When
authSessionStorage.remove();
// Then
expect(authSessionStorage.read()).toBeNull();
});
});

View File

@@ -0,0 +1,38 @@
import { z } from "zod";
export type AuthSessionRecord = {
readonly token: string;
readonly role: "ADMIN";
};
const authSessionStorageKey = "ai-character-admin-auth-session";
const authSessionRecordSchema = z.object({
token: z.string().min(1),
role: z.literal("ADMIN"),
});
function parseAuthSession(value: string): AuthSessionRecord | null {
try {
return authSessionRecordSchema.parse(JSON.parse(value));
} catch (error) {
if (error instanceof SyntaxError || error instanceof z.ZodError) {
return null;
}
throw error;
}
}
export const authSessionStorage = {
read(): AuthSessionRecord | null {
const value = sessionStorage.getItem(authSessionStorageKey);
return value === null ? null : parseAuthSession(value);
},
save(session: AuthSessionRecord): void {
sessionStorage.setItem(authSessionStorageKey, JSON.stringify(session));
},
remove(): void {
sessionStorage.removeItem(authSessionStorageKey);
},
};

View File

@@ -0,0 +1,77 @@
import { useCallback, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import { login as requestLogin, logout as requestLogout } from "@/features/auth/api/auth-api";
import { AuthSessionContext } from "@/features/auth/model/auth-session-context";
import type { AuthSessionContextValue } from "@/features/auth/model/auth-session-context";
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
import type { AuthSessionRecord } from "@/features/auth/model/auth-session-storage";
import type { LoginCredentials } from "@/features/auth/schemas/login-schema";
import type { ApiClient } from "@/shared/api/client";
const logoutFailureWarning = "서버 로그아웃 확인에 실패했습니다.";
export type AuthSessionProviderProps = {
readonly apiClient: ApiClient;
readonly children: ReactNode;
readonly onNavigateLogin?: (path: "/login") => void;
};
export function AuthSessionProvider({ apiClient, children, onNavigateLogin }: AuthSessionProviderProps) {
const [session, setSession] = useState<AuthSessionRecord | null>(() => authSessionStorage.read());
const [loginNotice, setLoginNotice] = useState<string | null>(null);
const logoutPromiseRef = useRef<Promise<void> | null>(null);
const clearSession = useCallback((nextLoginNotice: string | null = null) => {
authSessionStorage.remove();
setSession(null);
setLoginNotice(nextLoginNotice);
}, []);
const login = useCallback(
async (credentials: LoginCredentials) => {
const nextSession = await requestLogin(apiClient, credentials);
authSessionStorage.save(nextSession);
setSession(nextSession);
setLoginNotice(null);
},
[apiClient],
);
const logout = useCallback(async () => {
if (logoutPromiseRef.current !== null) {
return logoutPromiseRef.current;
}
const logoutPromise = (async () => {
let didLogoutRequestFail = false;
try {
await requestLogout(apiClient);
} catch (error) {
if (error instanceof Error) {
didLogoutRequestFail = true;
} else {
didLogoutRequestFail = true;
}
}
authSessionStorage.remove();
setSession(null);
setLoginNotice(didLogoutRequestFail ? logoutFailureWarning : null);
onNavigateLogin?.("/login");
})().finally(() => {
logoutPromiseRef.current = null;
});
logoutPromiseRef.current = logoutPromise;
return logoutPromise;
}, [apiClient, onNavigateLogin]);
const value = useMemo<AuthSessionContextValue>(
() => ({ session, loginNotice, login, logout, clearSession }),
[clearSession, login, loginNotice, logout, session],
);
return <AuthSessionContext.Provider value={value}>{children}</AuthSessionContext.Provider>;
}

View File

@@ -0,0 +1,156 @@
import { useId, useRef, useState } from "react";
import { z } from "zod";
import { loginSchema } from "@/features/auth/schemas/login-schema";
import type { LoginCredentials } from "@/features/auth/schemas/login-schema";
type LoginFieldErrors = {
readonly email: string | null;
readonly password: string | null;
};
export type LoginPageProps = {
readonly notice?: string | null;
readonly onSubmit: (credentials: LoginCredentials) => Promise<void>;
};
const emptyErrors: LoginFieldErrors = { email: null, password: null };
function getCredentials(form: HTMLFormElement): LoginCredentials {
const formData = new FormData(form);
return {
email: String(formData.get("email") ?? ""),
password: String(formData.get("password") ?? ""),
};
}
function getFieldErrors(error: z.ZodError<LoginCredentials>): LoginFieldErrors {
const fieldErrors = z.flattenError(error).fieldErrors;
return {
email: fieldErrors.email?.[0] ?? null,
password: fieldErrors.password?.[0] ?? null,
};
}
export function LoginPage({ notice = null, onSubmit }: LoginPageProps) {
const emailErrorId = useId();
const passwordErrorId = useId();
const emailRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
const [errors, setErrors] = useState<LoginFieldErrors>(emptyErrors);
const [submitError, setSubmitError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleSubmit(form: HTMLFormElement): Promise<void> {
const parsedCredentials = loginSchema.safeParse(getCredentials(form));
if (!parsedCredentials.success) {
const nextErrors = getFieldErrors(parsedCredentials.error);
setErrors(nextErrors);
if (nextErrors.email !== null) {
emailRef.current?.focus();
} else if (nextErrors.password !== null) {
passwordRef.current?.focus();
}
return;
}
setErrors(emptyErrors);
setSubmitError(null);
setIsSubmitting(true);
try {
await onSubmit(parsedCredentials.data);
} catch (error) {
if (error instanceof Error) {
setSubmitError(error.message || "로그인에 실패했습니다.");
return;
}
throw error;
} finally {
setIsSubmitting(false);
}
}
return (
<main className="min-h-[100dvh] bg-background px-4 py-12 text-foreground sm:px-6">
<section className="mx-auto flex w-full max-w-sm flex-col gap-6 rounded-lg border border-border bg-card p-6">
<header className="flex flex-col gap-2">
<p className="text-xs font-semibold text-info">AI CHARACTER ADMIN</p>
<h1 className="text-2xl font-bold leading-tight"> </h1>
<p className="text-sm text-muted-foreground">ADMIN .</p>
</header>
{notice === null ? null : (
<p className="rounded-md border border-warning bg-warning-surface p-3 text-sm font-semibold text-warning" role="alert">
{notice}
</p>
)}
<form
className="flex flex-col gap-4"
noValidate
onSubmit={(event) => {
event.preventDefault();
void handleSubmit(event.currentTarget);
}}
>
<div className="flex flex-col gap-2">
<label className="text-sm font-semibold" htmlFor="login-email">
</label>
<input
aria-describedby={errors.email === null ? undefined : emailErrorId}
aria-invalid={errors.email !== null}
autoComplete="email"
className="rounded-md border border-input bg-card px-3 py-2 text-base text-foreground"
id="login-email"
name="email"
ref={emailRef}
type="email"
/>
{errors.email === null ? null : (
<p className="text-sm font-semibold text-destructive" id={emailErrorId} role="alert">
{errors.email}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-semibold" htmlFor="login-password">
</label>
<input
aria-describedby={errors.password === null ? undefined : passwordErrorId}
aria-invalid={errors.password !== null}
autoComplete="current-password"
className="rounded-md border border-input bg-card px-3 py-2 text-base text-foreground"
id="login-password"
name="password"
ref={passwordRef}
type="password"
/>
{errors.password === null ? null : (
<p className="text-sm font-semibold text-destructive" id={passwordErrorId} role="alert">
{errors.password}
</p>
)}
</div>
{submitError === null ? null : (
<p className="text-sm font-semibold text-destructive" role="alert">
{submitError}
</p>
)}
<button
className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground transition-colors hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60"
disabled={isSubmitting}
type="submit"
>
</button>
</form>
</section>
</main>
);
}

View File

@@ -0,0 +1,8 @@
import { z } from "zod";
export const loginSchema = z.object({
email: z.email("올바른 이메일을 입력하세요."),
password: z.string().min(1, "비밀번호를 입력하세요."),
});
export type LoginCredentials = z.infer<typeof loginSchema>;

View File

@@ -0,0 +1,107 @@
import { http, HttpResponse } from "msw";
import { afterEach, describe, expect, test, vi } from "vitest";
import { login, logout } from "@/features/auth/api/auth-api";
import { createApiClient } from "@/shared/api/client";
import { server } from "@/shared/test/server";
const apiBaseUrl = "https://api.example.com";
function createClient(token: string | null = "header.payload.signature") {
return createApiClient({
getToken: () => token,
clearSession: vi.fn(),
onAuthExpired: vi.fn(),
});
}
afterEach(() => {
vi.unstubAllEnvs();
});
describe("auth API", () => {
test("posts email and password JSON only to admin login without Authorization", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const client = createClient("caller-token");
const observedRequests: string[] = [];
let authorization: string | null = null;
let contentType: string | null = null;
let body: unknown = null;
server.use(
http.post(`${apiBaseUrl}/admin/member/login`, async ({ request }) => {
observedRequests.push(new URL(request.url).pathname);
authorization = request.headers.get("Authorization");
contentType = request.headers.get("Content-Type");
body = await request.json();
return HttpResponse.json({
success: true,
message: null,
data: { token: "jwt-token", role: "ADMIN" },
errorProperty: null,
});
}),
http.all(`${apiBaseUrl}/refresh`, ({ request }) => {
observedRequests.push(new URL(request.url).pathname);
return HttpResponse.json({ success: false, message: "unexpected", data: null }, { status: 500 });
}),
);
// When
const session = await login(client, { email: "admin@test.com", password: "password" });
// Then
expect(session).toEqual({ token: "jwt-token", role: "ADMIN" });
expect(authorization).toBeNull();
expect(contentType).toContain("application/json");
expect(body).toEqual({ email: "admin@test.com", password: "password" });
expect(observedRequests).toEqual(["/admin/member/login"]);
});
test.each([
{ name: "empty token", data: { token: "", role: "ADMIN" } },
{ name: "missing token", data: { role: "ADMIN" } },
{ name: "missing role", data: { token: "jwt-token" } },
{ name: "non ADMIN role", data: { token: "jwt-token", role: "USER" } },
])("rejects $name login response", async ({ data }) => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const client = createClient();
server.use(
http.post(`${apiBaseUrl}/admin/member/login`, () =>
HttpResponse.json({ success: true, message: null, data, errorProperty: null }),
),
);
// When
const request = login(client, { email: "admin@test.com", password: "password" });
// Then
await expect(request).rejects.toThrow("API 응답 형식이 올바르지 않습니다.");
});
test("posts logout once with Bearer and no body", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const client = createClient("logout-token");
let calls = 0;
let authorization: string | null = null;
let body = "not-read";
server.use(
http.post(`${apiBaseUrl}/member/logout`, async ({ request }) => {
calls += 1;
authorization = request.headers.get("Authorization");
body = await request.text();
return HttpResponse.json({ success: true, message: null, data: {}, errorProperty: null });
}),
);
// When
await logout(client);
// Then
expect(calls).toBe(1);
expect(authorization).toBe("Bearer logout-token");
expect(body).toBe("");
});
});

View File

@@ -0,0 +1,203 @@
import { render, screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { useState } from "react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { useAuthSession } from "@/features/auth/model/auth-session-context";
import { AuthSessionProvider } from "@/features/auth/model/auth-session";
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
import { createApiClient } from "@/shared/api/client";
import { server } from "@/shared/test/server";
const apiBaseUrl = "https://api.example.com";
function SessionProbe() {
const auth = useAuthSession();
const [loginFailed, setLoginFailed] = useState(false);
return (
<section>
<div>{auth.session === null ? "보호 콘텐츠 없음" : "보호 콘텐츠"}</div>
<div>{auth.session?.token ?? "토큰 없음"}</div>
{loginFailed ? <p role="status"> </p> : null}
{auth.loginNotice === null ? null : <p role="alert">{auth.loginNotice}</p>}
<button
onClick={() =>
void auth.login({ email: "admin@test.com", password: "password" }).catch(() => {
setLoginFailed(true);
})
}
type="button"
>
</button>
<button onClick={() => void auth.logout()} type="button">
</button>
</section>
);
}
function renderSession(onNavigateLogin = vi.fn()) {
const client = createApiClient({
getToken: () => authSessionStorage.read()?.token ?? null,
clearSession: authSessionStorage.remove,
onAuthExpired: onNavigateLogin,
});
render(
<AuthSessionProvider apiClient={client} onNavigateLogin={onNavigateLogin}>
<SessionProbe />
</AuthSessionProvider>,
);
return { onNavigateLogin };
}
afterEach(() => {
vi.unstubAllEnvs();
document.cookie = "auth=; Max-Age=0; path=/";
});
describe("auth session model", () => {
test("rejects stored session with empty token", () => {
// Given
sessionStorage.setItem(
"ai-character-admin-auth-session",
JSON.stringify({ token: "", role: "ADMIN" }),
);
// When, Then
expect(authSessionStorage.read()).toBeNull();
});
test("stores successful ADMIN login only in sessionStorage and restores it in the same tab", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const indexedDbOpen = vi.fn();
vi.stubGlobal("indexedDB", { open: indexedDbOpen });
const cookieBefore = document.cookie;
server.use(
http.post(`${apiBaseUrl}/admin/member/login`, () =>
HttpResponse.json({
success: true,
message: null,
data: { token: "jwt-token", role: "ADMIN" },
errorProperty: null,
}),
),
);
// When
renderSession();
screen.getByRole("button", { name: "로그인 실행" }).click();
// Then
await screen.findByText("jwt-token");
expect(authSessionStorage.read()).toEqual({ token: "jwt-token", role: "ADMIN" });
expect(localStorage).toHaveLength(0);
expect(indexedDbOpen).not.toHaveBeenCalled();
expect(document.cookie).toBe(cookieBefore);
// When
renderSession();
// Then
expect(screen.getAllByText("jwt-token")).toHaveLength(2);
});
test.each([
{ name: "empty token", data: { token: "", role: "ADMIN" } },
{ name: "missing token", data: { role: "ADMIN" } },
{ name: "missing role", data: { token: "jwt-token" } },
{ name: "non ADMIN role", data: { token: "jwt-token", role: "USER" } },
])("rejects $name without storing or exposing protected content", async ({ data }) => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
server.use(
http.post(`${apiBaseUrl}/admin/member/login`, () =>
HttpResponse.json({ success: true, message: null, data, errorProperty: null }),
),
);
// When
renderSession();
screen.getByRole("button", { name: "로그인 실행" }).click();
// Then
await waitFor(() => expect(authSessionStorage.read()).toBeNull());
expect(screen.queryByText("보호 콘텐츠", { exact: true })).not.toBeInTheDocument();
expect(screen.getByText("보호 콘텐츠 없음")).toBeInTheDocument();
});
test.each([
{ name: "success", response: "success" },
{ name: "non-2xx", response: "server-error" },
{ name: "network error", response: "network-error" },
])("removes local session and navigates to login after logout $name", async ({ response }) => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
authSessionStorage.save({ token: "logout-token", role: "ADMIN" });
const onNavigateLogin = vi.fn();
server.use(
http.post(`${apiBaseUrl}/member/logout`, () => {
if (response === "network-error") {
return HttpResponse.error();
}
if (response === "server-error") {
return HttpResponse.json(
{ success: false, message: "로그아웃 확인 실패", data: null, errorProperty: null },
{ status: 500 },
);
}
return HttpResponse.json({ success: true, message: null, data: {}, errorProperty: null });
}),
);
// When
renderSession(onNavigateLogin);
screen.getByRole("button", { name: "로그아웃 실행" }).click();
// Then
await waitFor(() => expect(authSessionStorage.read()).toBeNull());
expect(onNavigateLogin).toHaveBeenCalledExactlyOnceWith("/login");
expect(screen.getByText("보호 콘텐츠 없음")).toBeInTheDocument();
if (response === "success") {
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
} else {
expect(screen.getByRole("alert")).toHaveTextContent("서버 로그아웃 확인에 실패했습니다.");
}
});
test("coalesces duplicate logout clicks while the logout request is in flight", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
authSessionStorage.save({ token: "logout-token", role: "ADMIN" });
let logoutCount = 0;
let resolveLogoutReady: (finishLogout: () => void) => void = () => undefined;
const logoutReady = new Promise<() => void>((resolve) => {
resolveLogoutReady = resolve;
});
server.use(
http.post(`${apiBaseUrl}/member/logout`, () => {
logoutCount += 1;
return new Promise((resolve) => {
resolveLogoutReady(() => resolve(HttpResponse.json({ success: true, message: null, data: {}, errorProperty: null })));
});
}),
);
// When
renderSession();
const logoutButton = screen.getByRole("button", { name: "로그아웃 실행" });
logoutButton.click();
logoutButton.click();
// Then
await waitFor(() => expect(logoutCount).toBe(1));
const finishLogout = await logoutReady;
finishLogout();
await waitFor(() => expect(authSessionStorage.read()).toBeNull());
});
});

View File

@@ -0,0 +1,89 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, test, vi } from "vitest";
import { LoginPage } from "@/features/auth/pages/LoginPage";
describe("LoginPage", () => {
test("renders visible email and password labels", () => {
// Given, When
render(<LoginPage onSubmit={vi.fn()} />);
// Then
expect(screen.getByLabelText("이메일")).toHaveAttribute("type", "email");
expect(screen.getByLabelText("비밀번호")).toHaveAttribute("type", "password");
});
test("renders a login notice as an alert", () => {
// Given, When
render(<LoginPage notice="세션이 만료되었습니다. 다시 로그인하세요." onSubmit={vi.fn()} />);
// Then
expect(screen.getByRole("alert")).toHaveTextContent("세션이 만료되었습니다. 다시 로그인하세요.");
});
test("connects field errors and focuses the first invalid field", async () => {
// Given
render(<LoginPage onSubmit={vi.fn()} />);
// When
fireEvent.click(screen.getByRole("button", { name: "로그인" }));
// Then
const email = screen.getByLabelText("이메일");
const password = screen.getByLabelText("비밀번호");
const emailError = await screen.findByText("올바른 이메일을 입력하세요.");
const passwordError = screen.getByText("비밀번호를 입력하세요.");
expect(email).toHaveAccessibleDescription("올바른 이메일을 입력하세요.");
expect(password).toHaveAccessibleDescription("비밀번호를 입력하세요.");
expect(email).toHaveAttribute("aria-describedby", emailError.id);
expect(password).toHaveAttribute("aria-describedby", passwordError.id);
expect(email).toHaveFocus();
});
test("submits valid email and password", async () => {
// Given
const onSubmit = vi.fn<() => Promise<void>>(() => Promise.resolve());
render(<LoginPage onSubmit={onSubmit} />);
// When
fireEvent.change(screen.getByLabelText("이메일"), { target: { value: "admin@test.com" } });
fireEvent.change(screen.getByLabelText("비밀번호"), { target: { value: "password" } });
fireEvent.click(screen.getByRole("button", { name: "로그인" }));
// Then
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledExactlyOnceWith({
email: "admin@test.com",
password: "password",
}),
);
});
test("re-enables submit button when login submission fails", async () => {
// Given
const onSubmit = vi.fn<() => Promise<void>>(() => Promise.reject(new Error("login failed")));
render(<LoginPage onSubmit={onSubmit} />);
// When
fireEvent.change(screen.getByLabelText("이메일"), { target: { value: "admin@test.com" } });
fireEvent.change(screen.getByLabelText("비밀번호"), { target: { value: "password" } });
fireEvent.click(screen.getByRole("button", { name: "로그인" }));
// Then
await waitFor(() => expect(screen.getByRole("button", { name: "로그인" })).toBeEnabled());
});
test("shows the server Korean error message when login submission fails with one", async () => {
// Given
const onSubmit = vi.fn<() => Promise<void>>(() => Promise.reject(new Error("이메일 또는 비밀번호가 올바르지 않습니다.")));
render(<LoginPage onSubmit={onSubmit} />);
// When
fireEvent.change(screen.getByLabelText("이메일"), { target: { value: "admin@test.com" } });
fireEvent.change(screen.getByLabelText("비밀번호"), { target: { value: "password" } });
fireEvent.click(screen.getByRole("button", { name: "로그인" }));
// Then
expect(await screen.findByRole("alert")).toHaveTextContent("이메일 또는 비밀번호가 올바르지 않습니다.");
});
});

View File

@@ -1,7 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClientProvider } from "@tanstack/react-query";
import { App } from "@/app/App";
import { queryClient } from "@/shared/api/query-client";
import "@/styles/globals.css";
import { getRuntimeEnv } from "@/shared/config/env";
getRuntimeEnv();
@@ -14,6 +17,8 @@ if (!root) {
createRoot(root).render(
<StrictMode>
<App />
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
);

View File

@@ -0,0 +1,215 @@
import { http, HttpResponse } from "msw";
import { afterEach, describe, expect, test, vi } from "vitest";
import { AccessDeniedError } from "../api-error";
import { createApiClient } from "../client";
import { server } from "../../test/server";
import { apiBaseUrl, createTestClient, valueSchema } from "./client-test-helpers";
afterEach(() => {
vi.unstubAllEnvs();
});
describe("authenticated API requests", () => {
test("sends Korean language without bearer authentication to login", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client } = createTestClient();
let acceptLanguage: string | null = null;
let authorization: string | null = null;
server.use(
http.post(`${apiBaseUrl}/admin/member/login`, ({ request }) => {
acceptLanguage = request.headers.get("Accept-Language");
authorization = request.headers.get("Authorization");
return HttpResponse.json({ success: true, message: null, data: { value: "ok" } });
}),
);
// When
await client.request({
path: "/admin/member/login",
method: "POST",
headers: {
Authorization: "Bearer caller-supplied-token",
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "admin@test.com", password: "secret" }),
responseSchema: valueSchema,
authentication: "none",
});
// Then
expect(acceptLanguage).toBe("ko");
expect(authorization).toBeNull();
});
test("sends bearer authentication to a protected request", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client } = createTestClient();
let authorization: string | null = null;
server.use(
http.get(`${apiBaseUrl}/api/v2/protected`, ({ request }) => {
authorization = request.headers.get("Authorization");
return HttpResponse.json({ success: true, message: null, data: { value: "ok" } });
}),
);
// When
await client.request({
path: "/api/v2/protected",
responseSchema: valueSchema,
authentication: "required",
});
// Then
expect(authorization).toBe("Bearer header.payload.signature");
});
test("sends bearer authentication to logout", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client } = createTestClient();
let authorization: string | null = null;
server.use(
http.post(`${apiBaseUrl}/member/logout`, ({ request }) => {
authorization = request.headers.get("Authorization");
return HttpResponse.json({ success: true, message: null, data: { value: "ok" } });
}),
);
// When
await client.request({
path: "/member/logout",
method: "POST",
responseSchema: valueSchema,
authentication: "required",
});
// Then
expect(authorization).toBe("Bearer header.payload.signature");
});
test("clears the session and redirects once for each concurrent 401 burst", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const clearSession = vi.fn();
const onAuthExpired = vi.fn();
const client = createApiClient({
getToken: () => "header.payload.signature",
clearSession,
onAuthExpired,
});
server.use(
http.get(`${apiBaseUrl}/protected`, () =>
HttpResponse.json(
{
success: false,
message: "인증 정보가 없습니다.",
data: null,
errorProperty: null,
},
{ status: 401 },
),
),
);
// When
const firstBurst = await Promise.allSettled([
client.request({
path: "/protected",
responseSchema: valueSchema,
authentication: "required",
}),
client.request({
path: "/protected",
responseSchema: valueSchema,
authentication: "required",
}),
]);
const secondBurst = await Promise.allSettled([
client.request({
path: "/protected",
responseSchema: valueSchema,
authentication: "required",
}),
client.request({
path: "/protected",
responseSchema: valueSchema,
authentication: "required",
}),
]);
// Then
expect(firstBurst).toHaveLength(2);
expect(secondBurst).toHaveLength(2);
expect(clearSession).toHaveBeenCalledTimes(2);
expect(onAuthExpired).toHaveBeenCalledTimes(2);
});
test("surfaces access denied without clearing the session", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client, clearSession, onAuthExpired } = createTestClient();
server.use(
http.get(`${apiBaseUrl}/protected`, () =>
HttpResponse.json(
{
success: false,
message: "접근 권한이 없습니다.",
data: null,
errorProperty: null,
},
{ status: 403 },
),
),
);
// When
const request = client.request({
path: "/protected",
responseSchema: valueSchema,
authentication: "required",
});
// Then
await expect(request).rejects.toBeInstanceOf(AccessDeniedError);
expect(clearSession).not.toHaveBeenCalled();
expect(onAuthExpired).not.toHaveBeenCalled();
});
test("does not write sensitive request data to the console", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client } = createTestClient("jwt.secret.value");
const multipartBody = new FormData();
multipartBody.append("request", JSON.stringify({ password: "secret-password" }));
multipartBody.append("file", new File(["private-content"], "private.txt"));
const consoleDebug = vi.spyOn(console, "debug");
const consoleError = vi.spyOn(console, "error");
const consoleInfo = vi.spyOn(console, "info");
const consoleLog = vi.spyOn(console, "log");
const consoleWarn = vi.spyOn(console, "warn");
server.use(
http.post(`${apiBaseUrl}/api/v2/protected`, () =>
HttpResponse.json({ success: true, message: null, data: { value: "ok" } }),
),
);
// When
await client.request({
path: "/api/v2/protected?signedUrl=https%3A%2F%2Fcdn.example.com%2Fprivate%3Fsignature%3Dabc",
method: "POST",
body: multipartBody,
responseSchema: valueSchema,
authentication: "required",
});
// Then
expect(consoleDebug).not.toHaveBeenCalled();
expect(consoleError).not.toHaveBeenCalled();
expect(consoleInfo).not.toHaveBeenCalled();
expect(consoleLog).not.toHaveBeenCalled();
expect(consoleWarn).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,31 @@
import { vi } from "vitest";
import { z } from "zod";
import { createApiClient } from "../client";
export const apiBaseUrl = "https://api.example.com";
export const valueSchema = z.object({ value: z.string() });
export type TestClient = {
readonly client: ReturnType<typeof createApiClient>;
readonly clearSession: ReturnType<typeof vi.fn>;
readonly onAuthExpired: ReturnType<typeof vi.fn>;
};
export function createTestClient(token = "header.payload.signature"): TestClient {
let currentToken: string | null = token;
const clearSession = vi.fn(() => {
currentToken = null;
});
const onAuthExpired = vi.fn();
return {
client: createApiClient({
getToken: () => currentToken,
clearSession,
onAuthExpired,
}),
clearSession,
onAuthExpired,
};
}

View File

@@ -0,0 +1,117 @@
import { http, HttpResponse } from "msw";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ApiError } from "../api-error";
import { server } from "../../test/server";
import { apiBaseUrl, createTestClient, valueSchema } from "./client-test-helpers";
afterEach(() => {
vi.unstubAllEnvs();
});
describe("API client", () => {
test("accepts a successful envelope without errorProperty", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client } = createTestClient();
server.use(
http.get(`${apiBaseUrl}/public`, () =>
HttpResponse.json({ success: true, message: null, data: { value: "ok" } }),
),
);
// When
const response = await client.request({
path: "/public",
responseSchema: valueSchema,
authentication: "none",
});
// Then
expect(response).toEqual({ value: "ok" });
});
test("accepts a successful envelope with null errorProperty", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client } = createTestClient();
server.use(
http.get(`${apiBaseUrl}/public`, () =>
HttpResponse.json({
success: true,
message: null,
data: { value: "ok" },
errorProperty: null,
}),
),
);
// When
const response = await client.request({
path: "/public",
responseSchema: valueSchema,
authentication: "none",
});
// Then
expect(response).toEqual({ value: "ok" });
});
test.each([400, 404, 405, 415, 500])(
"preserves a Korean server error envelope for status %i",
async (status) => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client } = createTestClient();
const message = `서버 오류 ${status}`;
const errorProperty = "request";
server.use(
http.get(`${apiBaseUrl}/error`, () =>
HttpResponse.json(
{ success: false, message, data: null, errorProperty },
{ status },
),
),
);
// When
const request = client.request({
path: "/error",
responseSchema: valueSchema,
authentication: "none",
});
// Then
await expect(request).rejects.toMatchObject({ status, message, errorProperty });
},
);
test("exposes server errors as ApiError", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client } = createTestClient();
server.use(
http.get(`${apiBaseUrl}/error`, () =>
HttpResponse.json(
{
success: false,
message: "잘못된 요청입니다.",
data: null,
errorProperty: null,
},
{ status: 400 },
),
),
);
// When
const request = client.request({
path: "/error",
responseSchema: valueSchema,
authentication: "none",
});
// Then
await expect(request).rejects.toBeInstanceOf(ApiError);
});
});

View File

@@ -0,0 +1,66 @@
import { describe, expect, test } from "vitest";
import { createPageParams, type PageData } from "../pagination";
describe("createPageParams", () => {
test("uses documented default page and size", () => {
// Given
const request = {};
// When
const pageParams = createPageParams(request);
// Then
expect(pageParams).toEqual({ page: 0, size: 20 });
});
test("clamps size to the documented lower bound", () => {
// Given
const request = { size: 1 };
// When
const pageParams = createPageParams(request);
// Then
expect(pageParams.size).toBe(20);
});
test("clamps size to the documented upper bound", () => {
// Given
const request = { size: 51 };
// When
const pageParams = createPageParams(request);
// Then
expect(pageParams.size).toBe(50);
});
test("keeps a provided page unchanged", () => {
// Given
const request = { page: 3, size: 20 };
// When
const pageParams = createPageParams(request);
// Then
expect(pageParams.page).toBe(3);
});
test("defines the documented page response shape", () => {
// Given
const page: PageData<string> = {
totalCount: 1,
page: 0,
size: 20,
hasNext: false,
items: ["루나"],
};
// When
const firstItem = page.items[0];
// Then
expect(firstItem).toBe("루나");
});
});

View File

@@ -0,0 +1,58 @@
import { describe, expect, test } from "vitest";
import { ApiError } from "../api-error";
import { createQueryClient, shouldRetryQuery } from "../query-client";
describe("TanStack Query defaults", () => {
test("does not retry unauthenticated query errors", () => {
// Given
const error = new ApiError({
status: 401,
message: "인증 정보가 없습니다.",
errorProperty: null,
});
// When
const shouldRetry = shouldRetryQuery(0, error);
// Then
expect(shouldRetry).toBe(false);
});
test("does not retry access-denied query errors", () => {
// Given
const error = new ApiError({
status: 403,
message: "접근 권한이 없습니다.",
errorProperty: null,
});
// When
const shouldRetry = shouldRetryQuery(0, error);
// Then
expect(shouldRetry).toBe(false);
});
test("limits retries for other query errors", () => {
// Given
const error = new Error("network failure");
// When
const shouldRetry = shouldRetryQuery(2, error);
// Then
expect(shouldRetry).toBe(false);
});
test("disables mutation retries", () => {
// Given
const queryClient = createQueryClient();
// When
const retry = queryClient.getDefaultOptions().mutations?.retry;
// Then
expect(retry).toBe(false);
});
});

View File

@@ -0,0 +1,21 @@
export type ApiErrorOptions = {
readonly status: number;
readonly message: string;
readonly errorProperty: string | null;
};
export class ApiError extends Error {
override readonly name: string = "ApiError";
readonly status: number;
readonly errorProperty: string | null;
constructor(options: ApiErrorOptions) {
super(options.message);
this.status = options.status;
this.errorProperty = options.errorProperty;
}
}
export class AccessDeniedError extends ApiError {
override readonly name: string = "AccessDeniedError";
}

131
src/shared/api/client.ts Normal file
View File

@@ -0,0 +1,131 @@
import type { z } from "zod";
import { AccessDeniedError, ApiError } from "./api-error";
import { getRuntimeEnv } from "../config/env";
import { createApiResponseSchema } from "./types";
type AuthenticationMode = "none" | "required";
export type ApiClientDependencies = {
readonly getToken: () => string | null;
readonly clearSession: () => void;
readonly onAuthExpired: () => void;
};
export type ApiRequestOptions<Data> = {
readonly path: string;
readonly responseSchema: z.ZodType<Data>;
readonly authentication: AuthenticationMode;
readonly method?: string;
readonly headers?: HeadersInit;
readonly body?: BodyInit | null;
};
export type ApiClient = {
readonly request: <Data>(options: ApiRequestOptions<Data>) => Promise<Data>;
};
function toApiError(
status: number,
response: {
readonly message: string;
readonly errorProperty: string | null;
},
): ApiError {
if (status === 403) {
return new AccessDeniedError({
status,
message: response.message,
errorProperty: response.errorProperty,
});
}
return new ApiError({
status,
message: response.message,
errorProperty: response.errorProperty,
});
}
export function createApiClient(dependencies: ApiClientDependencies): ApiClient {
let hasHandledAuthenticationExpiry = false;
let activeProtectedRequestCount = 0;
return {
async request<Data>(options: ApiRequestOptions<Data>): Promise<Data> {
const isProtectedRequest = options.authentication === "required";
if (isProtectedRequest) {
activeProtectedRequestCount += 1;
}
try {
const headers = new Headers(options.headers);
headers.set("Accept-Language", "ko");
headers.delete("Authorization");
if (isProtectedRequest) {
const token = dependencies.getToken();
if (token !== null) {
headers.set("Authorization", `Bearer ${token}`);
}
}
const init: RequestInit = { headers };
if (options.method !== undefined) {
init.method = options.method;
}
if (options.body !== undefined) {
init.body = options.body;
}
const response = await fetch(new URL(options.path, getRuntimeEnv().apiBaseUrl), init);
const parsedResponse = createApiResponseSchema(options.responseSchema).safeParse(
await response.json(),
);
if (!parsedResponse.success) {
throw new ApiError({
status: response.status,
message: "API 응답 형식이 올바르지 않습니다.",
errorProperty: null,
});
}
const apiResponse = parsedResponse.data;
if (response.ok && apiResponse.success) {
return apiResponse.data;
}
if (!apiResponse.success) {
if (response.status === 401 && isProtectedRequest) {
if (!hasHandledAuthenticationExpiry) {
hasHandledAuthenticationExpiry = true;
dependencies.clearSession();
dependencies.onAuthExpired();
}
}
throw toApiError(response.status, apiResponse);
}
throw new ApiError({
status: response.status,
message: "API 오류 응답 형식이 올바르지 않습니다.",
errorProperty: null,
});
} finally {
if (isProtectedRequest) {
activeProtectedRequestCount -= 1;
if (activeProtectedRequestCount === 0) {
hasHandledAuthenticationExpiry = false;
}
}
}
},
};
}

View File

@@ -0,0 +1,24 @@
export type PageData<Item> = {
readonly totalCount: number;
readonly page: number;
readonly size: number;
readonly hasNext: boolean;
readonly items: readonly Item[];
};
export type PageParams = {
readonly page?: number;
readonly size?: number;
};
export function createPageParams(params: PageParams = {}): {
readonly page: number;
readonly size: number;
} {
const size = params.size ?? 20;
return {
page: params.page ?? 0,
size: Math.min(Math.max(size, 20), 50),
};
}

View File

@@ -0,0 +1,22 @@
import { QueryClient } from "@tanstack/react-query";
import { ApiError } from "./api-error";
export function shouldRetryQuery(failureCount: number, error: unknown): boolean {
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
return false;
}
return failureCount < 2;
}
export function createQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { retry: shouldRetryQuery },
mutations: { retry: false },
},
});
}
export const queryClient = createQueryClient();

34
src/shared/api/types.ts Normal file
View File

@@ -0,0 +1,34 @@
import { z } from "zod";
export type ApiSuccessResponse<Data> = {
readonly success: true;
readonly message: null;
readonly data: Data;
readonly errorProperty?: null;
};
export type ApiErrorResponse = {
readonly success: false;
readonly message: string;
readonly data: null;
readonly errorProperty: string | null;
};
export type ApiResponse<Data> = ApiSuccessResponse<Data> | ApiErrorResponse;
export function createApiResponseSchema<Data>(dataSchema: z.ZodType<Data>) {
return z.discriminatedUnion("success", [
z.object({
success: z.literal(true),
message: z.null(),
data: dataSchema,
errorProperty: z.null().optional(),
}),
z.object({
success: z.literal(false),
message: z.string(),
data: z.null(),
errorProperty: z.string().nullable(),
}),
]);
}

View File

@@ -0,0 +1,12 @@
import { expect, test } from "vitest";
import { formatCanAmount, formatSeoulDateTime } from "@/shared/lib/formatters";
test("formatSeoulDateTime displays UTC input in Asia/Seoul", () => {
expect(formatSeoulDateTime("2026-07-25T15:30:00.000Z")).toBe("2026. 07. 26. 00:30");
});
test("formatCanAmount displays non-negative integer can units without domain status labels", () => {
expect(formatCanAmount(0)).toBe("0캔");
expect(formatCanAmount(12345)).toBe("12,345캔");
});

View File

@@ -0,0 +1,103 @@
import { expect, test, vi } from "vitest";
import { calculateCropOutputSize, calculateCropSourceRect, createCroppedImageFile } from "@/shared/lib/crop-image";
type RenderedCrop = {
readonly bottomAlpha: number;
readonly height: number;
readonly sourceHeight: number;
readonly sourceWidth: number;
readonly sourceX: number;
readonly sourceY: number;
readonly topAlpha: number;
readonly width: number;
};
function restoreDescriptor(property: "getContext" | "toBlob", descriptor: PropertyDescriptor | undefined): void {
if (descriptor === undefined) {
Reflect.deleteProperty(HTMLCanvasElement.prototype, property);
return;
}
Object.defineProperty(HTMLCanvasElement.prototype, property, descriptor);
}
test("calculateCropOutputSize caps width at maxWidth and keeps aspect height", () => {
expect(calculateCropOutputSize({ aspect: 1, maxWidth: 800, noUpscale: true, sourceHeight: 600, sourceWidth: 1200 })).toEqual({ height: 600, width: 600 });
expect(calculateCropOutputSize({ aspect: 210 / 297, maxWidth: 1000, noUpscale: true, sourceHeight: 1600, sourceWidth: 1200 })).toEqual({ height: 1414, width: 1000 });
});
test("calculateCropOutputSize never upscales when noUpscale is true", () => {
expect(calculateCropOutputSize({ aspect: 2, maxWidth: 800, noUpscale: true, sourceHeight: 800, sourceWidth: 600 })).toEqual({ height: 300, width: 600 });
expect(calculateCropOutputSize({ aspect: 2, maxWidth: 800, noUpscale: false, sourceHeight: 800, sourceWidth: 600 })).toEqual({ height: 400, width: 800 });
});
test("calculateCropSourceRect crops the largest centered source rectangle for the requested aspect", () => {
expect(calculateCropSourceRect({ aspect: 1, offsetX: 0, offsetY: 0, sourceHeight: 600, sourceWidth: 1200, zoom: 1 })).toEqual({ height: 600, sourceX: 300, sourceY: 0, width: 600 });
expect(calculateCropSourceRect({ aspect: 1, offsetX: 10, offsetY: -20, sourceHeight: 600, sourceWidth: 1200, zoom: 1 })).toEqual({ height: 600, sourceX: 290, sourceY: 0, width: 600 });
});
test("createCroppedImageFile keeps a wide no-upscale crop opaque at the top and bottom", async () => {
let renderedCrop: RenderedCrop | null = null;
const imageFile = new File(["wide"], "wide.png", { type: "image/png" });
const outputSize = calculateCropOutputSize({ aspect: 1, maxWidth: 800, noUpscale: true, sourceHeight: 600, sourceWidth: 1200 });
const originalGetContext = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, "getContext");
const originalToBlob = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, "toBlob");
try {
Object.defineProperty(HTMLCanvasElement.prototype, "getContext", {
configurable: true,
value: () => ({
drawImage: (...args: readonly unknown[]) => {
const [sourceImage, sourceX, sourceY, sourceWidth, sourceHeight, targetX, targetY, targetWidth, targetHeight] = args;
if (!(sourceImage instanceof EventTarget) || typeof sourceX !== "number" || typeof sourceY !== "number" || typeof sourceWidth !== "number" || typeof sourceHeight !== "number" || typeof targetX !== "number" || typeof targetY !== "number" || typeof targetWidth !== "number" || typeof targetHeight !== "number") {
throw new Error("Unexpected crop render call");
}
renderedCrop = {
bottomAlpha: sourceY + sourceHeight <= 600 && targetY + targetHeight <= outputSize.height ? 255 : 0,
height: targetHeight,
sourceHeight,
sourceWidth,
sourceX,
sourceY,
topAlpha: sourceY >= 0 && targetY === 0 ? 255 : 0,
width: targetWidth,
};
},
}),
});
Object.defineProperty(HTMLCanvasElement.prototype, "toBlob", {
configurable: true,
value: (callback: BlobCallback) => {
callback(new Blob([JSON.stringify(renderedCrop)], { type: imageFile.type }));
},
});
vi.stubGlobal(
"Image",
class FakeImage extends EventTarget {
set src(_value: string) {
queueMicrotask(() => this.dispatchEvent(new Event("load")));
}
},
);
const croppedFile = await createCroppedImageFile({
aspect: 1,
file: imageFile,
offsetX: 0,
offsetY: 0,
outputHeight: outputSize.height,
outputWidth: outputSize.width,
previewUrl: "blob:wide",
sourceHeight: 600,
sourceWidth: 1200,
zoom: 1,
});
await expect(croppedFile.text()).resolves.toBe(JSON.stringify({ bottomAlpha: 255, height: 600, sourceHeight: 600, sourceWidth: 600, sourceX: 300, sourceY: 0, topAlpha: 255, width: 600 }));
} finally {
restoreDescriptor("getContext", originalGetContext);
restoreDescriptor("toBlob", originalToBlob);
}
});

View File

@@ -0,0 +1,101 @@
export type CropOutputSizeRequest = {
readonly aspect: number | "free";
readonly maxWidth: number;
readonly noUpscale: boolean;
readonly sourceHeight: number;
readonly sourceWidth: number;
};
export type CropOutputSize = {
readonly height: number;
readonly width: number;
};
export type CropRenderRequest = {
readonly aspect: number | "free";
readonly file: File;
readonly offsetX: number;
readonly offsetY: number;
readonly outputHeight: number;
readonly outputWidth: number;
readonly previewUrl: string;
readonly sourceHeight: number;
readonly sourceWidth: number;
readonly zoom: number;
};
export type CropSourceRectRequest = {
readonly aspect: number | "free";
readonly offsetX: number;
readonly offsetY: number;
readonly sourceHeight: number;
readonly sourceWidth: number;
readonly zoom: number;
};
export type CropSourceRect = {
readonly height: number;
readonly sourceX: number;
readonly sourceY: number;
readonly width: number;
};
function getAspect(aspect: number | "free", sourceWidth: number, sourceHeight: number): number {
return aspect === "free" ? sourceWidth / sourceHeight : aspect;
}
export function calculateCropSourceRect({ aspect, offsetX, offsetY, sourceHeight, sourceWidth, zoom }: CropSourceRectRequest): CropSourceRect {
const cropAspect = getAspect(aspect, sourceWidth, sourceHeight);
const sourceAspect = sourceWidth / sourceHeight;
const baseWidth = sourceAspect > cropAspect ? Math.round(sourceHeight * cropAspect) : sourceWidth;
const baseHeight = sourceAspect > cropAspect ? sourceHeight : Math.round(sourceWidth / cropAspect);
const width = Math.round(baseWidth / zoom);
const height = Math.round(baseHeight / zoom);
const maxSourceX = sourceWidth - width;
const maxSourceY = sourceHeight - height;
const centeredX = Math.round((sourceWidth - width) / 2 - offsetX / zoom);
const centeredY = Math.round((sourceHeight - height) / 2 - offsetY / zoom);
return {
height,
sourceX: Math.min(Math.max(centeredX, 0), maxSourceX),
sourceY: Math.min(Math.max(centeredY, 0), maxSourceY),
width,
};
}
export function calculateCropOutputSize({ aspect, maxWidth, noUpscale, sourceHeight, sourceWidth }: CropOutputSizeRequest): CropOutputSize {
const cropAspect = getAspect(aspect, sourceWidth, sourceHeight);
const cropRect = calculateCropSourceRect({ aspect, offsetX: 0, offsetY: 0, sourceHeight, sourceWidth, zoom: 1 });
const width = noUpscale ? Math.min(maxWidth, cropRect.width) : maxWidth;
return { height: Math.round(width / cropAspect), width };
}
export function createCroppedImageFile(request: CropRenderRequest): Promise<File> {
return new Promise((resolve, reject) => {
const image = new Image();
image.addEventListener("load", () => {
const canvas = document.createElement("canvas");
canvas.width = request.outputWidth;
canvas.height = request.outputHeight;
const context = canvas.getContext("2d");
if (context === null) {
reject(new Error("Canvas context unavailable"));
return;
}
const cropRect = calculateCropSourceRect(request);
context.drawImage(image, cropRect.sourceX, cropRect.sourceY, cropRect.width, cropRect.height, 0, 0, request.outputWidth, request.outputHeight);
canvas.toBlob((blob) => {
if (blob === null) {
reject(new Error("Canvas result unavailable"));
return;
}
resolve(new File([blob], request.file.name, { type: request.file.type }));
}, request.file.type);
});
image.addEventListener("error", () => reject(new Error("Image preview unavailable")));
image.src = request.previewUrl;
});
}

View File

@@ -0,0 +1,23 @@
const seoulDateTimeFormatter = new Intl.DateTimeFormat("ko-KR", {
day: "2-digit",
hour: "2-digit",
hourCycle: "h23",
minute: "2-digit",
month: "2-digit",
timeZone: "Asia/Seoul",
year: "numeric",
});
const canAmountFormatter = new Intl.NumberFormat("ko-KR", {
maximumFractionDigits: 0,
});
export function formatSeoulDateTime(utcDateTime: string | Date): string {
const parts = Object.fromEntries(seoulDateTimeFormatter.formatToParts(new Date(utcDateTime)).map((part) => [part.type, part.value]));
return `${parts.year}. ${parts.month}. ${parts.day}. ${parts.hour}:${parts.minute}`;
}
export function formatCanAmount(amount: number): string {
return `${canAmountFormatter.format(Math.max(0, Math.trunc(amount)))}`;
}

View File

@@ -0,0 +1,3 @@
import { setupServer } from "msw/node";
export const server = setupServer();

View File

@@ -0,0 +1,11 @@
import { expect, test, vi } from "vitest";
test("test setup can stub a global in one test", () => {
vi.stubGlobal("indexedDB", { open: vi.fn() });
expect(indexedDB.open).toBeDefined();
});
test("test setup restores stubbed globals before the next test", () => {
expect("indexedDB" in globalThis).toBe(false);
});

View File

@@ -1,10 +1,23 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";
import { afterAll, afterEach, beforeAll, vi } from "vitest";
import { server } from "./server";
beforeAll(() => {
server.listen({ onUnhandledRequest: "error" });
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.restoreAllMocks();
vi.unstubAllGlobals();
sessionStorage.clear();
localStorage.clear();
server.resetHandlers();
});
afterAll(() => {
server.close();
});

View File

@@ -0,0 +1,117 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { AdminAudioPlayer } from "@/shared/ui/admin-audio-player";
import { AudioPlaybackProvider } from "@/shared/ui/audio-playback-provider";
let playSpy: ReturnType<typeof vi.spyOn>;
let pauseSpy: ReturnType<typeof vi.spyOn>;
let loadSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
playSpy = vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined);
pauseSpy = vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => undefined);
loadSpy = vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => undefined);
});
afterEach(() => {
playSpy.mockRestore();
pauseSpy.mockRestore();
loadSpy.mockRestore();
});
test("AdminAudioPlayer wraps native audio with controls and no download or autoplay", () => {
render(<AdminAudioPlayer playerId="one" src="https://cdn.example.com/signed/audio.m4a?token=secret" title="샘플 오디오" />);
const audio = document.querySelector("audio");
expect(audio).toHaveAttribute("src", "https://cdn.example.com/signed/audio.m4a?token=secret");
expect(audio).not.toHaveAttribute("autoplay");
expect(audio).toHaveAttribute("controlsList", "nodownload");
expect(screen.getByRole("button", { name: "재생" })).toBeInTheDocument();
expect(screen.getByRole("slider", { name: "재생 위치" })).toBeInTheDocument();
expect(screen.getByRole("slider", { name: "볼륨" })).toBeInTheDocument();
expect(screen.getByRole("combobox", { name: "재생 속도" })).toBeInTheDocument();
});
test("AudioPlaybackProvider keeps only one player active by player id", () => {
render(
<AudioPlaybackProvider>
<AdminAudioPlayer playerId="one" src="https://cdn.example.com/signed/one.m4a?token=secret-one" title="첫 번째" />
<AdminAudioPlayer playerId="two" src="https://cdn.example.com/signed/two.m4a?token=secret-two" title="두 번째" />
</AudioPlaybackProvider>,
);
const playButtons = screen.getAllByRole("button", { name: "재생" });
const firstPlayButton = playButtons[0];
const secondPlayButton = playButtons[1];
if (firstPlayButton === undefined || secondPlayButton === undefined) {
throw new Error("expected two play buttons");
}
fireEvent.click(firstPlayButton);
fireEvent.click(secondPlayButton);
expect(playSpy).toHaveBeenCalledTimes(2);
expect(pauseSpy).toHaveBeenCalled();
});
test("AdminAudioPlayer supports keyboard play, seek, volume, speed, generic error, and manual retry only", () => {
render(<AdminAudioPlayer playerId="one" src="https://cdn.example.com/signed/audio.m4a?token=secret" title="샘플 오디오" />);
const player = screen.getByRole("group", { name: "샘플 오디오 오디오 플레이어" });
fireEvent.keyDown(player, { key: " " });
fireEvent.change(screen.getByRole("slider", { name: "재생 위치" }), { target: { value: "12" } });
fireEvent.change(screen.getByRole("slider", { name: "볼륨" }), { target: { value: "0.5" } });
fireEvent.change(screen.getByRole("combobox", { name: "재생 속도" }), { target: { value: "1.5" } });
const audio = document.querySelector("audio");
if (!(audio instanceof HTMLAudioElement)) {
throw new Error("expected native audio element");
}
fireEvent.error(audio);
expect(playSpy).toHaveBeenCalledTimes(1);
expect(screen.getByRole("alert")).toHaveTextContent("오디오를 재생할 수 없습니다");
fireEvent.click(screen.getByRole("button", { name: "오디오 다시 시도" }));
expect(loadSpy).toHaveBeenCalledTimes(1);
expect(playSpy).toHaveBeenCalledTimes(1);
});
test("AdminAudioPlayer ignores Enter and Space from descendant controls", () => {
render(<AdminAudioPlayer playerId="one" src="https://cdn.example.com/signed/audio.m4a?token=secret" title="샘플 오디오" />);
fireEvent.keyDown(screen.getByRole("combobox", { name: "재생 속도" }), { key: "Enter" });
fireEvent.keyDown(screen.getByRole("button", { name: "재생" }), { key: " " });
expect(playSpy).not.toHaveBeenCalled();
});
test("AudioPlaybackProvider and AdminAudioPlayer do not log or persist signed URLs", () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
const localStorageSpy = vi.spyOn(Storage.prototype, "setItem");
const signedUrl = "https://cdn.example.com/signed/audio.m4a?token=secret";
render(
<AudioPlaybackProvider>
<AdminAudioPlayer playerId="one" src={signedUrl} title="샘플 오디오" />
</AudioPlaybackProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "재생" }));
const audio = document.querySelector("audio");
if (!(audio instanceof HTMLAudioElement)) {
throw new Error("expected native audio element");
}
fireEvent.error(audio);
expect(logSpy).not.toHaveBeenCalled();
expect(warnSpy).not.toHaveBeenCalled();
expect(errorSpy).not.toHaveBeenCalled();
expect(localStorageSpy).not.toHaveBeenCalledWith(expect.any(String), expect.stringContaining(signedUrl));
logSpy.mockRestore();
warnSpy.mockRestore();
errorSpy.mockRestore();
localStorageSpy.mockRestore();
});

View File

@@ -0,0 +1,60 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useState } from "react";
import { expect, test, vi } from "vitest";
import { ConfirmDeactivateDialog } from "@/shared/ui/confirm-deactivate-dialog";
test("ConfirmDeactivateDialog confirms deactivation with target and impact copy, not a switch", () => {
const onCancel = vi.fn();
const onConfirm = vi.fn();
render(
<ConfirmDeactivateDialog
impactDescription="사용자는 이 캐릭터를 더 이상 선택할 수 없습니다."
onCancel={onCancel}
onConfirm={onConfirm}
open
targetName="루나"
/>,
);
expect(screen.getByRole("alertdialog", { name: "루나 비활성화 확인" })).toHaveTextContent("사용자는 이 캐릭터를 더 이상 선택할 수 없습니다.");
expect(screen.queryByRole("switch")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "취소" }));
fireEvent.click(screen.getByRole("button", { name: "비활성화" }));
expect(onCancel).toHaveBeenCalledTimes(1);
expect(onConfirm).toHaveBeenCalledTimes(1);
});
test("ConfirmDeactivateDialog traps focus and returns it to the trigger after cancel", async () => {
function Harness() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)} type="button">
</button>
<ConfirmDeactivateDialog impactDescription="사용자는 이 캐릭터를 더 이상 선택할 수 없습니다." onCancel={() => setOpen(false)} onConfirm={() => setOpen(false)} open={open} targetName="루나" />
</>
);
}
render(<Harness />);
const trigger = screen.getByRole("button", { name: "비활성화 열기" });
trigger.focus();
fireEvent.click(trigger);
const cancel = screen.getByRole("button", { name: "취소" });
const confirm = screen.getByRole("button", { name: "비활성화" });
await waitFor(() => expect(cancel).toHaveFocus());
fireEvent.keyDown(screen.getByRole("alertdialog", { name: "루나 비활성화 확인" }), { key: "Tab", shiftKey: true });
expect(confirm).toHaveFocus();
fireEvent.keyDown(screen.getByRole("alertdialog", { name: "루나 비활성화 확인" }), { key: "Tab" });
expect(cancel).toHaveFocus();
fireEvent.click(cancel);
await waitFor(() => expect(trigger).toHaveFocus());
});

View File

@@ -0,0 +1,41 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { FileField } from "@/shared/ui/file-field";
test("FileField exposes label, description, error, accept guidance, keyboard file input, and controlled value", () => {
const onChange = vi.fn();
const value = new File(["image"], "profile.png", { type: "image/png" });
render(
<FileField
accept="image/png"
acceptDescription="PNG만 업로드할 수 있습니다."
description="프로필 이미지를 선택하세요."
error="파일이 너무 큽니다."
label="대표 이미지"
onChange={onChange}
value={value}
/>,
);
const input = screen.getByLabelText("대표 이미지");
expect(input).toHaveAttribute("accept", "image/png");
expect(input).toHaveAttribute("aria-invalid", "true");
expect(input).toHaveAccessibleDescription("프로필 이미지를 선택하세요. PNG만 업로드할 수 있습니다. 파일이 너무 큽니다.");
expect(screen.getByText("profile.png")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "선택 취소" })).toBeInTheDocument();
});
test("FileField emits File or null and clear selection without owning upload policy", () => {
const onChange = vi.fn();
const selected = new File(["audio"], "voice.mp3", { type: "audio/mpeg" });
const { rerender } = render(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={onChange} value={null} />);
fireEvent.change(screen.getByLabelText("오디오"), { target: { files: [selected] } });
expect(onChange).toHaveBeenCalledWith(selected);
rerender(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={onChange} value={selected} />);
fireEvent.click(screen.getByRole("button", { name: "선택 취소" }));
expect(onChange).toHaveBeenCalledWith(null);
});

View File

@@ -0,0 +1,28 @@
import { readFile } from "node:fs/promises";
import { expect, test } from "vitest";
const sharedFileMediaFiles = [
"src/shared/validation/file-validation.ts",
"src/shared/validation/audio-file-policy.ts",
"src/shared/validation/image-policy.ts",
"src/shared/lib/crop-image.ts",
"src/shared/ui/file-field.tsx",
"src/shared/ui/image-crop-dialog.tsx",
"src/shared/ui/upload-progress.tsx",
"src/shared/ui/admin-audio-player.tsx",
"src/shared/ui/audio-playback-provider.tsx",
"src/shared/ui/audio-playback-context.ts",
"src/shared/ui/use-audio-playback.ts",
] as const;
test("shared file media primitives do not import endpoints, query cache, or domain DTOs", async () => {
const contents = await Promise.all(sharedFileMediaFiles.map((filePath) => readFile(filePath, "utf8")));
for (const content of contents) {
expect(content).not.toMatch(/@\/features\//);
expect(content).not.toMatch(/@tanstack\/react-query/);
expect(content).not.toMatch(/@\/shared\/api/);
expect(content).not.toMatch(/endpoint|DTO/iu);
}
});

View File

@@ -0,0 +1,21 @@
import { render, screen } from "@testing-library/react";
import { IconOnlyAction } from "@/shared/ui/icon-only-action";
describe("IconOnlyAction", () => {
test("has an accessible name and a tooltip without duplicating the name as description", () => {
render(
<IconOnlyAction label="새로고침">
<span aria-hidden="true">R</span>
</IconOnlyAction>,
);
const button = screen.getByRole("button", { name: "새로고침" });
const tooltip = screen.getByRole("tooltip");
expect(tooltip).toHaveTextContent("새로고침");
expect(button).not.toHaveAccessibleDescription("새로고침");
expect(button).not.toHaveAttribute("aria-describedby");
expect(button).toHaveAttribute("type", "button");
});
});

View File

@@ -0,0 +1,68 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { ImageCropDialog } from "@/shared/ui/image-crop-dialog";
import type { CropRenderRequest } from "@/shared/lib/crop-image";
const image = {
file: new File(["image"], "profile.png", { type: "image/png" }),
height: 600,
previewUrl: "blob:profile",
width: 600,
};
test("ImageCropDialog provides move, zoom, reset, preview, cancel, and apply controls", async () => {
const onApply = vi.fn();
const onCancel = vi.fn();
const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([String(request.zoom)], "crop.png", { type: "image/png" })));
render(<ImageCropDialog image={image} onApply={onApply} onCancel={onCancel} open policy={{ aspect: 1, maxWidth: 800, noUpscale: true }} renderCrop={renderCrop} />);
fireEvent.click(screen.getByRole("button", { name: "오른쪽으로 이동" }));
fireEvent.change(screen.getByRole("slider", { name: "확대 비율" }), { target: { value: "1.5" } });
expect(screen.getByText("예상 결과 600 × 600px")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "초기화" }));
fireEvent.click(screen.getByRole("button", { name: "적용" }));
await screen.findByText("예상 결과 600 × 600px");
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetX: 0, offsetY: 0, outputHeight: 600, outputWidth: 600, zoom: 1 }));
expect(onApply).toHaveBeenCalledWith(expect.any(File));
fireEvent.click(screen.getByRole("button", { name: "취소" }));
expect(onCancel).toHaveBeenCalled();
});
test("ImageCropDialog supports keyboard movement and no-upscale sizing", async () => {
const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([`${request.offsetX}`], "crop.png", { type: "image/png" })));
render(<ImageCropDialog image={image} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: 2, maxWidth: 800, noUpscale: true }} renderCrop={renderCrop} />);
const preview = screen.getByRole("application", { name: "이미지 crop 미리보기" });
fireEvent.keyDown(preview, { key: "ArrowRight" });
fireEvent.keyDown(preview, { key: "+" });
fireEvent.click(screen.getByRole("button", { name: "적용" }));
expect(await screen.findByText("예상 결과 600 × 300px")).toBeInTheDocument();
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetX: 10, outputHeight: 300, outputWidth: 600, zoom: 1.1 }));
});
test("ImageCropDialog supports free ratio output and pointer drag movement", async () => {
const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([`${request.offsetX},${request.offsetY}`], "crop.png", { type: "image/png" })));
render(<ImageCropDialog image={{ ...image, height: 600, width: 1200 }} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: "free", maxWidth: 800, noUpscale: true }} renderCrop={renderCrop} />);
const preview = screen.getByRole("application", { name: "이미지 crop 미리보기" });
fireEvent.pointerDown(preview, { clientX: 100, clientY: 100, pointerId: 1 });
fireEvent.pointerMove(preview, { clientX: 130, clientY: 115, pointerId: 1 });
fireEvent.pointerUp(preview, { pointerId: 1 });
fireEvent.click(screen.getByRole("button", { name: "적용" }));
expect(await screen.findByText("예상 결과 800 × 400px")).toBeInTheDocument();
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetX: 30, offsetY: 15, outputHeight: 400, outputWidth: 800 }));
});
test("ImageCropDialog renders nothing when closed", () => {
render(<ImageCropDialog image={image} onApply={vi.fn()} onCancel={vi.fn()} open={false} policy={{ aspect: 1, maxWidth: 800, noUpscale: true }} />);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});

View File

@@ -0,0 +1,29 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { PageState } from "@/shared/ui/page-state";
test("PageState exposes accessible loading, empty, error, retry, and content states", () => {
const onRetry = vi.fn();
const { rerender } = render(<PageState description="자료를 불러오는 중입니다." state="loading" title="불러오는 중" />);
expect(screen.getByRole("status")).toHaveTextContent("불러오는 중");
rerender(<PageState description="조건에 맞는 자료가 없습니다." state="empty" title="자료 없음" />);
expect(screen.getByRole("status")).toHaveTextContent("자료 없음");
rerender(<PageState description="잠시 후 다시 시도하세요." onRetry={onRetry} state="error" title="불러오지 못했습니다" />);
expect(screen.getByRole("alert")).toHaveTextContent("불러오지 못했습니다");
fireEvent.click(screen.getByRole("button", { name: "다시 시도" }));
expect(onRetry).toHaveBeenCalledTimes(1);
rerender(
<PageState state="content">
<p> </p>
</PageState>,
);
expect(screen.getByText("공유 콘텐츠")).toBeInTheDocument();
});

View File

@@ -0,0 +1,42 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import type { PageData } from "@/shared/api/pagination";
import { ResourcePagination } from "@/shared/ui/resource-pagination";
const pageData: PageData<string> = {
totalCount: 42,
page: 1,
size: 20,
hasNext: true,
items: [],
};
test("ResourcePagination uses PageData and real buttons for accessible page movement", () => {
const onPageChange = vi.fn();
const onSizeChange = vi.fn();
render(<ResourcePagination data={pageData} onPageChange={onPageChange} onSizeChange={onSizeChange} />);
const previous = screen.getByRole("button", { name: "이전 페이지" });
const next = screen.getByRole("button", { name: "다음 페이지" });
expect(previous.tagName).toBe("BUTTON");
expect(next).toBeEnabled();
expect(screen.getByText("총 42개 · 2페이지")).toBeInTheDocument();
fireEvent.click(previous);
fireEvent.click(next);
fireEvent.change(screen.getByLabelText("페이지 크기"), { target: { value: "50" } });
expect(onPageChange).toHaveBeenNthCalledWith(1, 0);
expect(onPageChange).toHaveBeenNthCalledWith(2, 2);
expect(onSizeChange).toHaveBeenCalledWith(50);
});
test("ResourcePagination disables unavailable previous and next actions", () => {
render(<ResourcePagination data={{ ...pageData, page: 0, hasNext: false }} onPageChange={vi.fn()} onSizeChange={vi.fn()} />);
expect(screen.getByRole("button", { name: "이전 페이지" })).toBeDisabled();
expect(screen.getByRole("button", { name: "다음 페이지" })).toBeDisabled();
});

View File

@@ -0,0 +1,18 @@
import { render, screen } from "@testing-library/react";
import { expect, test } from "vitest";
import { ResponsiveResourceList } from "@/shared/ui/responsive-resource-list";
test("ResponsiveResourceList renders only desktop and mobile slots without domain props", () => {
render(
<ResponsiveResourceList
ariaLabel="공유 자료 목록"
desktop={<table><tbody><tr><td> </td></tr></tbody></table>}
mobile={<ul><li> </li></ul>}
/>,
);
expect(screen.getByRole("region", { name: "공유 자료 목록" })).toBeInTheDocument();
expect(screen.getByText("데스크톱 슬롯")).toBeInTheDocument();
expect(screen.getByText("모바일 슬롯")).toBeInTheDocument();
});

View File

@@ -0,0 +1,42 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, test, vi } from "vitest";
import { SearchToolbar } from "@/shared/ui/search-toolbar";
afterEach(() => {
vi.useRealTimers();
});
test("SearchToolbar keeps search controlled, renders filters, and emits only a debounced generic query", () => {
vi.useFakeTimers();
const onQueryChange = vi.fn();
const onSearchChange = vi.fn();
const { rerender } = render(
<SearchToolbar
filters={<select aria-label="상태 필터"><option></option></select>}
onQueryChange={onQueryChange}
onSearchChange={onSearchChange}
search=""
/>,
);
fireEvent.change(screen.getByRole("searchbox", { name: "검색어" }), { target: { value: "루나" } });
expect(onSearchChange).toHaveBeenCalledWith("루나");
expect(screen.getByLabelText("상태 필터")).toBeInTheDocument();
expect(onQueryChange).not.toHaveBeenCalled();
rerender(
<SearchToolbar
filters={<select aria-label="상태 필터"><option></option></select>}
onQueryChange={onQueryChange}
onSearchChange={onSearchChange}
search="루나"
/>,
);
act(() => vi.advanceTimersByTime(299));
expect(onQueryChange).not.toHaveBeenCalled();
act(() => vi.advanceTimersByTime(1));
expect(onQueryChange).toHaveBeenCalledWith("루나");
});

View File

@@ -0,0 +1,35 @@
import { render, screen } from "@testing-library/react";
import { StatusBadge } from "@/shared/ui/status-badge";
describe("StatusBadge", () => {
test("renders visible Korean text labels for every state", () => {
render(
<>
<StatusBadge status="OPEN" />
<StatusBadge status="SCHEDULED" />
<StatusBadge status="INACTIVE" />
</>,
);
expect(screen.getByLabelText("상태: 공개")).toHaveTextContent("공개");
expect(screen.getByLabelText("상태: 예약")).toHaveTextContent("예약");
expect(screen.getByLabelText("상태: 비활성")).toHaveTextContent("비활성");
});
test("renders a domain label with optional icon and description without relying on color meaning", () => {
render(
<StatusBadge
description={<span> 18:00 </span>}
icon={<span aria-hidden="true">S</span>}
label="검수 대기"
tone="warning"
/>,
);
const badge = screen.getByLabelText("상태: 검수 대기, 오늘 18:00 자동 전환");
expect(badge).toHaveTextContent("검수 대기");
expect(badge).toHaveTextContent("오늘 18:00 자동 전환");
});
});

View File

@@ -0,0 +1,48 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { UnsavedChangesGuard } from "@/shared/ui/unsaved-changes-guard";
test("UnsavedChangesGuard blocks only dirty route leave and returns focus to the trigger on cancel", async () => {
const onLeave = vi.fn();
const { rerender } = render(
<UnsavedChangesGuard dirty impactDescription="저장하지 않은 변경사항이 사라집니다." title="이 화면을 떠나시겠습니까?">
{(requestRouteLeave) => (
<button onClick={(event) => requestRouteLeave(event.currentTarget, onLeave)} type="button">
</button>
)}
</UnsavedChangesGuard>,
);
const trigger = screen.getByRole("button", { name: "목록으로 이동" });
fireEvent.click(trigger);
expect(onLeave).not.toHaveBeenCalled();
const dialog = screen.getByRole("alertdialog", { name: "이 화면을 떠나시겠습니까?" });
expect(dialog).toBeInTheDocument();
const cancel = screen.getByRole("button", { name: "계속 편집" });
const leave = screen.getByRole("button", { name: "떠나기" });
await waitFor(() => expect(cancel).toHaveFocus());
fireEvent.keyDown(dialog, { key: "Tab", shiftKey: true });
expect(leave).toHaveFocus();
fireEvent.keyDown(dialog, { key: "Tab" });
expect(cancel).toHaveFocus();
fireEvent.click(cancel);
expect(trigger).toHaveFocus();
rerender(
<UnsavedChangesGuard dirty={false} impactDescription="저장하지 않은 변경사항이 사라집니다." title="이 화면을 떠나시겠습니까?">
{(requestRouteLeave) => (
<button onClick={(event) => requestRouteLeave(event.currentTarget, onLeave)} type="button">
</button>
)}
</UnsavedChangesGuard>,
);
fireEvent.click(screen.getByRole("button", { name: "목록으로 이동" }));
expect(onLeave).toHaveBeenCalledTimes(1);
});

View File

@@ -0,0 +1,25 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { UploadProgress } from "@/shared/ui/upload-progress";
test("UploadProgress displays status and progress without owning an upload client", () => {
render(<UploadProgress fileName="voice.mp3" progress={45} status="uploading" />);
expect(screen.getByRole("progressbar", { name: "업로드 진행률" })).toHaveAttribute("aria-valuenow", "45");
expect(screen.getByText("voice.mp3")).toBeInTheDocument();
expect(screen.getByText("업로드 중")).toBeInTheDocument();
});
test("UploadProgress exposes cancel and retry callbacks only", () => {
const onCancel = vi.fn();
const onRetry = vi.fn();
render(<UploadProgress onCancel={onCancel} onRetry={onRetry} progress={0} status="error" />);
fireEvent.click(screen.getByRole("button", { name: "업로드 취소" }));
fireEvent.click(screen.getByRole("button", { name: "다시 시도" }));
expect(onCancel).toHaveBeenCalledTimes(1);
expect(onRetry).toHaveBeenCalledTimes(1);
});

View File

@@ -0,0 +1,141 @@
import { useRef, useState } from "react";
import { useAudioPlayback } from "./use-audio-playback";
export type AdminAudioPlayerProps = {
readonly playerId: string;
readonly src: string;
readonly title: string;
};
function formatTime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds <= 0) {
return "0:00";
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60).toString().padStart(2, "0");
return `${minutes}:${remainingSeconds}`;
}
export function AdminAudioPlayer({ playerId, src, title }: AdminAudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [hasError, setHasError] = useState(false);
const { clearPlayer, requestPlay } = useAudioPlayback(playerId, audioRef);
function play() {
const audio = audioRef.current;
if (audio === null) {
return;
}
setHasError(false);
requestPlay();
void audio.play().catch(() => setHasError(true));
}
function pause() {
audioRef.current?.pause();
clearPlayer();
}
function togglePlay() {
if (isPlaying) {
pause();
return;
}
play();
}
function retry() {
setHasError(false);
audioRef.current?.load();
}
function handleKeyDown(event: React.KeyboardEvent<HTMLElement>) {
if (event.currentTarget !== event.target) {
return;
}
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
togglePlay();
}
}
function changeCurrentTime(nextTime: number) {
const audio = audioRef.current;
setCurrentTime(nextTime);
if (audio !== null) {
audio.currentTime = nextTime;
}
}
function changeVolume(nextVolume: number) {
if (audioRef.current !== null) {
audioRef.current.volume = nextVolume;
}
}
function changePlaybackRate(nextPlaybackRate: number) {
if (audioRef.current !== null) {
audioRef.current.playbackRate = nextPlaybackRate;
}
}
return (
<section aria-label={`${title} 오디오 플레이어`} className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4" onKeyDown={handleKeyDown} role="group" tabIndex={0}>
<audio
controlsList="nodownload"
onDurationChange={(event) => setDuration(event.currentTarget.duration)}
onEnded={() => {
setIsPlaying(false);
clearPlayer();
}}
onError={() => {
setHasError(true);
setIsPlaying(false);
}}
onPause={() => setIsPlaying(false)}
onPlay={() => setIsPlaying(true)}
onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
preload="metadata"
ref={audioRef}
src={src}
/>
<div className="flex flex-wrap items-center gap-2">
<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={togglePlay} type="button">
{isPlaying ? "일시정지" : "재생"}
</button>
<span className="text-sm text-muted-foreground">{formatTime(currentTime)} / {formatTime(duration)}</span>
</div>
<label className="flex flex-col gap-2 text-sm font-semibold">
<input aria-label="재생 위치" max={duration || 0} min="0" onChange={(event) => changeCurrentTime(Number(event.currentTarget.value))} step="1" type="range" value={currentTime} />
</label>
<label className="flex flex-col gap-2 text-sm font-semibold">
<input aria-label="볼륨" max="1" min="0" onChange={(event) => changeVolume(Number(event.currentTarget.value))} step="0.05" type="range" />
</label>
<label className="flex flex-col gap-2 text-sm font-semibold">
<select aria-label="재생 속도" className="rounded-md border border-input bg-card px-3 py-2 text-base" onChange={(event) => changePlaybackRate(Number(event.currentTarget.value))} defaultValue="1">
<option value="0.75">0.75×</option>
<option value="1">1×</option>
<option value="1.25">1.25×</option>
<option value="1.5">1.5×</option>
<option value="2">2×</option>
</select>
</label>
{hasError ? (
<div className="flex flex-col gap-2 rounded-md border border-destructive bg-card p-3 text-sm text-destructive" role="alert">
<p> . .</p>
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={retry} type="button"> </button>
</div>
) : null}
</section>
);
}

View File

@@ -0,0 +1,9 @@
import { createContext } from "react";
export type AudioPlaybackContextValue = {
readonly activePlayerId: string | null;
readonly clearPlayer: (playerId: string) => void;
readonly requestPlay: (playerId: string) => void;
};
export const AudioPlaybackContext = createContext<AudioPlaybackContextValue | null>(null);

View File

@@ -0,0 +1,22 @@
import { useState } from "react";
import type { ReactNode } from "react";
import { AudioPlaybackContext } from "./audio-playback-context";
export type AudioPlaybackProviderProps = {
readonly children: ReactNode;
};
export function AudioPlaybackProvider({ children }: AudioPlaybackProviderProps) {
const [activePlayerId, setActivePlayerId] = useState<string | null>(null);
function clearPlayer(playerId: string) {
setActivePlayerId((current) => (current === playerId ? null : current));
}
return (
<AudioPlaybackContext value={{ activePlayerId, clearPlayer, requestPlay: setActivePlayerId }}>
{children}
</AudioPlaybackContext>
);
}

View File

@@ -0,0 +1,38 @@
import { useModalFocus } from "@/shared/ui/use-modal-focus";
export type ConfirmDeactivateDialogProps = {
readonly impactDescription: string;
readonly onCancel: () => void;
readonly onConfirm: () => void;
readonly open: boolean;
readonly targetName: string;
};
export function ConfirmDeactivateDialog({ impactDescription, onCancel, onConfirm, open, targetName }: ConfirmDeactivateDialogProps) {
const { dialogRef, trapFocus } = useModalFocus<HTMLElement>(open);
if (!open) {
return null;
}
const title = `${targetName} 비활성화 확인`;
return (
<div className="fixed inset-0 z-modal grid place-items-center bg-background/80 p-4">
<section aria-modal="true" className="flex w-full max-w-sm flex-col gap-4 rounded-lg border border-border bg-card p-6" onKeyDown={trapFocus} ref={dialogRef} role="alertdialog" aria-label={title}>
<div className="flex flex-col gap-2">
<h2 className="text-xl font-semibold">{title}</h2>
<p className="text-sm text-muted-foreground">{impactDescription}</p>
</div>
<div className="flex justify-end gap-2">
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={onCancel} type="button">
</button>
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={onConfirm} type="button">
</button>
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { useId, useRef } from "react";
export type FileFieldProps = {
readonly accept: string;
readonly acceptDescription: string;
readonly description?: string;
readonly error?: string;
readonly label: string;
readonly onChange: (file: File | null) => void;
readonly value: File | null;
};
export function FileField({ accept, acceptDescription, description, error, label, onChange, value }: FileFieldProps) {
const inputId = useId();
const descriptionId = useId();
const acceptId = useId();
const errorId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const describedBy = [description === undefined ? null : descriptionId, acceptId, error === undefined ? null : errorId].filter((id): id is string => id !== null).join(" ");
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
const files = event.currentTarget.files;
onChange(files === null ? null : files[0] ?? null);
}
function clearSelection() {
if (inputRef.current !== null) {
inputRef.current.value = "";
}
onChange(null);
}
return (
<div className="flex flex-col gap-2 rounded-lg border border-border bg-card p-4">
<label className="text-sm font-semibold" htmlFor={inputId}>{label}</label>
{description === undefined ? null : <p className="text-sm text-muted-foreground" id={descriptionId}>{description}</p>}
<p className="text-sm text-muted-foreground" id={acceptId}>{acceptDescription}</p>
<input accept={accept} aria-describedby={describedBy} aria-invalid={error === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base text-foreground" id={inputId} onChange={handleChange} ref={inputRef} type="file" />
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted-foreground">{value === null ? "선택된 파일 없음" : value.name}</span>
{value === null ? null : (
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={clearSelection} type="button"> </button>
)}
</div>
{error === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorId} role="alert">{error}</p>}
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { useId } from "react";
import type { ButtonHTMLAttributes, ReactNode } from "react";
export type IconOnlyActionProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "aria-label" | "children"> & {
readonly children: ReactNode;
readonly label: string;
};
export function IconOnlyAction({ children, className, label, type = "button", ...buttonProps }: IconOnlyActionProps) {
const tooltipId = useId();
const classes = ["icon-only-action", className].filter(Boolean).join(" ");
return (
<span className="icon-only-action-wrap">
<button {...buttonProps} aria-label={label} className={classes} type={type}>
{children}
<span className="sr-only">{label}</span>
</button>
<span className="icon-only-action-tooltip" id={tooltipId} role="tooltip">
{label}
</span>
</span>
);
}

View File

@@ -0,0 +1,153 @@
import { useRef, useState } from "react";
import { calculateCropOutputSize, createCroppedImageFile } from "@/shared/lib/crop-image";
import type { CropRenderRequest } from "@/shared/lib/crop-image";
import { useModalFocus } from "@/shared/ui/use-modal-focus";
export type CropSourceImage = {
readonly file: File;
readonly height: number;
readonly previewUrl: string;
readonly width: number;
};
export type ImageCropPolicy = {
readonly aspect: number | "free";
readonly maxWidth: number;
readonly noUpscale: boolean;
};
export type ImageCropDialogProps = {
readonly image: CropSourceImage;
readonly onApply: (file: File) => void;
readonly onCancel: () => void;
readonly open: boolean;
readonly policy: ImageCropPolicy;
readonly renderCrop?: (request: CropRenderRequest) => Promise<File>;
};
const MOVE_STEP = 10;
const ZOOM_STEP = 0.1;
export function ImageCropDialog({ image, onApply, onCancel, open, policy, renderCrop = createCroppedImageFile }: ImageCropDialogProps) {
const [offsetX, setOffsetX] = useState(0);
const [offsetY, setOffsetY] = useState(0);
const [zoom, setZoom] = useState(1);
const dragPointRef = useRef<{ readonly x: number; readonly y: number } | null>(null);
const { dialogRef, trapFocus } = useModalFocus<HTMLElement>(open);
const outputSize = calculateCropOutputSize({ aspect: policy.aspect, maxWidth: policy.maxWidth, noUpscale: policy.noUpscale, sourceHeight: image.height, sourceWidth: image.width });
if (!open) {
return null;
}
function resetCrop() {
setOffsetX(0);
setOffsetY(0);
setZoom(1);
}
function move(deltaX: number, deltaY: number) {
setOffsetX((current) => current + deltaX);
setOffsetY((current) => current + deltaY);
}
function changeZoom(nextZoom: number) {
setZoom(Math.min(3, Math.max(1, Number(nextZoom.toFixed(1)))));
}
function handleKeyDown(event: React.KeyboardEvent<HTMLElement>) {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
move(0, MOVE_STEP);
return;
case "ArrowLeft":
event.preventDefault();
move(-MOVE_STEP, 0);
return;
case "ArrowRight":
event.preventDefault();
move(MOVE_STEP, 0);
return;
case "ArrowUp":
event.preventDefault();
move(0, -MOVE_STEP);
return;
case "+":
event.preventDefault();
changeZoom(zoom + ZOOM_STEP);
return;
case "-":
event.preventDefault();
changeZoom(zoom - ZOOM_STEP);
return;
default:
}
}
function startDrag(event: React.PointerEvent<HTMLElement>) {
dragPointRef.current = { x: event.clientX, y: event.clientY };
event.currentTarget.setPointerCapture?.(event.pointerId);
}
function drag(event: React.PointerEvent<HTMLElement>) {
const dragPoint = dragPointRef.current;
if (dragPoint === null) {
return;
}
move(event.clientX - dragPoint.x, event.clientY - dragPoint.y);
dragPointRef.current = { x: event.clientX, y: event.clientY };
}
function stopDrag() {
dragPointRef.current = null;
}
async function applyCrop() {
const file = await renderCrop({
aspect: policy.aspect,
file: image.file,
offsetX,
offsetY,
outputHeight: outputSize.height,
outputWidth: outputSize.width,
previewUrl: image.previewUrl,
sourceHeight: image.height,
sourceWidth: image.width,
zoom,
});
onApply(file);
}
return (
<div className="fixed inset-0 z-modal grid place-items-center bg-background/80 p-4">
<section aria-label="이미지 crop" aria-modal="true" className="flex w-full max-w-lg flex-col gap-4 rounded-lg border border-border bg-card p-6" onKeyDown={trapFocus} ref={dialogRef} role="dialog">
<div className="flex flex-col gap-2">
<h2 className="text-xl font-semibold"> crop</h2>
<p className="text-sm text-muted-foreground">, , .</p>
</div>
<div aria-label="이미지 crop 미리보기" className="overflow-hidden rounded-lg border border-border bg-muted p-4" onKeyDown={handleKeyDown} onPointerDown={startDrag} onPointerLeave={stopDrag} onPointerMove={drag} onPointerUp={stopDrag} role="application" tabIndex={0}>
<img alt="선택한 이미지 미리보기" className="mx-auto max-h-64 max-w-full" src={image.previewUrl} style={{ transform: `translate(${offsetX}px, ${offsetY}px) scale(${zoom})` }} />
</div>
<p className="text-sm font-semibold text-info"> {outputSize.width} × {outputSize.height}px</p>
<div className="grid gap-2 sm:grid-cols-2">
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => move(0, -MOVE_STEP)} type="button"> </button>
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => move(0, MOVE_STEP)} type="button"> </button>
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => move(-MOVE_STEP, 0)} type="button"> </button>
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => move(MOVE_STEP, 0)} type="button"> </button>
</div>
<label className="flex flex-col gap-2 text-sm font-semibold">
<input aria-label="확대 비율" max="3" min="1" onChange={(event) => changeZoom(Number(event.currentTarget.value))} step="0.1" type="range" value={zoom} />
</label>
<div className="flex justify-end gap-2">
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={resetCrop} type="button"></button>
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={onCancel} type="button"></button>
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={() => void applyCrop()} type="button"></button>
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,66 @@
import type { ReactNode } from "react";
type LoadingPageStateProps = {
readonly description?: string;
readonly state: "loading";
readonly title: string;
};
type EmptyPageStateProps = {
readonly description?: string;
readonly state: "empty";
readonly title: string;
};
type ErrorPageStateProps = {
readonly description?: string;
readonly onRetry?: () => void;
readonly state: "error";
readonly title: string;
};
type ContentPageStateProps = {
readonly children: ReactNode;
readonly state: "content";
};
export type PageStateProps = LoadingPageStateProps | EmptyPageStateProps | ErrorPageStateProps | ContentPageStateProps;
function assertNever(value: never): never {
throw new Error(`Unhandled page state: ${String(value)}`);
}
export function PageState(props: PageStateProps) {
switch (props.state) {
case "content":
return <>{props.children}</>;
case "loading":
return (
<section className="rounded-lg border border-border bg-card p-6" role="status">
<h2 className="text-xl font-semibold">{props.title}</h2>
{props.description === undefined ? null : <p className="mt-2 text-sm text-muted-foreground">{props.description}</p>}
</section>
);
case "empty":
return (
<section className="rounded-lg border border-border bg-card p-6" role="status">
<h2 className="text-xl font-semibold">{props.title}</h2>
{props.description === undefined ? null : <p className="mt-2 text-sm text-muted-foreground">{props.description}</p>}
</section>
);
case "error":
return (
<section className="rounded-lg border border-destructive bg-card p-6 text-destructive" role="alert">
<h2 className="text-xl font-semibold">{props.title}</h2>
{props.description === undefined ? null : <p className="mt-2 text-sm">{props.description}</p>}
{props.onRetry === undefined ? null : (
<button className="mt-4 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={props.onRetry} type="button">
</button>
)}
</section>
);
default:
return assertNever(props);
}
}

View File

@@ -0,0 +1,32 @@
import type { PageData } from "@/shared/api/pagination";
export type ResourcePaginationProps = {
readonly data: PageData<unknown>;
readonly onPageChange: (page: number) => void;
readonly onSizeChange: (size: number) => void;
readonly sizeOptions?: readonly number[];
};
export function ResourcePagination({ data, onPageChange, onSizeChange, sizeOptions = [20, 50] }: ResourcePaginationProps) {
return (
<nav aria-label="페이지" className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm font-semibold text-muted-foreground"> {data.totalCount.toLocaleString("ko-KR")} · {data.page + 1}</p>
<div className="flex flex-wrap items-center gap-2">
<label className="text-sm font-semibold" htmlFor="resource-page-size">
</label>
<select className="rounded-md border border-input bg-card px-3 py-2 text-base" id="resource-page-size" onChange={(event) => onSizeChange(Number(event.currentTarget.value))} value={data.size}>
{sizeOptions.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={data.page <= 0} onClick={() => onPageChange(data.page - 1)} type="button">
</button>
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={!data.hasNext} onClick={() => onPageChange(data.page + 1)} type="button">
</button>
</div>
</nav>
);
}

View File

@@ -0,0 +1,16 @@
import type { ReactNode } from "react";
export type ResponsiveResourceListProps = {
readonly ariaLabel: string;
readonly desktop: ReactNode;
readonly mobile: ReactNode;
};
export function ResponsiveResourceList({ ariaLabel, desktop, mobile }: ResponsiveResourceListProps) {
return (
<section aria-label={ariaLabel} className="rounded-lg border border-border bg-card" role="region">
<div className="hidden overflow-x-auto md:block">{desktop}</div>
<div className="md:hidden">{mobile}</div>
</section>
);
}

View File

@@ -0,0 +1,37 @@
import { useEffect, useId, useRef } from "react";
import type { ReactNode } from "react";
export type SearchToolbarProps = {
readonly filters?: ReactNode;
readonly onQueryChange: (query: string) => void;
readonly onSearchChange: (search: string) => void;
readonly search: string;
};
export function SearchToolbar({ filters, onQueryChange, onSearchChange, search }: SearchToolbarProps) {
const searchId = useId();
const didMountRef = useRef(false);
useEffect(() => {
if (!didMountRef.current) {
didMountRef.current = true;
return undefined;
}
const timeoutId = window.setTimeout(() => onQueryChange(search), 300);
return () => window.clearTimeout(timeoutId);
}, [onQueryChange, search]);
return (
<section aria-label="검색 도구" className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4 sm:flex-row sm:items-end sm:justify-between">
<div className="flex min-w-0 flex-1 flex-col gap-2">
<label className="text-sm font-semibold" htmlFor={searchId}>
</label>
<input className="rounded-md border border-input bg-card px-3 py-2 text-base text-foreground" id={searchId} onChange={(event) => onSearchChange(event.currentTarget.value)} type="search" value={search} />
</div>
{filters === undefined ? null : <div className="flex flex-col gap-2 sm:min-w-48">{filters}</div>}
</section>
);
}

View File

@@ -0,0 +1,91 @@
import { isValidElement } from "react";
import type { ReactNode } from "react";
const STATUS_BADGE = {
INACTIVE: {
className: "status-badge status-badge--inactive",
label: "비활성",
tone: "inactive",
},
OPEN: {
className: "status-badge status-badge--success",
label: "공개",
tone: "success",
},
SCHEDULED: {
className: "status-badge status-badge--warning",
label: "예약",
tone: "warning",
},
} as const;
export type StatusBadgeStatus = keyof typeof STATUS_BADGE;
export type StatusBadgeTone = "inactive" | "success" | "warning";
type PresetStatusBadgeProps = {
readonly status: StatusBadgeStatus;
readonly description?: never;
readonly icon?: never;
readonly label?: never;
readonly tone?: never;
};
type DomainStatusBadgeProps = {
readonly description?: ReactNode;
readonly icon?: ReactNode;
readonly label: string;
readonly status?: never;
readonly tone: StatusBadgeTone;
};
export type StatusBadgeProps = PresetStatusBadgeProps | DomainStatusBadgeProps;
function getNodeText(node: ReactNode): string {
if (typeof node === "string" || typeof node === "number") {
return String(node);
}
if (Array.isArray(node)) {
return node.map(getNodeText).join("");
}
if (isValidElement<{ readonly children?: ReactNode }>(node)) {
return getNodeText(node.props.children);
}
return "";
}
function getBadge(props: StatusBadgeProps): {
readonly className: string;
readonly description: ReactNode;
readonly label: string;
readonly icon: ReactNode;
} {
if (props.status !== undefined) {
const badge = STATUS_BADGE[props.status];
return { className: badge.className, description: null, icon: null, label: badge.label };
}
return {
className: `status-badge status-badge--${props.tone}`,
description: props.description ?? null,
icon: props.icon ?? null,
label: props.label,
};
}
export function StatusBadge(props: StatusBadgeProps) {
const badge = getBadge(props);
const descriptionText = getNodeText(badge.description);
const ariaLabel = descriptionText.length > 0 ? `상태: ${badge.label}, ${descriptionText}` : `상태: ${badge.label}`;
return (
<span aria-label={ariaLabel} className={badge.className}>
{badge.icon ?? <span aria-hidden="true" className="status-badge__dot" />}
{badge.label}
{badge.description === null ? null : <span className="font-normal">{badge.description}</span>}
</span>
);
}

View File

@@ -0,0 +1,68 @@
import { useState } from "react";
import type { ReactNode } from "react";
import { useModalFocus } from "@/shared/ui/use-modal-focus";
export type RequestRouteLeave = (trigger: HTMLElement, leaveRoute: () => void) => void;
export type UnsavedChangesGuardProps = {
readonly children: (requestRouteLeave: RequestRouteLeave) => ReactNode;
readonly dirty: boolean;
readonly impactDescription: string;
readonly title: string;
};
type PendingRouteLeave = {
readonly leaveRoute: () => void;
readonly trigger: HTMLElement;
};
export function UnsavedChangesGuard({ children, dirty, impactDescription, title }: UnsavedChangesGuardProps) {
const [pendingRouteLeave, setPendingRouteLeave] = useState<PendingRouteLeave | null>(null);
const { dialogRef, trapFocus } = useModalFocus<HTMLElement>(pendingRouteLeave !== null);
function closeDialog() {
const trigger = pendingRouteLeave?.trigger;
setPendingRouteLeave(null);
trigger?.focus();
}
function confirmLeave() {
const leaveRoute = pendingRouteLeave?.leaveRoute;
setPendingRouteLeave(null);
leaveRoute?.();
}
function requestRouteLeave(trigger: HTMLElement, leaveRoute: () => void) {
if (!dirty) {
leaveRoute();
return;
}
setPendingRouteLeave({ leaveRoute, trigger });
}
return (
<>
{children(requestRouteLeave)}
{dirty && pendingRouteLeave !== null ? (
<div className="fixed inset-0 z-modal grid place-items-center bg-background/80 p-4">
<section aria-label={title} aria-modal="true" className="flex w-full max-w-sm flex-col gap-4 rounded-lg border border-border bg-card p-6" onKeyDown={trapFocus} ref={dialogRef} role="alertdialog">
<div className="flex flex-col gap-2">
<h2 className="text-xl font-semibold">{title}</h2>
<p className="text-sm text-muted-foreground">{impactDescription}</p>
</div>
<div className="flex justify-end gap-2">
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={closeDialog} type="button">
</button>
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={confirmLeave} type="button">
</button>
</div>
</section>
</div>
) : null}
</>
);
}

View File

@@ -0,0 +1,39 @@
const STATUS_LABEL = {
canceled: "취소됨",
error: "업로드 실패",
idle: "대기 중",
success: "업로드 완료",
uploading: "업로드 중",
} as const;
export type UploadProgressStatus = keyof typeof STATUS_LABEL;
export type UploadProgressProps = {
readonly fileName?: string;
readonly onCancel?: () => void;
readonly onRetry?: () => void;
readonly progress: number;
readonly status: UploadProgressStatus;
};
export function UploadProgress({ fileName, onCancel, onRetry, progress, status }: UploadProgressProps) {
const safeProgress = Math.min(100, Math.max(0, Math.round(progress)));
return (
<section aria-label="업로드 상태" className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-col gap-1">
{fileName === undefined ? null : <p className="text-sm font-semibold">{fileName}</p>}
<p className="text-sm text-muted-foreground">{STATUS_LABEL[status]}</p>
</div>
<div className="flex gap-2">
{onCancel === undefined ? null : <button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={onCancel} type="button"> </button>}
{onRetry === undefined ? null : <button className="rounded-md border border-input bg-primary px-3 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={onRetry} type="button"> </button>}
</div>
</div>
<div aria-label="업로드 진행률" aria-valuemax={100} aria-valuemin={0} aria-valuenow={safeProgress} className="h-3 overflow-hidden rounded-sm bg-muted" role="progressbar">
<div className="h-full bg-primary" style={{ inlineSize: `${safeProgress}%` }} />
</div>
</section>
);
}

View File

@@ -0,0 +1,19 @@
import { useContext, useEffect } from "react";
import type { RefObject } from "react";
import { AudioPlaybackContext } from "./audio-playback-context";
export function useAudioPlayback(playerId: string, audioRef: RefObject<HTMLAudioElement | null>) {
const context = useContext(AudioPlaybackContext);
useEffect(() => {
if (context?.activePlayerId !== null && context?.activePlayerId !== undefined && context.activePlayerId !== playerId) {
audioRef.current?.pause();
}
}, [audioRef, context?.activePlayerId, playerId]);
return {
clearPlayer: () => context?.clearPlayer(playerId),
requestPlay: () => context?.requestPlay(playerId),
};
}

View File

@@ -0,0 +1,50 @@
import { useEffect, useRef } from "react";
const focusableSelector = "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])";
export function useModalFocus<T extends HTMLElement>(open: boolean) {
const dialogRef = useRef<T>(null);
const triggerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!open) {
return undefined;
}
triggerRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const focusableElements = Array.from(dialogRef.current?.querySelectorAll<HTMLElement>(focusableSelector) ?? []);
focusableElements[0]?.focus();
return () => {
triggerRef.current?.focus();
triggerRef.current = null;
};
}, [open]);
function trapFocus(event: React.KeyboardEvent<T>) {
if (event.key !== "Tab") {
return;
}
const focusableElements = Array.from(dialogRef.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();
}
}
return { dialogRef, trapFocus };
}

View File

@@ -0,0 +1,31 @@
import { getFileExtension, validateFile } from "./file-validation";
import type { FileValidationResult } from "./file-validation";
export const AUDIO_FILE_POLICY = {
allowedExtensions: [".mp3", ".aac", ".m4a"],
allowedMimeTypes: ["audio/mpeg", "audio/aac", "audio/mp4", "audio/x-m4a"],
maxBytes: 1_024_000_000,
} as const;
const allowedMimeByExtension = {
".aac": ["audio/aac"],
".m4a": ["audio/mp4", "audio/x-m4a"],
".mp3": ["audio/mpeg"],
} satisfies Record<string, readonly string[]>;
export type AudioFileValidationResult = FileValidationResult | { readonly ok: false; readonly reason: "mimeExtensionCombination" };
export function validateAudioFile(file: File): AudioFileValidationResult {
const baseResult = validateFile(file, AUDIO_FILE_POLICY);
if (!baseResult.ok) {
return baseResult;
}
const extension = getFileExtension(file.name);
const allowedMimeTypes = allowedMimeByExtension[extension as keyof typeof allowedMimeByExtension];
if (allowedMimeTypes === undefined || !allowedMimeTypes.includes(file.type)) {
return { ok: false, reason: "mimeExtensionCombination" };
}
return { ok: true };
}

View File

@@ -0,0 +1,93 @@
import { expect, test, vi } from "vitest";
import { createImagePolicy, IMAGE_MAX_BYTES } from "@/shared/validation/image-policy";
import { AUDIO_FILE_POLICY, validateAudioFile } from "@/shared/validation/audio-file-policy";
import { validateFile } from "@/shared/validation/file-validation";
function fileWithSize(name: string, type: string, size: number): File {
const file = new File(["x"], name, { type });
Object.defineProperty(file, "size", { value: size });
return file;
}
test("validateFile checks injected extension, MIME, and maxBytes together", () => {
const policy = {
allowedExtensions: [".png"] as const,
allowedMimeTypes: ["image/png"] as const,
maxBytes: 10,
};
expect(validateFile(fileWithSize("cover.png", "image/png", 10), policy)).toEqual({ ok: true });
expect(validateFile(fileWithSize("cover.jpg", "image/png", 10), policy)).toEqual({ ok: false, reason: "extension" });
expect(validateFile(fileWithSize("cover.png", "image/jpeg", 10), policy)).toEqual({ ok: false, reason: "mime" });
expect(validateFile(fileWithSize("cover.png", "image/png", 11), policy)).toEqual({ ok: false, reason: "size" });
});
test("validateFile lets callers inject a 10MB byte boundary without owning image domain policy", () => {
const tenMegabytes = 10 * 1024 * 1024;
const policy = {
allowedExtensions: [".jpg"] as const,
allowedMimeTypes: ["image/jpeg"] as const,
maxBytes: tenMegabytes,
};
expect(validateFile(fileWithSize("profile.jpg", "image/jpeg", tenMegabytes), policy)).toEqual({ ok: true });
expect(validateFile(fileWithSize("profile.jpg", "image/jpeg", tenMegabytes + 1), policy)).toEqual({ ok: false, reason: "size" });
});
test("validateAudioFile accepts MP3, AAC, M4A including audio/x-m4a at 1,024,000,000 bytes", () => {
expect(AUDIO_FILE_POLICY.maxBytes).toBe(1_024_000_000);
expect(validateAudioFile(fileWithSize("voice.mp3", "audio/mpeg", 1_024_000_000))).toEqual({ ok: true });
expect(validateAudioFile(fileWithSize("voice.aac", "audio/aac", 1_024_000_000))).toEqual({ ok: true });
expect(validateAudioFile(fileWithSize("voice.m4a", "audio/mp4", 1_024_000_000))).toEqual({ ok: true });
expect(validateAudioFile(fileWithSize("voice.m4a", "audio/x-m4a", 1_024_000_000))).toEqual({ ok: true });
});
test("validateAudioFile rejects WAV, oversized files, and audio/x-m4a without .m4a without sniffing codecs", async () => {
const wav = fileWithSize("voice.wav", "audio/wav", 10);
const sniff = vi.spyOn(wav, "arrayBuffer");
expect(validateAudioFile(wav)).toEqual({ ok: false, reason: "extension" });
expect(validateAudioFile(fileWithSize("voice.mp3", "audio/mpeg", 1_024_000_001))).toEqual({ ok: false, reason: "size" });
expect(validateAudioFile(fileWithSize("voice.aac", "audio/x-m4a", 10))).toEqual({ ok: false, reason: "mimeExtensionCombination" });
expect(sniff).not.toHaveBeenCalled();
});
test("validateAudioFile rejects mismatched canonical MIME and extension combinations", () => {
expect(validateAudioFile(fileWithSize("voice.mp3", "audio/mp4", 10))).toEqual({ ok: false, reason: "mimeExtensionCombination" });
expect(validateAudioFile(fileWithSize("voice.aac", "audio/mpeg", 10))).toEqual({ ok: false, reason: "mimeExtensionCombination" });
expect(validateAudioFile(fileWithSize("voice.m4a", "audio/aac", 10))).toEqual({ ok: false, reason: "mimeExtensionCombination" });
});
test("createImagePolicy records only a domain-neutral crop contract", () => {
expect(createImagePolicy({ aspect: 1, cropRequired: true, maxWidth: 800, noUpscale: true })).toEqual({
aspect: 1,
cropRequired: true,
maxBytes: 10_485_760,
maxWidth: 800,
noUpscale: true,
});
});
test("createImagePolicy records the confirmed 10MiB image byte boundary", () => {
const policy = createImagePolicy({ aspect: 1, cropRequired: true, maxWidth: 800, noUpscale: true });
expect(IMAGE_MAX_BYTES).toBe(10_485_760);
expect(policy.maxBytes).toBe(10_485_760);
expect(validateFile(fileWithSize("profile.jpg", "image/jpeg", 10_485_759), {
allowedExtensions: [".jpg"] as const,
allowedMimeTypes: ["image/jpeg"] as const,
maxBytes: policy.maxBytes,
})).toEqual({ ok: true });
expect(validateFile(fileWithSize("profile.jpg", "image/jpeg", 10_485_760), {
allowedExtensions: [".jpg"] as const,
allowedMimeTypes: ["image/jpeg"] as const,
maxBytes: policy.maxBytes,
})).toEqual({ ok: true });
expect(validateFile(fileWithSize("profile.jpg", "image/jpeg", 10_485_761), {
allowedExtensions: [".jpg"] as const,
allowedMimeTypes: ["image/jpeg"] as const,
maxBytes: policy.maxBytes,
})).toEqual({ ok: false, reason: "size" });
});

View File

@@ -0,0 +1,31 @@
export type FileValidationPolicy = {
readonly allowedExtensions: readonly string[];
readonly allowedMimeTypes: readonly string[];
readonly maxBytes: number;
};
export type FileValidationResult =
| { readonly ok: true }
| { readonly ok: false; readonly reason: "extension" | "mime" | "size" };
export function getFileExtension(fileName: string): string {
const dotIndex = fileName.lastIndexOf(".");
return dotIndex < 0 ? "" : fileName.slice(dotIndex).toLowerCase();
}
export function validateFile(file: File, policy: FileValidationPolicy): FileValidationResult {
if (file.size > policy.maxBytes) {
return { ok: false, reason: "size" };
}
if (!policy.allowedExtensions.includes(getFileExtension(file.name))) {
return { ok: false, reason: "extension" };
}
if (!policy.allowedMimeTypes.includes(file.type)) {
return { ok: false, reason: "mime" };
}
return { ok: true };
}

View File

@@ -0,0 +1,15 @@
export type ImageAspect = number | "free";
export const IMAGE_MAX_BYTES = 10_485_760;
export type ImagePolicy = {
readonly aspect: ImageAspect;
readonly cropRequired: boolean;
readonly maxBytes: number;
readonly maxWidth: number;
readonly noUpscale: boolean;
};
export function createImagePolicy(policy: Omit<ImagePolicy, "maxBytes">): ImagePolicy {
return { ...policy, maxBytes: IMAGE_MAX_BYTES };
}

View File

@@ -0,0 +1,165 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
const rootDir = process.cwd();
function projectFile(path: string) {
const absolutePath = join(rootDir, path);
expect(existsSync(absolutePath)).toBe(true);
return readFileSync(absolutePath, "utf8");
}
function cssSource() {
return projectFile("src/styles/globals.css");
}
function cssVariable(css: string, name: string) {
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const matches = [...css.matchAll(new RegExp(`(?:^|[\\s{])--${escapedName}:\\s*([^;]+);`, "gm"))];
const value = matches.at(-1)?.[1]?.trim();
if (!value) {
throw new Error(`Missing --${name}`);
}
return resolveCssVariable(css, value).toUpperCase();
}
function resolveCssVariable(css: string, value: string): string {
const variableName = /^var\(--([^)]+)\)$/.exec(value.trim())?.[1];
if (!variableName) {
return value;
}
return cssVariable(css, variableName);
}
function relativeLuminance(channel: number) {
const normalized = channel / 255;
if (normalized <= 0.03928) {
return normalized / 12.92;
}
return ((normalized + 0.055) / 1.055) ** 2.4;
}
function hexToRgb(hex: string) {
const value = hex.replace("#", "");
return {
blue: Number.parseInt(value.slice(4, 6), 16),
green: Number.parseInt(value.slice(2, 4), 16),
red: Number.parseInt(value.slice(0, 2), 16),
};
}
function contrastRatio(foreground: string, background: string) {
const fg = hexToRgb(foreground);
const bg = hexToRgb(background);
const fgLuminance = 0.2126 * relativeLuminance(fg.red) + 0.7152 * relativeLuminance(fg.green) + 0.0722 * relativeLuminance(fg.blue);
const bgLuminance = 0.2126 * relativeLuminance(bg.red) + 0.7152 * relativeLuminance(bg.green) + 0.0722 * relativeLuminance(bg.blue);
const lighter = Math.max(fgLuminance, bgLuminance);
const darker = Math.min(fgLuminance, bgLuminance);
return (lighter + 0.05) / (darker + 0.05);
}
describe("Task 1.1 design system tokens", () => {
test("wires PRD brand tokens through CSS variable mode", () => {
const css = cssSource();
const componentsJson = projectFile("components.json");
const main = projectFile("src/main.tsx");
const design = projectFile("DESIGN.md");
expect(main).toContain('import "@/styles/globals.css";');
expect(componentsJson).toContain('"cssVariables": true');
expect(componentsJson).toContain('"css": "src/styles/globals.css"');
expect(css).toContain("@import \"tailwindcss\"");
expect(cssVariable(css, "color-brand-500")).toBe("#00BDF7");
expect(cssVariable(css, "primary")).toBe("#00BDF7");
expect(cssVariable(css, "primary-foreground")).toBe("#062B36");
expect(cssVariable(css, "button-bg")).toBe("#00BDF7");
expect(design).toContain("--color-brand-500");
expect(design).toContain("--primary");
});
test("keeps required token contrast and blocks white text on primary", () => {
const css = cssSource();
const primary = cssVariable(css, "color-brand-500");
const primaryForeground = cssVariable(css, "color-primary-foreground");
const background = cssVariable(css, "background");
const card = cssVariable(css, "color-card");
const info = cssVariable(css, "info");
const input = cssVariable(css, "color-input");
const ring = cssVariable(css, "color-brand-800");
expect(contrastRatio(primaryForeground, primary)).toBeGreaterThanOrEqual(4.5);
expect(contrastRatio("#FFFFFF", primary)).toBeLessThan(4.5);
expect(contrastRatio(info, background)).toBeGreaterThanOrEqual(4.5);
expect(contrastRatio(input, card)).toBeGreaterThanOrEqual(3);
expect(contrastRatio(ring, card)).toBeGreaterThanOrEqual(3);
});
test("keeps core foreground contrast against page and card surfaces", () => {
const css = cssSource();
const foreground = cssVariable(css, "foreground");
const background = cssVariable(css, "background");
const card = cssVariable(css, "card");
expect(contrastRatio(foreground, background)).toBeGreaterThanOrEqual(4.5);
expect(contrastRatio(foreground, card)).toBeGreaterThanOrEqual(4.5);
});
test("keeps the theme light-only", () => {
const source = [
projectFile("src/main.tsx"),
projectFile("src/app/App.tsx"),
cssSource(),
projectFile("components.json"),
projectFile("package.json"),
].join("\n");
expect(source).not.toMatch(/\.dark\b/);
expect(source).not.toContain("ThemeProvider");
expect(source).not.toContain("theme toggle");
expect(source).not.toContain("next-themes");
expect(source).not.toContain("prefers-color-scheme");
});
test("defines Korean admin base styles and accessibility primitives", () => {
const css = cssSource();
expect(cssVariable(css, "font-sans")).toBe('PRETENDARD, "NOTO SANS KR", "APPLE SD GOTHIC NEO", SYSTEM-UI, SANS-SERIF');
expect(css).toContain("--target-control-min: 2.75rem");
expect(css).toContain("--focus-ring-width: 0.125rem");
expect(css).toContain("--z-sticky: 10");
expect(css).toContain("--z-navigation: 20");
expect(css).toContain("--z-popover: 30");
expect(css).toContain("--z-overlay: 40");
expect(css).toContain("--z-modal: 50");
expect(css).toMatch(/input,\s*select,\s*textarea\s*{[^}]*font-size:\s*max\(1rem, var\(--font-size-body\)\)/s);
expect(css).toMatch(/button,\s*\[role="button"\],\s*input,\s*select,\s*textarea\s*{[^}]*min-block-size:\s*var\(--target-control-min\)/s);
expect(css).toContain(":focus-visible");
expect(css).toContain("@media (prefers-reduced-motion: reduce)");
expect(css).toContain(".icon-only-action");
expect(css).toContain("border: 1px solid var(--input)");
});
test("exposes declared semantic status and link tokens to Tailwind", () => {
const css = cssSource();
expect(css).toContain("--color-success: var(--success)");
expect(css).toContain("--color-success-surface: var(--success-surface)");
expect(css).toContain("--color-warning: var(--warning)");
expect(css).toContain("--color-warning-surface: var(--warning-surface)");
expect(css).toContain("--color-inactive: var(--inactive)");
expect(css).toContain("--color-inactive-surface: var(--inactive-surface)");
expect(css).toContain("--color-link: var(--link)");
expect(css).toContain("--color-link-hover: var(--link-hover)");
expect(css).toContain("--color-info: var(--info)");
});
});

289
src/styles/globals.css Normal file
View File

@@ -0,0 +1,289 @@
@import "tailwindcss";
@theme inline {
--font-sans: var(--font-sans);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-success: var(--success);
--color-success-surface: var(--success-surface);
--color-warning: var(--warning);
--color-warning-surface: var(--warning-surface);
--color-inactive: var(--inactive);
--color-inactive-surface: var(--inactive-surface);
--color-link: var(--link);
--color-link-hover: var(--link-hover);
--color-info: var(--info);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: var(--radius-sm);
--radius-md: var(--radius-md);
--radius-lg: var(--radius-lg);
--z-sticky: var(--z-sticky);
--z-navigation: var(--z-navigation);
--z-popover: var(--z-popover);
--z-overlay: var(--z-overlay);
--z-modal: var(--z-modal);
}
:root {
--color-brand-50: #F0FBFF;
--color-brand-100: #D9F6FF;
--color-brand-200: #B5EEFF;
--color-brand-300: #7CE2FF;
--color-brand-400: #36D1FF;
--color-brand-500: #00BDF7;
--color-brand-600: #00A9DE;
--color-brand-700: #009DCE;
--color-brand-800: #007EA8;
--color-brand-900: #086789;
--color-brand-950: #063747;
--color-background: #F6FBFD;
--color-card: #FFFFFF;
--color-foreground: #102A33;
--color-muted: #E9F4F7;
--color-muted-foreground: #425F69;
--color-secondary: #E1F5FA;
--color-secondary-foreground: #123E4B;
--color-accent: #D9F6FF;
--color-accent-foreground: #0C566F;
--color-border: #D5E8EE;
--color-input: #577581;
--color-primary-foreground: #062B36;
--color-success: #167347;
--color-success-surface: #EAF8F0;
--color-warning: #9A5B00;
--color-warning-surface: #FFF7E6;
--color-destructive: #B42318;
--color-destructive-surface: #FEF0EE;
--color-inactive: #52636A;
--color-inactive-surface: #EEF3F5;
--background: var(--color-background);
--foreground: var(--color-foreground);
--card: var(--color-card);
--card-foreground: var(--color-foreground);
--popover: var(--color-card);
--popover-foreground: var(--color-foreground);
--primary: var(--color-brand-500);
--primary-hover: var(--color-brand-600);
--primary-active: var(--color-brand-700);
--primary-foreground: var(--color-primary-foreground);
--secondary: var(--color-secondary);
--secondary-foreground: var(--color-secondary-foreground);
--muted: var(--color-muted);
--muted-foreground: var(--color-muted-foreground);
--accent: var(--color-accent);
--accent-foreground: var(--color-accent-foreground);
--destructive: var(--color-destructive);
--border: var(--color-border);
--input: var(--color-input);
--ring: var(--color-brand-800);
--link: var(--color-brand-800);
--link-hover: var(--color-brand-900);
--info: var(--color-brand-900);
--success: var(--color-success);
--success-surface: var(--color-success-surface);
--warning: var(--color-warning);
--warning-surface: var(--color-warning-surface);
--inactive: var(--color-inactive);
--inactive-surface: var(--color-inactive-surface);
--font-sans: Pretendard, "Noto Sans KR", "Apple SD Gothic Neo", system-ui, sans-serif;
--font-size-page-title: 1.5rem;
--font-size-section-title: 1.25rem;
--font-size-body: 0.875rem;
--font-size-small: 0.8125rem;
--font-size-caption: 0.75rem;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius: var(--radius-md);
--target-control-min: 2.75rem;
--focus-ring-width: 0.125rem;
--duration-micro: 150ms;
--z-sticky: 10;
--z-navigation: 20;
--z-popover: 30;
--z-overlay: 40;
--z-modal: 50;
--button-bg: var(--primary);
--button-bg-hover: var(--primary-hover);
--button-bg-active: var(--primary-active);
--button-fg: var(--primary-foreground);
--button-radius: var(--radius-md);
--button-border: var(--input);
--badge-radius: var(--radius-sm);
}
* {
box-sizing: border-box;
}
html {
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
font-size: 100%;
}
body {
min-block-size: 100dvb;
margin: 0;
background: var(--background);
color: var(--foreground);
font-size: var(--font-size-body);
line-height: 1.5;
text-rendering: optimizeLegibility;
}
button,
[role="button"],
input,
select,
textarea {
min-block-size: var(--target-control-min);
}
input,
select,
textarea {
font: inherit;
font-size: max(1rem, var(--font-size-body));
}
button {
font: inherit;
}
:focus-visible {
outline: var(--focus-ring-width) solid var(--ring);
outline-offset: 0.125rem;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
border: 1px solid currentColor;
border-radius: var(--badge-radius);
padding: var(--space-1) var(--space-2);
font-size: var(--font-size-caption);
font-weight: 700;
line-height: 1.4;
}
.status-badge__dot {
inline-size: 0.5rem;
block-size: 0.5rem;
border-radius: 999px;
background: currentColor;
}
.status-badge--success {
background: var(--success-surface);
color: var(--success);
}
.status-badge--warning {
background: var(--warning-surface);
color: var(--warning);
}
.status-badge--inactive {
background: var(--inactive-surface);
color: var(--inactive);
}
.icon-only-action-wrap {
position: relative;
display: inline-flex;
}
.icon-only-action {
display: inline-grid;
place-items: center;
min-inline-size: var(--target-control-min);
border: 1px solid var(--input);
border-radius: var(--button-radius);
background: var(--card);
color: var(--foreground);
cursor: pointer;
transition: background-color var(--duration-micro) ease-out, color var(--duration-micro) ease-out, transform var(--duration-micro) ease-out;
}
.icon-only-action:hover {
background: var(--accent);
color: var(--accent-foreground);
}
.icon-only-action:active {
transform: translateY(1px);
}
.icon-only-action:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.icon-only-action-tooltip {
position: absolute;
inset-block-end: calc(100% + var(--space-1));
inset-inline-start: 50%;
z-index: var(--z-popover);
border-radius: var(--radius-sm);
padding: var(--space-1) var(--space-2);
background: var(--foreground);
color: var(--card);
font-size: var(--font-size-caption);
opacity: 0;
pointer-events: none;
transform: translateX(-50%);
transition: opacity var(--duration-micro) ease-out;
white-space: nowrap;
}
.icon-only-action:hover + .icon-only-action-tooltip,
.icon-only-action:focus-visible + .icon-only-action-tooltip {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto;
transition-duration: 0.01ms;
animation-duration: 0.01ms;
animation-iteration-count: 1;
}
}

View File

@@ -0,0 +1,77 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
const apiBaseUrl = "https://test-character-admin.sodalive.net";
const sessionStorageKey = "ai-character-admin-auth-session";
async function openShell(page: import("@playwright/test").Page): Promise<void> {
await page.route(`${apiBaseUrl}/api/v2/admin/ai-characters?page=0&size=20`, async (route) => {
await route.fulfill({
contentType: "application/json",
json: { success: true, message: null, data: null, errorProperty: null },
});
});
await page.addInitScript(
([key, value]) => {
window.sessionStorage.setItem(key, value);
},
[sessionStorageKey, JSON.stringify({ token: "admin-token", role: "ADMIN" })],
);
await page.goto("/ai-characters");
await expect(page.getByRole("heading", { name: "AI 캐릭터", exact: true })).toBeVisible();
}
test("keeps shell controls visible without horizontal overflow at 320px and 200 percent zoom", async ({ page }) => {
await page.setViewportSize({ width: 320, height: 640 });
await openShell(page);
await page.evaluate(() => {
document.documentElement.style.zoom = "2";
});
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth);
await expect(page.getByRole("button", { name: "모바일 메뉴 열기" })).toBeInViewport();
await expect(page.getByRole("button", { name: "로그아웃" })).toBeInViewport();
expect(overflow).toBe(false);
});
test("keeps the open mobile menu visible and keyboard-contained at 320px and 200 percent zoom", async ({ page }) => {
await page.setViewportSize({ width: 320, height: 640 });
await openShell(page);
await page.evaluate(() => {
document.documentElement.style.zoom = "2";
});
await page.getByRole("button", { name: "모바일 메뉴 열기" }).focus();
await page.keyboard.press("Enter");
const closeButton = page.getByRole("button", { name: "모바일 메뉴 닫기" });
const navLink = page.getByRole("navigation", { name: "모바일 주 메뉴" }).getByRole("link", { name: "AI 캐릭터" });
await expect(closeButton).toBeFocused();
await expect(closeButton).toBeInViewport();
await expect(navLink).toBeInViewport();
await page.keyboard.press("Shift+Tab");
await expect(navLink).toBeFocused();
await page.keyboard.press("Tab");
await expect(closeButton).toBeFocused();
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth);
expect(overflow).toBe(false);
await page.keyboard.press("Escape");
await expect(page.getByRole("navigation", { name: "모바일 주 메뉴" })).toBeHidden();
await expect(page.getByRole("button", { name: "모바일 메뉴 열기" })).toBeFocused();
});
test("has no critical or serious axe violations on the shell route", async ({ page }) => {
await openShell(page);
const results = await new AxeBuilder({ page }).analyze();
const blockingViolations = results.violations.filter(
(violation) => violation.impact === "critical" || violation.impact === "serious",
);
expect(blockingViolations).toEqual([]);
});

43
tests/e2e/auth.spec.ts Normal file
View File

@@ -0,0 +1,43 @@
import { expect, test } from "@playwright/test";
const apiBaseUrl = "https://test-character-admin.sodalive.net";
test("completes login, navigation, and logout using only the keyboard", async ({ page }) => {
await page.route(`${apiBaseUrl}/admin/member/login`, async (route) => {
await route.fulfill({
contentType: "application/json",
json: { success: true, message: null, data: { token: "admin-token", role: "ADMIN" }, errorProperty: null },
});
});
await page.route(`${apiBaseUrl}/api/v2/admin/ai-characters?page=0&size=20`, async (route) => {
await route.fulfill({
contentType: "application/json",
json: { success: true, message: null, data: null, errorProperty: null },
});
});
await page.route(`${apiBaseUrl}/member/logout`, async (route) => {
await route.fulfill({
contentType: "application/json",
json: { success: true, message: null, data: {}, errorProperty: null },
});
});
await page.goto("/login");
await page.getByLabel("이메일").focus();
await page.keyboard.type("admin@test.com");
await page.getByLabel("비밀번호").focus();
await page.keyboard.type("password");
await page.getByRole("button", { name: "로그인" }).focus();
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/ai-characters$/);
await expect(page.getByRole("heading", { name: "AI 캐릭터", exact: true })).toBeVisible();
const logoutButton = page.getByRole("button", { name: "로그아웃" });
await logoutButton.focus();
await expect(logoutButton).toBeFocused();
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByRole("heading", { name: "관리자 로그인" })).toBeVisible();
});

View File

@@ -4,5 +4,5 @@ test("opens the root shell", async ({ page }) => {
await page.goto("/");
await expect(page.locator("html")).toHaveAttribute("lang", "ko");
await expect(page.getByRole("main")).toContainText("AI 캐릭터 관리자");
await expect(page.getByRole("main")).toContainText("관리자 로그인");
});

View File

@@ -4,7 +4,7 @@
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client", "vitest/globals"],
"types": ["vite/client"],
"allowImportingTsExtensions": true,
"module": "ESNext",
"moduleResolution": "bundler",
@@ -23,5 +23,11 @@
"jsx": "react-jsx",
"noEmit": true
},
"include": ["src"]
"include": ["src"],
"exclude": [
"src/**/*.test.ts",
"src/**/*.test.tsx",
"src/**/__tests__/**",
"src/shared/test/**"
]
}

View File

@@ -2,6 +2,7 @@
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.test.json" }
]
}

27
tsconfig.test.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.test.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["node", "vite/client", "vitest/globals", "@testing-library/jest-dom"],
"allowImportingTsExtensions": true,
"module": "ESNext",
"moduleResolution": "bundler",
"skipLibCheck": true,
"baseUrl": ".",
"ignoreDeprecations": "6.0",
"paths": {
"@/*": ["src/*"]
},
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"moduleDetection": "force",
"jsx": "react-jsx",
"noEmit": true
},
"include": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/shared/test/**/*.ts"]
}

View File

@@ -1,8 +1,9 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
import tailwindcss from "@tailwindcss/vite";
import { configDefaults, defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
plugins: [react(), tailwindcss()],
server: {
host: "127.0.0.1",
port: 8888,
@@ -17,5 +18,6 @@ export default defineConfig({
environment: "jsdom",
setupFiles: ["./src/shared/test/setup.ts"],
globals: true,
exclude: [...configDefaults.exclude, "tests/e2e/**"],
},
});