diff --git a/package.json b/package.json index 0ca4c81..6d90f53 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@tanstack/react-query": "5.101.4", "react": "19.2.8", "react-dom": "19.2.8", + "react-router-dom": "7.18.1", "zod": "4.4.3" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index edec421..d1f273e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: react-dom: specifier: 19.2.8 version: 19.2.8(react@19.2.8) + react-router-dom: + specifier: 7.18.1 + version: 7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) zod: specifier: 4.4.3 version: 4.4.3 @@ -1260,6 +1263,23 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-router-dom@7.18.1: + resolution: {integrity: sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.18.1: + resolution: {integrity: sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + react@19.2.8: resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} @@ -1312,6 +1332,9 @@ packages: engines: {node: '>=10'} hasBin: true + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + set-cookie-parser@3.1.2: resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==} @@ -2604,6 +2627,20 @@ snapshots: react-is@17.0.2: {} + react-router-dom@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-router: 7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + + react-router@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + cookie: 1.1.1 + react: 19.2.8 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.8(react@19.2.8) + react@19.2.8: {} rechoir@0.8.0: @@ -2663,6 +2700,8 @@ snapshots: semver@7.8.5: {} + set-cookie-parser@2.7.2: {} + set-cookie-parser@3.1.2: {} shebang-command@2.0.0: diff --git a/src/bootstrap/main.jsx b/src/bootstrap/main.jsx index 5d40e93..79d45aa 100644 --- a/src/bootstrap/main.jsx +++ b/src/bootstrap/main.jsx @@ -1,18 +1,10 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; +import { createAnonymousSessionAdapter } from "../adapters/auth/external-session-adapter.js"; +import { AppRouter } from "../presentation/routes/app-router.jsx"; import { BootConfigError, loadRuntimeConfig } from "./load-runtime-config.js"; -/** @param {{ environment: string }} props */ -function BootstrapShell({ environment }) { - return ( - - Clean Architecture Frontend - {environment} 런타임 계약이 검증되었습니다. - - ); -} - /** @param {{ supportReference: string }} props */ function BootErrorShell({ supportReference }) { return ( @@ -36,7 +28,10 @@ async function boot() { const runtime = await loadRuntimeConfig(); root.render( - + , ); } catch (error) { diff --git a/src/contracts/routes.js b/src/contracts/routes.js new file mode 100644 index 0000000..e4ad773 --- /dev/null +++ b/src/contracts/routes.js @@ -0,0 +1,63 @@ +/** + * @typedef {{ + * routeId: string, + * path: string, + * paramsSchema: string | null, + * searchSchema: string | null, + * access: "public" | "session-required" | "integration-defined", + * loadingSurface: string, + * errorSurface: string, + * chunkId: string + * }} RouteDefinition + */ + +/** @param {RouteDefinition} definition */ +const route = (definition) => Object.freeze(definition); + +export const ROUTE_REGISTRY = Object.freeze({ + APP_HOME: route({ + routeId: "APP_HOME", + path: "/", + paramsSchema: null, + searchSchema: null, + access: "public", + loadingSurface: "app-shell", + errorSurface: "route-boundary", + chunkId: "route-home", + }), + SAMPLE_RESOURCE_LIST: route({ + routeId: "SAMPLE_RESOURCE_LIST", + path: "/sample/resources", + paramsSchema: null, + searchSchema: "SampleResourceListQuery", + access: "integration-defined", + loadingSurface: "sample-resource-list", + errorSurface: "feature-boundary", + chunkId: "route-sample-resources", + }), + NOT_FOUND: route({ + routeId: "NOT_FOUND", + path: "*", + paramsSchema: null, + searchSchema: null, + access: "public", + loadingSurface: "none", + errorSurface: "not-found", + chunkId: "route-not-found", + }), +}); + +/** @param {string} routeId */ +export function getRoute(routeId) { + const registry = /** @type {Record>} */ ( + ROUTE_REGISTRY + ); + const selected = registry[routeId]; + if (!selected) throw new Error(`Unregistered route: ${routeId}`); + return selected; +} + +/** @param {string} routeId */ +export function routePath(routeId) { + return getRoute(routeId).path; +} diff --git a/src/presentation/routes/app-router.jsx b/src/presentation/routes/app-router.jsx new file mode 100644 index 0000000..9157260 --- /dev/null +++ b/src/presentation/routes/app-router.jsx @@ -0,0 +1,79 @@ +import { + BrowserRouter, + Link, + Route, + Routes, +} from "react-router-dom"; + +import { routePath } from "../../contracts/routes.js"; +import { decideRouteAccess } from "./navigation-policy.js"; + +function HomePage() { + return ( + + Clean Architecture Frontend + 런타임 계약이 검증되었습니다. + 샘플 리소스 + + ); +} + +function SamplePlaceholder() { + return ( + + 샘플 리소스 + 계약 fixture를 준비하고 있습니다. + + ); +} + +function NotFoundPage() { + return ( + + 페이지를 찾을 수 없습니다. + 홈으로 이동 + + ); +} + +/** + * @param {{ + * authSession: import("../../application/ports/auth-session-port.js").AuthSessionPort + * }} props + */ +function GuardedSampleRoute({ authSession }) { + const decision = decideRouteAccess( + "SAMPLE_RESOURCE_LIST", + authSession.getState(), + ); + if (!decision.allowed) { + return ( + + 세션이 필요합니다. + 로그인 + + ); + } + return ; +} + +/** + * @param {{ + * authSession: import("../../application/ports/auth-session-port.js").AuthSessionPort, + * basename?: string + * }} props + */ +export function AppRouter({ authSession, basename = "/" }) { + return ( + + + } /> + } + /> + } /> + + + ); +} diff --git a/src/presentation/routes/navigation-policy.js b/src/presentation/routes/navigation-policy.js new file mode 100644 index 0000000..9fa5133 --- /dev/null +++ b/src/presentation/routes/navigation-policy.js @@ -0,0 +1,37 @@ +import { getRoute } from "../../contracts/routes.js"; + +/** + * @param {string} routeId + * @param {import("../../application/ports/auth-session-port.js").SessionState} sessionState + */ +export function decideRouteAccess(routeId, sessionState) { + const route = getRoute(routeId); + if (route.access === "public") return { allowed: true, action: "none" }; + if (sessionState === "authenticated") { + return { allowed: true, action: "none" }; + } + if (sessionState === "recovery-pending") { + return { allowed: false, action: "wait-for-session" }; + } + return { allowed: false, action: "show-sign-in" }; +} + +export function createRedirectLoopGuard() { + const visitedPairs = new Set(); + + return Object.freeze({ + /** + * @param {string} source + * @param {string} target + */ + allow(source, target) { + const pair = `${source}->${target}`; + if (source === target || visitedPairs.has(pair)) return false; + visitedPairs.add(pair); + return true; + }, + reset() { + visitedPairs.clear(); + }, + }); +} diff --git a/tests/component/router.test.jsx b/tests/component/router.test.jsx new file mode 100644 index 0000000..e253c63 --- /dev/null +++ b/tests/component/router.test.jsx @@ -0,0 +1,24 @@ +// @vitest-environment jsdom + +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.js"; +import { AppRouter } from "../../src/presentation/routes/app-router.jsx"; + +describe("application router", () => { + it("renders not-found without making an API request", () => { + window.history.pushState({}, "", "/missing"); + render(); + expect( + screen.getByRole("heading", { name: "페이지를 찾을 수 없습니다." }), + ).toBeVisible(); + }); + + it("shows session-required UX without claiming authorization", () => { + window.history.pushState({}, "", "/sample/resources"); + render(); + expect(screen.getByRole("heading", { name: "세션이 필요합니다." })).toBeVisible(); + expect(screen.getByRole("button", { name: "로그인" })).toBeVisible(); + }); +}); diff --git a/tests/unit/navigation-policy.test.js b/tests/unit/navigation-policy.test.js new file mode 100644 index 0000000..fa61fae --- /dev/null +++ b/tests/unit/navigation-policy.test.js @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import { + createRedirectLoopGuard, + decideRouteAccess, +} from "../../src/presentation/routes/navigation-policy.js"; +import { ROUTE_REGISTRY } from "../../src/contracts/routes.js"; + +describe("route registry", () => { + it("matches the stable registry snapshot", () => { + expect(ROUTE_REGISTRY).toMatchInlineSnapshot(` + { + "APP_HOME": { + "access": "public", + "chunkId": "route-home", + "errorSurface": "route-boundary", + "loadingSurface": "app-shell", + "paramsSchema": null, + "path": "/", + "routeId": "APP_HOME", + "searchSchema": null, + }, + "NOT_FOUND": { + "access": "public", + "chunkId": "route-not-found", + "errorSurface": "not-found", + "loadingSurface": "none", + "paramsSchema": null, + "path": "*", + "routeId": "NOT_FOUND", + "searchSchema": null, + }, + "SAMPLE_RESOURCE_LIST": { + "access": "integration-defined", + "chunkId": "route-sample-resources", + "errorSurface": "feature-boundary", + "loadingSurface": "sample-resource-list", + "paramsSchema": null, + "path": "/sample/resources", + "routeId": "SAMPLE_RESOURCE_LIST", + "searchSchema": "SampleResourceListQuery", + }, + } + `); + }); + + it("treats client access as a UX hint, not authorization", () => { + expect(decideRouteAccess("APP_HOME", "unauthenticated")).toEqual({ + allowed: true, + action: "none", + }); + expect(decideRouteAccess("SAMPLE_RESOURCE_LIST", "unauthenticated")).toEqual({ + allowed: false, + action: "show-sign-in", + }); + expect(decideRouteAccess("SAMPLE_RESOURCE_LIST", "authenticated")).toEqual({ + allowed: true, + action: "none", + }); + }); + + it("allows at most one automatic redirect per source-target pair", () => { + const guard = createRedirectLoopGuard(); + expect(guard.allow("/private", "/signin")).toBe(true); + expect(guard.allow("/private", "/signin")).toBe(false); + expect(guard.allow("/signin", "/signin")).toBe(false); + }); +});
{environment} 런타임 계약이 검증되었습니다.
런타임 계약이 검증되었습니다.
계약 fixture를 준비하고 있습니다.