feat(ai-character): 관리자 인증 셸 구현
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,6 +10,7 @@ mise.toml
|
||||
node_modules/
|
||||
dist/
|
||||
playwright-report/
|
||||
.playwright-mcp/
|
||||
test-results/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
|
||||
196
DESIGN.md
Normal file
196
DESIGN.md
Normal 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
21
components.json
Normal 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
BIN
login-surface.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
1201
package-lock.json
generated
1201
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
BIN
protected-shell-surface.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
@@ -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");
|
||||
});
|
||||
|
||||
258
src/app/App.tsx
258
src/app/App.tsx
@@ -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
37
src/app/admin-pages.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/app/browser-location.ts
Normal file
33
src/app/browser-location.ts
Normal 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
7
src/app/route-paths.ts
Normal 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];
|
||||
31
src/features/auth/api/auth-api.ts
Normal file
31
src/features/auth/api/auth-api.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
32
src/features/auth/model/auth-session-context.ts
Normal file
32
src/features/auth/model/auth-session-context.ts
Normal 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;
|
||||
}
|
||||
35
src/features/auth/model/auth-session-storage.test.ts
Normal file
35
src/features/auth/model/auth-session-storage.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
38
src/features/auth/model/auth-session-storage.ts
Normal file
38
src/features/auth/model/auth-session-storage.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
77
src/features/auth/model/auth-session.tsx
Normal file
77
src/features/auth/model/auth-session.tsx
Normal 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>;
|
||||
}
|
||||
156
src/features/auth/pages/LoginPage.tsx
Normal file
156
src/features/auth/pages/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
src/features/auth/schemas/login-schema.ts
Normal file
8
src/features/auth/schemas/login-schema.ts
Normal 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>;
|
||||
107
src/features/auth/tests/auth-api.test.ts
Normal file
107
src/features/auth/tests/auth-api.test.ts
Normal 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("");
|
||||
});
|
||||
});
|
||||
203
src/features/auth/tests/auth-session.test.tsx
Normal file
203
src/features/auth/tests/auth-session.test.tsx
Normal 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());
|
||||
});
|
||||
});
|
||||
89
src/features/auth/tests/login-page.test.tsx
Normal file
89
src/features/auth/tests/login-page.test.tsx
Normal 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("이메일 또는 비밀번호가 올바르지 않습니다.");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
215
src/shared/api/__tests__/client-auth.test.ts
Normal file
215
src/shared/api/__tests__/client-auth.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
31
src/shared/api/__tests__/client-test-helpers.ts
Normal file
31
src/shared/api/__tests__/client-test-helpers.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
117
src/shared/api/__tests__/client.test.ts
Normal file
117
src/shared/api/__tests__/client.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
66
src/shared/api/__tests__/pagination.test.ts
Normal file
66
src/shared/api/__tests__/pagination.test.ts
Normal 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("루나");
|
||||
});
|
||||
});
|
||||
58
src/shared/api/__tests__/query-client.test.ts
Normal file
58
src/shared/api/__tests__/query-client.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
21
src/shared/api/api-error.ts
Normal file
21
src/shared/api/api-error.ts
Normal 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
131
src/shared/api/client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
24
src/shared/api/pagination.ts
Normal file
24
src/shared/api/pagination.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
22
src/shared/api/query-client.ts
Normal file
22
src/shared/api/query-client.ts
Normal 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
34
src/shared/api/types.ts
Normal 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(),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
12
src/shared/lib/__tests__/formatters.test.ts
Normal file
12
src/shared/lib/__tests__/formatters.test.ts
Normal 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캔");
|
||||
});
|
||||
103
src/shared/lib/crop-image.test.ts
Normal file
103
src/shared/lib/crop-image.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
101
src/shared/lib/crop-image.ts
Normal file
101
src/shared/lib/crop-image.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
23
src/shared/lib/formatters.ts
Normal file
23
src/shared/lib/formatters.ts
Normal 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)))}캔`;
|
||||
}
|
||||
3
src/shared/test/server.ts
Normal file
3
src/shared/test/server.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { setupServer } from "msw/node";
|
||||
|
||||
export const server = setupServer();
|
||||
11
src/shared/test/setup-isolation.test.ts
Normal file
11
src/shared/test/setup-isolation.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
117
src/shared/ui/__tests__/admin-audio-player.test.tsx
Normal file
117
src/shared/ui/__tests__/admin-audio-player.test.tsx
Normal 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();
|
||||
});
|
||||
60
src/shared/ui/__tests__/confirm-deactivate-dialog.test.tsx
Normal file
60
src/shared/ui/__tests__/confirm-deactivate-dialog.test.tsx
Normal 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());
|
||||
});
|
||||
41
src/shared/ui/__tests__/file-field.test.tsx
Normal file
41
src/shared/ui/__tests__/file-field.test.tsx
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
21
src/shared/ui/__tests__/icon-only-action.test.tsx
Normal file
21
src/shared/ui/__tests__/icon-only-action.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
68
src/shared/ui/__tests__/image-crop-dialog.test.tsx
Normal file
68
src/shared/ui/__tests__/image-crop-dialog.test.tsx
Normal 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();
|
||||
});
|
||||
29
src/shared/ui/__tests__/page-state.test.tsx
Normal file
29
src/shared/ui/__tests__/page-state.test.tsx
Normal 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();
|
||||
});
|
||||
42
src/shared/ui/__tests__/resource-pagination.test.tsx
Normal file
42
src/shared/ui/__tests__/resource-pagination.test.tsx
Normal 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();
|
||||
});
|
||||
18
src/shared/ui/__tests__/responsive-resource-list.test.tsx
Normal file
18
src/shared/ui/__tests__/responsive-resource-list.test.tsx
Normal 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();
|
||||
});
|
||||
42
src/shared/ui/__tests__/search-toolbar.test.tsx
Normal file
42
src/shared/ui/__tests__/search-toolbar.test.tsx
Normal 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("루나");
|
||||
});
|
||||
35
src/shared/ui/__tests__/status-badge.test.tsx
Normal file
35
src/shared/ui/__tests__/status-badge.test.tsx
Normal 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 자동 전환");
|
||||
});
|
||||
});
|
||||
48
src/shared/ui/__tests__/unsaved-changes-guard.test.tsx
Normal file
48
src/shared/ui/__tests__/unsaved-changes-guard.test.tsx
Normal 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);
|
||||
});
|
||||
25
src/shared/ui/__tests__/upload-progress.test.tsx
Normal file
25
src/shared/ui/__tests__/upload-progress.test.tsx
Normal 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);
|
||||
});
|
||||
141
src/shared/ui/admin-audio-player.tsx
Normal file
141
src/shared/ui/admin-audio-player.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
src/shared/ui/audio-playback-context.ts
Normal file
9
src/shared/ui/audio-playback-context.ts
Normal 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);
|
||||
22
src/shared/ui/audio-playback-provider.tsx
Normal file
22
src/shared/ui/audio-playback-provider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
src/shared/ui/confirm-deactivate-dialog.tsx
Normal file
38
src/shared/ui/confirm-deactivate-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/shared/ui/file-field.tsx
Normal file
48
src/shared/ui/file-field.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/shared/ui/icon-only-action.tsx
Normal file
24
src/shared/ui/icon-only-action.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
153
src/shared/ui/image-crop-dialog.tsx
Normal file
153
src/shared/ui/image-crop-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
src/shared/ui/page-state.tsx
Normal file
66
src/shared/ui/page-state.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
32
src/shared/ui/resource-pagination.tsx
Normal file
32
src/shared/ui/resource-pagination.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/shared/ui/responsive-resource-list.tsx
Normal file
16
src/shared/ui/responsive-resource-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/shared/ui/search-toolbar.tsx
Normal file
37
src/shared/ui/search-toolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
src/shared/ui/status-badge.tsx
Normal file
91
src/shared/ui/status-badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
src/shared/ui/unsaved-changes-guard.tsx
Normal file
68
src/shared/ui/unsaved-changes-guard.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
39
src/shared/ui/upload-progress.tsx
Normal file
39
src/shared/ui/upload-progress.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
src/shared/ui/use-audio-playback.ts
Normal file
19
src/shared/ui/use-audio-playback.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
50
src/shared/ui/use-modal-focus.ts
Normal file
50
src/shared/ui/use-modal-focus.ts
Normal 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 };
|
||||
}
|
||||
31
src/shared/validation/audio-file-policy.ts
Normal file
31
src/shared/validation/audio-file-policy.ts
Normal 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 };
|
||||
}
|
||||
93
src/shared/validation/file-media-policy.test.ts
Normal file
93
src/shared/validation/file-media-policy.test.ts
Normal 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" });
|
||||
});
|
||||
31
src/shared/validation/file-validation.ts
Normal file
31
src/shared/validation/file-validation.ts
Normal 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 };
|
||||
}
|
||||
15
src/shared/validation/image-policy.ts
Normal file
15
src/shared/validation/image-policy.ts
Normal 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 };
|
||||
}
|
||||
165
src/styles/__tests__/design-system.test.ts
Normal file
165
src/styles/__tests__/design-system.test.ts
Normal 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
289
src/styles/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
77
tests/e2e/accessibility-shell.spec.ts
Normal file
77
tests/e2e/accessibility-shell.spec.ts
Normal 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
43
tests/e2e/auth.spec.ts
Normal 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();
|
||||
});
|
||||
@@ -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("관리자 로그인");
|
||||
});
|
||||
|
||||
@@ -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/**"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
27
tsconfig.test.json
Normal 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"]
|
||||
}
|
||||
@@ -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/**"],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user