diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index b1062d3..9fd4fdb 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -29,10 +29,42 @@ module.exports = { from: { path: "^src/adapters" }, to: { path: "^src/(presentation|bootstrap)" }, }, + { + name: "feature-domain-is-framework-neutral", + severity: "error", + from: { path: "^src/features/[^/]+/domain" }, + to: { + path: "^(src/(application|presentation|adapters|bootstrap)|src/features/[^/]+/(application|adapters|presentation)|react|react-dom|@tanstack)", + }, + }, + { + name: "feature-application-does-not-know-runtime", + severity: "error", + from: { path: "^src/features/[^/]+/application" }, + to: { + path: "^(src/(presentation|adapters|bootstrap)|src/features/[^/]+/(adapters|presentation)|react|react-dom|@tanstack)", + }, + }, + { + name: "feature-presentation-does-not-know-outbound-adapters", + severity: "error", + from: { path: "^src/features/[^/]+/presentation" }, + to: { + path: "^(src/(adapters|bootstrap)|src/features/[^/]+/adapters|@tanstack)", + }, + }, + { + name: "feature-adapters-do-not-know-presentation", + severity: "error", + from: { path: "^src/features/[^/]+/adapters" }, + to: { + path: "^(src/(presentation|bootstrap)|src/features/[^/]+/presentation)", + }, + }, { name: "concrete-adapters-compose-only-in-bootstrap", severity: "error", - from: { path: "^src/(domain|application|presentation|contracts|sample)" }, + from: { path: "^src/(domain|application|presentation|contracts)" }, to: { path: "^src/adapters" }, }, { diff --git a/artifacts/tests/a11y-manual/SAMPLE_RESOURCE_LIST.md b/artifacts/tests/a11y-manual/REFERENCE_RESOURCE_LIST.md similarity index 92% rename from artifacts/tests/a11y-manual/SAMPLE_RESOURCE_LIST.md rename to artifacts/tests/a11y-manual/REFERENCE_RESOURCE_LIST.md index 5e160e8..dede1df 100644 --- a/artifacts/tests/a11y-manual/SAMPLE_RESOURCE_LIST.md +++ b/artifacts/tests/a11y-manual/REFERENCE_RESOURCE_LIST.md @@ -1,7 +1,7 @@ # SAMPLE_RESOURCE_LIST accessibility review Status: pending-manual-review -Route ID: SAMPLE_RESOURCE_LIST +Route ID: REFERENCE_RESOURCE_LIST Release ID: Reviewer: Reviewed at: diff --git a/config/ci/gates.json b/config/ci/gates.json index 79d6678..8104985 100644 --- a/config/ci/gates.json +++ b/config/ci/gates.json @@ -109,9 +109,15 @@ }, "FE-GATE-007": { "name": "integration", - "steps": [{ "script": "test:integration", "expect": "pass" }], + "steps": [ + { "script": "test:integration", "expect": "pass" }, + { "script": "test:reference-feature", "expect": "pass" } + ], "logPath": "artifacts/quality/gates/FE-GATE-007.txt", - "evidence": ["artifacts/tests/integration.xml"], + "evidence": [ + "artifacts/tests/integration.xml", + "artifacts/tests/reference-feature.xml" + ], "retentionClass": "merge-cycle" }, "FE-GATE-008": { @@ -134,7 +140,7 @@ "artifacts/tests/a11y-manual/EXAMPLES_UI.md", "artifacts/tests/a11y-manual/EXAMPLES_STATES.md", "artifacts/tests/a11y-manual/EXAMPLES_AUTH.md", - "artifacts/tests/a11y-manual/SAMPLE_RESOURCE_LIST.md", + "artifacts/tests/a11y-manual/REFERENCE_RESOURCE_LIST.md", "artifacts/tests/a11y-manual/NOT_FOUND.md", "artifacts/tests/a11y-manual/report.json" ], @@ -256,7 +262,7 @@ "retentionClass": "release-coherence" }, "FE-GATE-020": { - "name": "sample-removal", + "name": "reference-feature-removal", "steps": [{ "script": "test:sample-removal", "expect": "pass" }], "logPath": "artifacts/quality/gates/FE-GATE-020.txt", "evidence": ["artifacts/tests/sample-removal.xml"], diff --git a/config/contracts/registry-governance.json b/config/contracts/registry-governance.json index 36afe18..ed7db13 100644 --- a/config/contracts/registry-governance.json +++ b/config/contracts/registry-governance.json @@ -3,17 +3,17 @@ "registries": [ { "registryId": "FE-REG-ROUTE", - "path": "src/contracts/routes.js", + "path": "src/features/installed-feature-contracts.js", "exportName": "ROUTE_REGISTRY", "owner": "feature-routing-navigation-guard-contract", "uniqueFields": ["routeId", "path", "chunkId"], "allowedValues": { "paramsSchema": [null, "NotFoundSplat"], - "searchSchema": [null, "SampleResourceListQuery"], + "searchSchema": [null, "ReferenceResourceListQuery"], "loadingSurface": [ "app-shell", "example-page", - "sample-resource-list", + "reference-resource-list", "none" ], "errorSurface": [ @@ -26,7 +26,7 @@ "route-examples-ui", "route-examples-states", "route-examples-auth", - "route-sample-resources", + "route-reference-resources", "route-not-found" ] }, @@ -50,7 +50,7 @@ }, { "registryId": "FE-REG-ROUTE-RUNTIME", - "path": "src/contracts/route-runtime-contract.js", + "path": "src/features/installed-feature-contracts.js", "exportName": "ROUTE_RUNTIME_CONTRACT", "owner": "feature-frontend-routing-release-recovery-runtime", "requiredFields": [ @@ -66,11 +66,11 @@ "ui-gallery-page", "state-gallery-page", "auth-example-page", - "sample-contract-page", + "reference-resource-page", "not-found-page" ], "paramsCodec": ["none", "NotFoundSplat"], - "searchCodec": ["none", "SampleResourceListQuery"] + "searchCodec": ["none", "ReferenceResourceListQuery"] }, "references": [ { @@ -82,7 +82,7 @@ }, { "registryId": "FE-REG-API", - "path": "src/contracts/api-operations.js", + "path": "src/features/installed-feature-contracts.js", "exportName": "API_OPERATIONS", "owner": "feature-api-client-response-envelope-contract", "requiredFields": [ @@ -139,7 +139,7 @@ }, { "registryId": "FE-REG-QUERY", - "path": "src/contracts/query-keys.js", + "path": "src/features/installed-feature-contracts.js", "exportName": "QUERY_REGISTRY", "owner": "feature-server-state-caching-contract", "requiredFields": [ diff --git a/docs/accessibility/manual-checklist.md b/docs/accessibility/manual-checklist.md index 3ebfe3a..6bfac32 100644 --- a/docs/accessibility/manual-checklist.md +++ b/docs/accessibility/manual-checklist.md @@ -4,7 +4,7 @@ Automated axe checks do not establish WCAG conformance. A human reviewer must review all six route records in `artifacts/tests/a11y-manual/` against one release candidate and sign them. The required scope is derived from the route registry: `APP_HOME`, `EXAMPLES_UI`, `EXAMPLES_STATES`, `EXAMPLES_AUTH`, -`SAMPLE_RESOURCE_LIST`, and `NOT_FOUND`. Copy the template fields exactly; the +`REFERENCE_RESOURCE_LIST`, and `NOT_FOUND`. Copy the template fields exactly; the gate rejects blank identity/timestamp/signature fields, pending verdicts, mismatched release IDs, or missing routes. diff --git a/docs/architecture/frontend-platform-capability-review.md b/docs/architecture/frontend-platform-capability-review.md index baac965..96cd248 100644 --- a/docs/architecture/frontend-platform-capability-review.md +++ b/docs/architecture/frontend-platform-capability-review.md @@ -33,14 +33,13 @@ 특히 다음은 선행 해결이 필요하다. -RP-01~RP-04에서 TypeScript 도구 안전망, application runtime 주입, +RP-01~RP-05에서 TypeScript 도구 안전망, application runtime 주입, query/mutation inbound adapter, HTTP 실행 계약과 executable route/release -recovery 계약은 구현됐다. 현재 선행 해결 +recovery 계약, 제거 가능한 reference 수직 슬라이스는 구현됐다. 현재 선행 해결 대상은 다음과 같다. -1. 전체를 제거할 수 있는 실제 reference feature -2. 폼, 페이지 템플릿, 확장된 디자인 시스템과 컴포넌트 워크벤치 -3. 국제화, diagnostics, optional adapter recipe와 심화 품질 게이트 +1. 폼, 페이지 템플릿, 확장된 디자인 시스템과 컴포넌트 워크벤치 +2. 국제화, diagnostics, optional adapter recipe와 심화 품질 게이트 따라서 현재 상태를 “프론트 공통부가 모두 구현됐다”고 표현하면 범위가 과장된다. 더 정확한 표현은 다음과 같다. @@ -70,7 +69,7 @@ recovery 계약은 구현됐다. 현재 선행 해결 | 오류 모델 | 부분 준비 | error registry와 normalization 존재 | typed discriminated union과 계층별 mapper | | 검증 | 부분 준비 | runtime/API Zod parse 결과를 실제 request에 사용 | route/form/domain 경계를 추가 | | 인증 연동 | 준비됨/프로젝트 선택 | opaque auth owner와 demo seam 존재 | 인증 방식별 recipe; 기본 token 저장소는 추가하지 않음 | -| 서버 상태 | 부분 준비 | 제한된 query/mutation bridge와 lifecycle test | RP-05 reference route에서 실제 feature 연결 | +| 서버 상태 | 준비됨 | reference route의 query/mutation, cancellation, stale, optimistic/conflict/rollback | feature별 query contribution recipe 유지 | | 클라이언트 상태 | 부분 준비 | local state, theme context, session external store | 상태 소유권 표와 typed external-store 예제 | | 범용 global store | 프로젝트 선택 | 별도 라이브러리 없음 | 필요 조건에 따라 Zustand/Redux Toolkit/state machine 선택 | | 라우팅 | 준비됨 | Data Router, typed runtime map, codec, metadata consumer, bounded chunk recovery | reference feature route와 release E2E로 사용 범위 확장 | @@ -86,7 +85,7 @@ recovery 계약은 구현됐다. 현재 선행 해결 | 비동기 상태 불변식 | 준비됨 | 배타적 typed overlay, stale latch, 실제 retry/conflict action | reference 화면에서 전체 상태 전시 | | 단위·통합·E2E | 준비됨 | Vitest, RTL, MSW, Playwright 3엔진 | TS 테스트 검사, 실제 bootstrap 통합, 위험 시나리오 보강 | | UI 회귀 검증 | 미제공 | axe/reflow는 있으나 visual baseline 없음 | Storybook 또는 동급 workshop과 시각 회귀 | -| 샘플 제거 | 부분 준비 | fixture 제거 테스트 존재 | sample domain/registry/runtime 전체 제거 검증 | +| 샘플 제거 | 준비됨 | feature/catalog/test 제거 후 type/architecture/registry/test/home/build 8단계 검증 | 새 contribution도 같은 제거 gate에 포함 | | registry·compatibility 집행 | 부분 준비 | registry와 gate는 있으나 실제 before/after 및 orphan 검사가 제한적 | type/reference/orphan/diff/migration을 자동 검증 | | 공급망 검사 | 부분 준비 | lockfile·문서·gate는 있으나 실제 transitive 취약점/license/SBOM 깊이가 부족 | pinned scanner와 policy exception/증적 연결 | | realtime·offline·file 등 | 프로젝트 선택 | 현재 없음 | port/adapter recipe와 선택 기준 제공 | @@ -159,8 +158,8 @@ timeout, retry, decoder, mapper 책임을 분리해야 한다. application에는 #### RP-04에서 route registry를 실행 계약으로 전환 -`src/contracts/routes.js`와 직렬화 가능한 -`src/contracts/route-runtime-contract.js`를 기준으로 +platform route 계약과 `src/features/installed-feature-contracts.js`의 직렬화 +가능한 contribution을 기준으로 `src/presentation/routes/app-router.tsx`가 Data Router route object와 navigation을 생성한다. `route-runtime.tsx`는 lazy component의 실행 map만 소유하며 contract/runtime 누락과 orphan은 TypeScript negative fixture와 registry @@ -179,16 +178,18 @@ gate가 모두 거절한다. 연결하고, no-store manifest 재조회와 build/release 쌍별 1회 reload를 production application input까지 연결한다. -#### reference feature가 완전히 제거되지 않는다 +#### RP-05에서 제거 가능한 reference feature 구현 -현재 sample removal gate는 `src/sample/contract-fixture`만 삭제한다. sample API -operation, schema, mapper, domain model, query key는 다른 production 경로에 남는다. -반면 화면에 노출된 `/sample/resources`는 실제 query 수직 흐름을 실행하지 않는다. +`src/features/reference-feature`가 domain, application input, outbound gateway, +DTO/schema, mapper, route/API/query contract, query/mutation controller와 page를 +한 소유 경계에 둔다. production composition은 generic feature input catalog를 +통해 이 input을 주입하며 UI는 HTTP나 output port를 직접 보지 않는다. -reference feature는 domain, application input/output, schemas, operation, -mapper, query controller, pages, tests를 한 소유 경계 아래 모아야 한다. 해당 모듈과 -registry contribution을 제거한 뒤 typecheck, architecture, test, build가 모두 -통과해야 “제거 가능”으로 판정한다. +`test:sample-removal`은 임시 복제본에서 feature source/tests를 삭제하고 installed +contract/runtime/adapter catalog를 빈 목록으로 재생성한다. 그 뒤 typecheck, +architecture, registry, unit/integration, home smoke, build와 fixture ID 잔여 +0개를 검사한다. 설치 모드에서는 MSW를 사용한 bootstrap → router → application +→ HTTP → schema → mapper → query cache → page 수직 테스트가 실행된다. #### 비동기·복구 상태의 불변식이 닫혀 있지 않다 diff --git a/docs/architecture/frontend-ports-adapters-and-boundaries.md b/docs/architecture/frontend-ports-adapters-and-boundaries.md index fa86802..39ee046 100644 --- a/docs/architecture/frontend-ports-adapters-and-boundaries.md +++ b/docs/architecture/frontend-ports-adapters-and-boundaries.md @@ -116,7 +116,7 @@ bootstrap은 page별 orchestration이나 업무 규칙을 소유하지 않는다 | `src/adapters` | HTTP, auth, storage, cache, telemetry 구현 | outbound adapter | | `src/bootstrap` | runtime config와 구현 조립 | 유일한 composition root | | `src/contracts` | 여러 계층의 registry가 혼재 | 소유 계층으로 분산 | -| `src/sample` | 제거 가능한 예제 일부 | 완전한 removable reference feature | +| `src/features/reference-feature` | 완전한 제거 가능 수직 예제 | installed contribution과 8단계 제거 gate 유지 | 현재 구조가 잘 제공하는 기반은 다음과 같다. @@ -154,15 +154,14 @@ RP-03 구현으로 HTTP와 server-state 경계도 다음처럼 연결됐다. - HTTP가 자동 network retry를 소유하고 query/mutation adapter의 vendor retry는 비활성화한다. -RP-04에서 첫 번째 실행 불일치는 닫혔다. route registry와 runtime map은 +RP-04에서 route 실행 불일치는 닫혔다. route registry와 runtime map은 Data Router tree, codec, surface, title, navigation, chunk/release recovery의 단일 조립 입력이며 registry/type/build 검증이 누락과 orphan을 거절한다. -후속 브랜치에서 닫아야 할 실행 불일치는 다음과 같다. - -1. 제거 테스트는 `src/sample/contract-fixture`만 제거하며, sample API - operation, Zod schema, mapper, domain model과 query key는 다른 경로에 - 남는다. +RP-05에서 두 번째 불일치도 닫혔다. feature별 domain/application/adapter/ +contract/presentation은 `src/features/reference-feature`가 소유하고, generic +installed catalog만 bootstrap과 router에 노출된다. 제거 gate는 feature와 test를 +삭제한 복제본에서 전체 P0 경로를 다시 실행한다. 이 문서의 목표 구조는 기존 기반을 폐기하는 것이 아니라 이러한 불일치를 제거하는 것이다. @@ -1222,11 +1221,11 @@ contract와 실패 분기를 우선한다. ### 26.5 Reference feature -- [ ] route부터 API mapper와 화면까지 완전한 수직 경로가 실행된다. -- [ ] list/create 등 최소 query와 mutation 예제가 있다. -- [ ] loading/empty/error/refresh/conflict 상태가 있다. -- [ ] reference feature 전체 삭제 후 typecheck/test/build가 통과한다. -- [ ] built asset에 reference operation, schema, mapper가 남지 않는다. +- [x] route부터 API mapper와 화면까지 완전한 수직 경로가 실행된다. +- [x] list/create 등 최소 query와 mutation 예제가 있다. +- [x] loading/empty/error/refresh/conflict 상태가 있다. +- [x] reference feature 전체 삭제 후 typecheck/architecture/registry/test/home/build가 통과한다. +- [x] 제거 모드 built asset에 reference operation, schema, mapper가 남지 않는다. ### 26.6 품질 diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index dc7ffe7..b824bdb 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -44,12 +44,13 @@ flowchart LR The current executable route tree is mounted only after runtime configuration and release-manifest coherence pass. It receives the composed query client, credential-opaque session port, storage port, telemetry port, and immutable -build ID. Visible starter pages do not depend on the removable sample fixture. +build ID. Generic starter pages do not depend on the removable reference +feature. This describes the current starter composition, not the completed target. The capability review found that raw outbound capabilities still reach the React tree, the composed application facade is not yet its entry point, and several -route, HTTP, recovery, telemetry, and sample-removal contracts are only +route, HTTP, recovery, telemetry, and reference-feature removal contracts are only partially connected. Use the following documents for the evidence and migration plan: diff --git a/docs/architecture/routing-pages-and-patterns.md b/docs/architecture/routing-pages-and-patterns.md index 4d4e71b..df0cf4b 100644 --- a/docs/architecture/routing-pages-and-patterns.md +++ b/docs/architecture/routing-pages-and-patterns.md @@ -23,8 +23,8 @@ RP-04 이후 현재 구현에는 다음 장점이 있다. - route heading focus와 비동기/render error boundary가 있다. - redirect loop와 chunk recovery가 bounded production call graph에 연결돼 있다. -`src/contracts/routes.js`, `src/contracts/route-runtime-contract.js`, -`src/presentation/routes/route-runtime.tsx`의 완전성은 TypeScript와 registry +platform route 계약, `src/features/installed-feature-contracts.js`, +`src/features/installed-feature-runtimes.tsx`의 완전성은 TypeScript와 registry negative fixture가 함께 검사한다. params/search codec, loading/error surface, access, title, navigation, chunk ID는 `src/presentation/routes/app-router.tsx`에서 모두 소비된다. built Vite diff --git a/docs/architecture/starter-experience.md b/docs/architecture/starter-experience.md index d5e37ed..313022d 100644 --- a/docs/architecture/starter-experience.md +++ b/docs/architecture/starter-experience.md @@ -11,7 +11,8 @@ primitives, and state surfaces remain reusable. validated config + coherent release manifest -> concrete adapters -> QueryClientProvider - -> BrowserRouter + -> ApplicationProvider + -> RouterProvider -> ThemeProvider -> SessionProvider -> AppShell @@ -31,7 +32,7 @@ remain stable in the shell. | `EXAMPLES_UI` | `/examples/ui` | public | interactive primitives and tokens | | `EXAMPLES_STATES` | `/examples/states` | public | async and access state matrix | | `EXAMPLES_AUTH` | `/examples/auth` | public | session integration controls | -| `SAMPLE_RESOURCE_LIST` | `/sample/resources` | integration-defined | protected integration seam | +| `REFERENCE_RESOURCE_LIST` | `/examples/reference-resources` | integration-defined | removable vertical slice | | `NOT_FOUND` | `*` | public | safe navigation recovery | Navigation labels and order come from `ROUTE_REGISTRY`; the sidebar does not @@ -54,24 +55,25 @@ An external owner implements `readState`, `subscribe`, `beginSignIn`, ## Extending the starter -The steps below describe the current extension path. The platform review found -that several route metadata fields and the composed application facade are not -yet connected end to end. New platform work should follow +The steps below describe the current extension path. New platform work should follow [routing, page templates, and reusable patterns](./routing-pages-and-patterns.md) and the [TypeScript, state, and data-flow target](./typescript-state-and-data-flow.md) rather than adding another independent route or data-loading convention. -1. Register the route path, access hint, title, chunk, loading surface, error - surface, and optional navigation metadata in `src/contracts/routes.js`. -2. Add a lazy page in `src/presentation/` and render it through `RouteSurface`. -3. Use application ports or use cases; do not import concrete adapters. +1. Add a serializable contribution under the feature ownership boundary and + install it through `src/features/installed-feature-contracts.js`. +2. Add the lazy component and route codecs through + `src/features/installed-feature-runtimes.tsx`. +3. Compose feature application inputs and outbound gateways only through + `src/features/installed-feature-adapters.ts`. 4. Use the semantic tokens, UI primitives, and state surfaces before adding a project-specific variant. 5. Add component behavior, all-engine E2E, automated axe, and signed manual route evidence. -6. Run `test:sample-removal` to prove the visible starter still builds without - `src/sample/contract-fixture`. +6. Run `test:sample-removal` to prove the generic starter typechecks, passes + architecture/registry/tests/home smoke, and builds without the complete + reference feature. Theme preference is the public `COLOR_SCHEME` storage contract. Authentication tokens and other secrets remain forbidden storage keys. diff --git a/docs/styling/design-tokens.md b/docs/styling/design-tokens.md index baf91eb..b2940c3 100644 --- a/docs/styling/design-tokens.md +++ b/docs/styling/design-tokens.md @@ -37,8 +37,9 @@ Arbitrary-value policy: - user-controlled or runtime-composed class strings are forbidden - class variants must be selected from a closed static map -The removable sample may demonstrate tokens, but production starter modules do -not import from `src/sample/contract-fixture`. +The removable reference feature may demonstrate tokens, but generic production +starter modules do not import its domain, application, adapter, or presentation +implementation. This file documents the currently implemented token and primitive baseline. The [design-system platform contract](./design-system-platform.md) defines the diff --git a/docs/testing/frontend-platform-testing-strategy.md b/docs/testing/frontend-platform-testing-strategy.md index ddf862e..3f5d5fe 100644 --- a/docs/testing/frontend-platform-testing-strategy.md +++ b/docs/testing/frontend-platform-testing-strategy.md @@ -446,6 +446,14 @@ tests/support/query/ helper는 production default를 복사하지 않는다. production factory를 호출하고 필요한 시간·retry만 test override로 주입한다. +RP-05의 executable reference matrix는 +`tests/features/reference-feature`에 모여 있다. 설치 모드에서는 URL codec과 +query key/HTTP search의 동일성, DTO 차단, loading/success/empty/terminal, +refreshing/stale, pending/duplicate/optimistic/conflict/rollback과 MSW production +composition을 검증한다. 제거 모드는 feature source/tests와 installed +contract/runtime/adapter contribution을 제거한 복제본에서 P0 gate와 built asset +잔여 0개를 다시 검증한다. + ## 8. Form 테스트 Form test는 field primitive, form controller, application command mapping을 diff --git a/eslint.config.js b/eslint.config.js index ac30084..c2a92ef 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -196,6 +196,62 @@ export default [ "no-restricted-imports": restrictedImports(layerPatterns.adapters), }, }, + { + files: [`src/features/*/domain/**/*.${sourceExtensions}`], + rules: { + "no-restricted-imports": restrictedImports(layerPatterns.domain), + "no-restricted-globals": [ + "error", + "window", + "document", + "localStorage", + "fetch", + ], + }, + }, + { + files: [`src/features/*/application/**/*.${sourceExtensions}`], + rules: { + "no-restricted-imports": restrictedImports(layerPatterns.application), + "no-restricted-globals": [ + "error", + "window", + "document", + "localStorage", + "fetch", + ], + }, + }, + { + files: [`src/features/*/presentation/**/*.${sourceExtensions}`], + rules: { + "no-restricted-imports": restrictedImports([ + "**/features/*/adapters/**", + "**/adapters/http/**", + "**/adapters/storage/**", + "**/adapters/auth/**", + "**/bootstrap/**", + "**/application/ports/out/**", + "@tanstack/**", + ]), + "no-restricted-globals": [ + "error", + "fetch", + "localStorage", + "sessionStorage", + ], + }, + }, + { + files: [`src/features/*/adapters/**/*.${sourceExtensions}`], + rules: { + "no-restricted-imports": restrictedImports([ + "**/presentation/**", + "**/bootstrap/**", + "@tanstack/**", + ]), + }, + }, { files: [`tests/**/*.${sourceExtensions}`], languageOptions: { diff --git a/package.json b/package.json index 0191f54..936e69a 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "test:a11y": "playwright test --grep @a11y && node scripts/write-a11y-report.mjs", "review:a11y-manual": "node scripts/verify-a11y-manual.mjs", "test:sample-removal": "node scripts/test-sample-removal.mjs", - "test:all": "corepack pnpm test:runtime-schema && corepack pnpm test:unit && corepack pnpm test:component && corepack pnpm test:integration", + "test:reference-feature": "vitest run tests/features/reference-feature --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/reference-feature.xml --passWithNoTests", + "test:all": "corepack pnpm test:runtime-schema && corepack pnpm test:unit && corepack pnpm test:component && corepack pnpm test:integration && corepack pnpm test:reference-feature", "verify:lockfile": "corepack pnpm install --frozen-lockfile", "generate:supply-chain": "node scripts/generate-supply-chain.mjs", "scan:security": "node scripts/security-scan.mjs", diff --git a/public/release-manifest.json b/public/release-manifest.json index 051a15f..ce70cd6 100644 --- a/public/release-manifest.json +++ b/public/release-manifest.json @@ -13,7 +13,7 @@ "route-examples-ui": "src/presentation/examples/ui-gallery-page.jsx", "route-examples-states": "src/presentation/examples/state-gallery-page.jsx", "route-examples-auth": "src/presentation/examples/auth-example-page.jsx", - "route-sample-resources": "src/presentation/pages/sample-contract-page.jsx", + "route-reference-resources": "src/features/reference-feature/presentation/reference-resource-page.tsx", "route-not-found": "src/presentation/pages/not-found-page.jsx" } } diff --git a/scripts/generate-build-manifest.mjs b/scripts/generate-build-manifest.mjs index b6a5ff8..f89337f 100644 --- a/scripts/generate-build-manifest.mjs +++ b/scripts/generate-build-manifest.mjs @@ -3,8 +3,10 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import process from "node:process"; import { z } from "zod"; -import { ROUTE_REGISTRY } from "../src/contracts/routes.js"; -import { ROUTE_RUNTIME_CONTRACT } from "../src/contracts/route-runtime-contract.js"; +import { + ROUTE_REGISTRY, + ROUTE_RUNTIME_CONTRACT, +} from "../src/features/installed-feature-contracts.js"; import { runtimeConfigSchema } from "../src/bootstrap/runtime-config-schema.js"; const packageJson = JSON.parse(await readFile("package.json", "utf8")); diff --git a/scripts/lib/manual-a11y-evidence.mjs b/scripts/lib/manual-a11y-evidence.mjs index 2fbeac7..d67cd4d 100644 --- a/scripts/lib/manual-a11y-evidence.mjs +++ b/scripts/lib/manual-a11y-evidence.mjs @@ -1,11 +1,8 @@ -export const MANUAL_A11Y_ROUTE_IDS = Object.freeze([ - "APP_HOME", - "EXAMPLES_UI", - "EXAMPLES_STATES", - "EXAMPLES_AUTH", - "SAMPLE_RESOURCE_LIST", - "NOT_FOUND", -]); +import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.js"; + +export const MANUAL_A11Y_ROUTE_IDS = Object.freeze( + Object.values(ROUTE_REGISTRY).map((route) => route.routeId), +); const REVIEW_FIELDS = Object.freeze([ "M1 Keyboard", diff --git a/scripts/test-performance.mjs b/scripts/test-performance.mjs index 598ec0a..b368466 100644 --- a/scripts/test-performance.mjs +++ b/scripts/test-performance.mjs @@ -6,7 +6,7 @@ import process from "node:process"; import { chromium } from "@playwright/test"; import { evaluateLabBudget } from "../src/application/policies/performance-budgets.js"; -import { ROUTE_REGISTRY } from "../src/contracts/routes.js"; +import { ROUTE_REGISTRY } from "../src/features/installed-feature-contracts.js"; const server = spawn( "corepack", @@ -67,8 +67,9 @@ try { }).observe({ type: "layout-shift", buffered: true }); }); await page.goto(baseUrl, { waitUntil: "networkidle" }); - const targetLabel = - ROUTE_REGISTRY.SAMPLE_RESOURCE_LIST.navigationLabel; + const targetLabel = Object.values(ROUTE_REGISTRY).find( + (definition) => definition.access === "integration-defined", + )?.navigationLabel; if (!targetLabel) { throw new Error("Performance route must be present in navigation."); } diff --git a/scripts/test-sample-removal.mjs b/scripts/test-sample-removal.mjs index ce42f54..0cf7677 100644 --- a/scripts/test-sample-removal.mjs +++ b/scripts/test-sample-removal.mjs @@ -1,78 +1,218 @@ -import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { spawnSync } from "node:child_process"; +import { + cp, + mkdir, + readFile, + readdir, + rm, + symlink, + writeFile, +} from "node:fs/promises"; import path from "node:path"; -const fixtureRoot = path.resolve(".tmp/sample-removal"); -const sampleRoot = path.resolve("src/sample/contract-fixture"); -const sourceRoot = path.resolve("src"); +const fixtureRoot = path.resolve(".tmp/reference-feature-removal"); const pnpmCli = /** @type {string} */ (process.env.npm_execpath); +const featureSource = "src/features/reference-feature"; +const featureTests = "tests/features/reference-feature"; +const copyTargets = [ + "src", + "tests", + "scripts", + "config", + "public", + "index.html", + "package.json", + "tsconfig.base.json", + "tsconfig.json", + "tsconfig.app.json", + "tsconfig.node.json", + "tsconfig.test.json", + "vite.config.js", + "vitest.config.js", + "playwright.config.js", + "eslint.config.js", + ".dependency-cruiser.cjs", +]; + +const emptyContracts = `import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../contracts/route-runtime-contract.js"; +import { PLATFORM_ROUTE_REGISTRY } from "../contracts/routes.js"; + +export const INSTALLED_FEATURE_CONTRACTS = + /** @type {readonly unknown[]} */ (Object.freeze([])); +export const ROUTE_REGISTRY = PLATFORM_ROUTE_REGISTRY; +export const ROUTE_RUNTIME_CONTRACT = PLATFORM_ROUTE_RUNTIME_CONTRACT; +export const API_OPERATIONS = Object.freeze({}); +export const QUERY_REGISTRY = Object.freeze({}); +export const NAVIGATION_ROUTES = Object.freeze( + Object.values(ROUTE_REGISTRY) + .filter((definition) => definition.navigationOrder !== null) + .sort( + (left, right) => + /** @type {number} */ (left.navigationOrder) - + /** @type {number} */ (right.navigationOrder), + ), +); +/** @param {string} routeId */ +export function getRoute(routeId) { + const registry = + /** @type {Readonly>} */ ( + 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; +} +`; + +const emptyRuntimes = `import { PLATFORM_ROUTE_CODECS } from "../presentation/routes/platform-route-codecs.js"; +import { PLATFORM_ROUTE_RUNTIME } from "../presentation/routes/route-runtime.js"; + +export const ROUTE_CODECS = PLATFORM_ROUTE_CODECS; +export const ROUTE_RUNTIME = PLATFORM_ROUTE_RUNTIME; +`; + +const emptyAdapters = `type FeatureContext = Readonly<{ + createHttpClient(contract: Readonly>): unknown; +}>; +export function createInstalledFeatureInputs(_context: FeatureContext) { + void _context; + return Object.freeze({}); +} +`; /** @param {string} directory @returns {Promise} */ -async function sourceFiles(directory) { +async function filesBelow(directory) { const entries = await readdir(directory, { withFileTypes: true }); - const nested = /** @type {string[][]} */ (await Promise.all( + const groups = await Promise.all( entries.map((entry) => { const target = path.join(directory, entry.name); - return entry.isDirectory() ? sourceFiles(target) : [target]; + return entry.isDirectory() ? filesBelow(target) : [target]; }), - )); - return nested.flat(); + ); + return groups.flat(); +} + +/** @param {string} script @param {string[]} [extra] */ +function runPnpm(script, extra = []) { + const result = spawnSync(process.execPath, [pnpmCli, script, ...extra], { + cwd: fixtureRoot, + stdio: "inherit", + }); + return result.status === 0; } await rm(fixtureRoot, { recursive: true, force: true }); await mkdir(fixtureRoot, { recursive: true }); +for (const target of copyTargets) { + await cp(target, path.join(fixtureRoot, target), { recursive: true }); +} +await symlink(path.resolve("node_modules"), path.join(fixtureRoot, "node_modules"), "dir"); -const incomingImports = []; -for (const sourceFile of await sourceFiles(sourceRoot)) { - if (sourceFile.startsWith(sampleRoot)) continue; - const content = await readFile(sourceFile, "utf8"); - if (/from\s+["'][^"']*sample\/contract-fixture/.test(content)) { - incomingImports.push(path.relative(".", sourceFile)); +await rm(path.join(fixtureRoot, featureSource), { + recursive: true, + force: true, +}); +await rm(path.join(fixtureRoot, featureTests), { + recursive: true, + force: true, +}); +await writeFile( + path.join(fixtureRoot, "src/features/installed-feature-contracts.js"), + emptyContracts, +); +await writeFile( + path.join(fixtureRoot, "src/features/installed-feature-runtimes.tsx"), + emptyRuntimes, +); +await writeFile( + path.join(fixtureRoot, "src/features/installed-feature-adapters.ts"), + emptyAdapters, +); + +/** @type {string[]} */ +const residue = []; +for (const root of ["src", "tests"]) { + for (const file of await filesBelow(path.join(fixtureRoot, root))) { + const relative = path.relative(fixtureRoot, file); + const content = await readFile(file, "utf8"); + if ( + /REFERENCE_RESOURCE|reference-feature|reference-resource/i.test( + `${relative}\n${content}`, + ) + ) { + residue.push(relative); + } } } -let buildStatus = 1; -if (incomingImports.length === 0) { - await cp("src", path.join(fixtureRoot, "src"), { - recursive: true, - filter: (source) => !source.startsWith(sampleRoot), - }); - await cp("public", path.join(fixtureRoot, "public"), { recursive: true }); - await cp("index.html", path.join(fixtureRoot, "index.html")); - await cp("vite.config.js", path.join(fixtureRoot, "vite.config.js")); - - const result = spawnSync( - process.execPath, - [ - pnpmCli, - "exec", - "vite", - "build", - fixtureRoot, - "--outDir", - path.join(fixtureRoot, "dist"), - ], - { stdio: "inherit" }, - ); - buildStatus = result.status ?? 1; +const checks = [ + ["typecheck", runPnpm("check:types")], + ["architecture", runPnpm("check:architecture")], + ["registry", runPnpm("check:registries")], + ["unit-integration", runPnpm("test:all")], + [ + "home-smoke", + runPnpm("exec", [ + "vitest", + "run", + "tests/component/router.test.jsx", + "--reporter=default", + ]), + ], + ["build", runPnpm("build")], +]; +/** @type {string[]} */ +const builtResidue = []; +for (const file of await filesBelow(path.join(fixtureRoot, "dist"))) { + if (!/\.(?:js|css|html|json)$/.test(file)) continue; + const content = await readFile(file, "utf8"); + if ( + /REFERENCE_RESOURCE|reference-feature|reference-resource/i.test(content) + ) { + builtResidue.push(path.relative(fixtureRoot, file)); + } } +const routeCatalog = await import( + `${new URL( + "../src/features/installed-feature-contracts.js", + `file://${fixtureRoot}/scripts/`, + ).href}?removed=${Date.now()}` +); +const routeIds = Object.keys(routeCatalog.ROUTE_REGISTRY); +const routeAbsent = routeIds.every((routeId) => !routeId.startsWith("REFERENCE_")); +checks.push(["route-absent", routeAbsent]); +checks.push(["fixture-id-residue", residue.length === 0]); +checks.push(["built-fixture-id-residue", builtResidue.length === 0]); +const passed = checks.every(([, result]) => result); await mkdir("artifacts/tests", { recursive: true }); -const passed = incomingImports.length === 0 && buildStatus === 0; await writeFile( "artifacts/tests/sample-removal.xml", `\n` + - `` + - `` + - `${passed ? "" : ""}` + + `` + + checks + .map( + ([name, result]) => + `${result ? "" : `${[...residue, ...builtResidue].join(", ")}`}`, + ) + .join("") + `\n`, ); await rm(fixtureRoot, { recursive: true, force: true }); if (!passed) { + const failures = checks + .filter(([, result]) => !result) + .map(([name]) => name); process.stderr.write( - `Sample removal failed. Incoming imports: ${incomingImports.join(", ")}\n`, + `Reference feature removal failed: ${failures.join(", ")}; residue: ${[...residue, ...builtResidue].join(", ")}\n`, ); process.exit(1); } -process.stdout.write("Sample removal smoke: PASS\n"); +process.stdout.write( + `Reference feature removal: PASS (${checks.length} checks, no fixture IDs)\n`, +); diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs index ca5e4fe..672ee1e 100644 --- a/scripts/verify-release.mjs +++ b/scripts/verify-release.mjs @@ -6,8 +6,10 @@ import { compareReleaseToRuntime, RELEASE_TOKEN_REGISTRY, } from "../src/contracts/release-tokens.js"; -import { ROUTE_RUNTIME_CONTRACT } from "../src/contracts/route-runtime-contract.js"; -import { ROUTE_REGISTRY } from "../src/contracts/routes.js"; +import { + ROUTE_REGISTRY, + ROUTE_RUNTIME_CONTRACT, +} from "../src/features/installed-feature-contracts.js"; const fixturesDocument = /** @type {{ diff --git a/src/adapters/http/client.js b/src/adapters/http/client.js index 46abc59..a590c3e 100644 --- a/src/adapters/http/client.js +++ b/src/adapters/http/client.js @@ -46,6 +46,8 @@ const noAuthSession = * random?: () => number, * validatePayload?: (schemaId: string, value: unknown) => * { success: true, data: unknown } | { success: false }, + * validateRequest?: (schemaId: string, value: unknown) => + * { success: true, data: unknown } | { success: false }, * mapPayload?: (operationId: string, payload: unknown) => unknown, * idempotencyKeyFactory?: () => string, * timeoutMs?: number, @@ -61,6 +63,8 @@ export function createHttpClient(dependencies) { const random = dependencies.random ?? Math.random; const validatePayload = dependencies.validatePayload ?? validateOperationPayload; + const validateRequest = + dependencies.validateRequest ?? validateOperationRequest; const mapPayload = dependencies.mapPayload ?? mapOperationPayload; const idempotencyKeyFactory = dependencies.idempotencyKeyFactory ?? (() => crypto.randomUUID()); @@ -193,7 +197,7 @@ export function createHttpClient(dependencies) { ? input.body : {}; if (operation.requestSource !== "none") { - const requestValidation = validateOperationRequest( + const requestValidation = validateRequest( operation.requestSchema, requestValue, ); diff --git a/src/adapters/http/resource-mapper.js b/src/adapters/http/resource-mapper.js index 2a8e636..4486b05 100644 --- a/src/adapters/http/resource-mapper.js +++ b/src/adapters/http/resource-mapper.js @@ -1,30 +1,5 @@ -import { createResource } from "../../domain/models/resource.js"; - -/** @param {unknown} value */ -export function mapResourceDto(value) { - if (!value || typeof value !== "object") { - throw new TypeError("Validated resource DTO is required"); - } - const dto = /** @type {Record} */ (value); - if (typeof dto.id !== "string" || typeof dto.name !== "string") { - throw new TypeError("Validated resource DTO invariants were breached"); - } - - return createResource({ - id: dto.id, - displayName: dto.name, - createdAt: typeof dto.createdAt === "string" ? dto.createdAt : null, - }); -} - /** @param {string} operationId @param {unknown} payload */ export function mapOperationPayload(operationId, payload) { - if (operationId === "LIST_SAMPLE_RESOURCES") { - if (!Array.isArray(payload)) throw new TypeError("Expected a resource list"); - return payload.map(mapResourceDto); - } - if (operationId === "CREATE_SAMPLE_RESOURCE") { - return mapResourceDto(payload); - } + void payload; throw new TypeError(`No boundary mapper registered for ${operationId}`); } diff --git a/src/adapters/http/schema-registry.js b/src/adapters/http/schema-registry.js index 4ec4af3..13064b9 100644 --- a/src/adapters/http/schema-registry.js +++ b/src/adapters/http/schema-registry.js @@ -37,35 +37,11 @@ export const responseEnvelopeSchema = z.discriminatedUnion("success", [ failureEnvelopeSchema, ]); -const sampleResourceSchema = z - .object({ - id: z.string().min(1), - name: z.string().min(1), - createdAt: z.string().optional(), - }) - .passthrough(); - const payloadSchemas = - /** @type {Readonly>} */ (Object.freeze({ - SampleResourceListPayload: z.array(sampleResourceSchema), - SampleResourcePayload: sampleResourceSchema, -})); + /** @type {Readonly>} */ (Object.freeze({})); const requestSchemas = - /** @type {Readonly>} */ (Object.freeze({ - SampleResourceListQuery: z - .object({ - cursor: z.string().optional(), - limit: z.int().min(1).max(100).default(20), - tags: z.array(z.string().trim().min(1)).optional(), - }) - .strict(), - CreateSampleResourceCommand: z - .object({ - name: z.string().trim().min(1).max(120), - }) - .strict(), -})); + /** @type {Readonly>} */ (Object.freeze({})); /** @param {unknown} value */ export function validateEnvelope(value) { diff --git a/src/application/create-application.ts b/src/application/create-application.ts index 2b92f9d..ee76069 100644 --- a/src/application/create-application.ts +++ b/src/application/create-application.ts @@ -15,6 +15,7 @@ export type { ApplicationApi, ApplicationOutputPorts }; */ export function createApplication( outputPorts: ApplicationOutputPorts, + featureInputs: Readonly> = {}, ): ApplicationApi { const session = Object.freeze({ getSnapshot: () => outputPorts.session.getState(), @@ -107,6 +108,18 @@ export function createApplication( } }, }); + const installedFeatureInputs = Object.freeze({ ...featureInputs }); + const features = Object.freeze({ + has(featureId: string) { + return Object.hasOwn(installedFeatureInputs, featureId); + }, + get(featureId: string) { + if (!Object.hasOwn(installedFeatureInputs, featureId)) { + throw new Error(`Application feature is not installed: ${featureId}`); + } + return installedFeatureInputs[featureId]; + }, + }); return Object.freeze({ session, @@ -114,5 +127,6 @@ export function createApplication( diagnostics, runtime, recovery, + features, }); } diff --git a/src/application/ports/in/application-api.ts b/src/application/ports/in/application-api.ts index 5a79e4c..68b62f7 100644 --- a/src/application/ports/in/application-api.ts +++ b/src/application/ports/in/application-api.ts @@ -47,4 +47,8 @@ export type ApplicationApi = Readonly<{ | Readonly<{ action: "support"; reason: string }> >; }>; + features: Readonly<{ + has(featureId: string): boolean; + get(featureId: string): unknown; + }>; }>; diff --git a/src/application/ports/out/index.ts b/src/application/ports/out/index.ts index 9b364cd..b16cb7e 100644 --- a/src/application/ports/out/index.ts +++ b/src/application/ports/out/index.ts @@ -7,11 +7,5 @@ export type { export type { ClockPort } from "../clock-port.js"; export type { QueryCachePort } from "../query-cache-port.js"; export type { ReleaseInfoPort } from "../release-info-port.js"; -export type { - RequestContext, - ResourceCommandPort, - ResourceQueryPort, - Result, -} from "../resource-ports.js"; export type { StoragePort } from "../storage-port.js"; export type { TelemetryPort } from "../telemetry-port.js"; diff --git a/src/application/ports/resource-ports.js b/src/application/ports/resource-ports.js deleted file mode 100644 index 1290758..0000000 --- a/src/application/ports/resource-ports.js +++ /dev/null @@ -1,28 +0,0 @@ -/** - * @template Query - * @template Model - * @typedef {{ execute(query: Query, context?: RequestContext): Promise> }} ResourceQueryPort - */ - -/** - * @template Command - * @template Model - * @typedef {{ execute(command: Command, context?: RequestContext): Promise> }} ResourceCommandPort - */ - -/** - * @typedef {{ - * operationId: string, - * routeId: string, - * signal?: AbortSignal, - * idempotencyKey?: string - * }} RequestContext - */ - -/** - * @template Value - * @typedef {{ ok: true, value: Value, meta?: Record } | - * { ok: false, error: import("../../contracts/errors.js").ApiFailure }} Result - */ - -export {}; diff --git a/src/application/view-models/resource-view-model.js b/src/application/view-models/resource-view-model.js deleted file mode 100644 index 963f25d..0000000 --- a/src/application/view-models/resource-view-model.js +++ /dev/null @@ -1,16 +0,0 @@ -/** - * @param {import("../../domain/models/resource.js").Resource} resource - * @param {(value: Date) => string} [formatDate] - */ -export function toResourceViewModel( - resource, - formatDate = (value) => new Intl.DateTimeFormat("ko-KR").format(value), -) { - return Object.freeze({ - resourceId: resource.id, - title: resource.displayName, - createdAtLabel: resource.createdAt - ? formatDate(new Date(resource.createdAt)) - : null, - }); -} diff --git a/src/bootstrap/composition-root.js b/src/bootstrap/composition-root.js index c6c58a2..db54934 100644 --- a/src/bootstrap/composition-root.js +++ b/src/bootstrap/composition-root.js @@ -16,7 +16,8 @@ import { createApplication } from "../application/create-application.js"; * release: Release * }): Promise<{ * outputPorts: OutputPorts, - * infrastructure: Infrastructure + * infrastructure: Infrastructure, + * featureInputs?: Readonly> * }> * }} factories * @returns {Promise} host @@ -54,7 +55,7 @@ function storageOrUndefined(value) { * scheduler?: Parameters[0]["scheduler"] * }} context */ -export function createRuntimeHttpClient(context) { +export function createRuntimeHttpClient(context, contract = {}) { return createHttpClient({ baseUrl: context.runtime.config.API_BASE_URL, timeoutMs: context.runtime.config.REQUEST_TIMEOUT_MS, @@ -63,6 +64,7 @@ export function createRuntimeHttpClient(context) { fetcher: context.fetcher, clock: context.clock, scheduler: context.scheduler, + ...contract, }); } @@ -116,6 +118,17 @@ export async function createRuntimeAdapters(context) { location.reload(); }, }); + const featureInputs = createInstalledFeatureInputs({ + createHttpClient: (contract) => + createRuntimeHttpClient( + { + runtime: context.runtime, + authSession, + fetcher: context.fetcher, + }, + contract, + ), + }); return Object.freeze({ outputPorts: Object.freeze({ @@ -128,5 +141,6 @@ export async function createRuntimeAdapters(context) { infrastructure: Object.freeze({ queryClient, }), + featureInputs, }); } diff --git a/src/contracts/api-operations.js b/src/contracts/api-operations.js index a4eec5b..e4196f1 100644 --- a/src/contracts/api-operations.js +++ b/src/contracts/api-operations.js @@ -14,41 +14,11 @@ * }} ApiOperation */ -/** @param {ApiOperation} definition */ -const operation = (definition) => Object.freeze(definition); - -export const API_OPERATIONS = Object.freeze({ - LIST_SAMPLE_RESOURCES: operation({ - method: "GET", - path: "/api/sample/resources", - operationId: "LIST_SAMPLE_RESOURCES", - auth: "external-session", - timeoutMs: null, - idempotency: "safe", - retry: "runtime", - requestSource: "search", - requestSchema: "SampleResourceListQuery", - responseSchema: "SampleResourceListPayload", - owner: "feature-sample-feature-slice-contract-fixture", - }), - CREATE_SAMPLE_RESOURCE: operation({ - method: "POST", - path: "/api/sample/resources", - operationId: "CREATE_SAMPLE_RESOURCE", - auth: "external-session", - timeoutMs: null, - idempotency: "keyed", - retry: "runtime", - requestSource: "body", - requestSchema: "CreateSampleResourceCommand", - responseSchema: "SampleResourcePayload", - owner: "feature-sample-feature-slice-contract-fixture", - }), -}); +export const API_OPERATIONS = Object.freeze({}); /** @param {string} operationId */ -export function getApiOperation(operationId) { - const registry = /** @type {Record} */ (API_OPERATIONS); +export function getApiOperation(operationId, operations = API_OPERATIONS) { + const registry = /** @type {Record} */ (operations); const selected = registry[operationId]; if (!selected) { throw new Error(`Unregistered API operation: ${operationId}`); diff --git a/src/contracts/query-keys.js b/src/contracts/query-keys.js index 050ac61..24f4be7 100644 --- a/src/contracts/query-keys.js +++ b/src/contracts/query-keys.js @@ -1,26 +1,4 @@ -const RESOURCE_NAMESPACE = Object.freeze(["resource", 1]); - -export const queryKeys = Object.freeze({ - resource: Object.freeze({ - all: () => RESOURCE_NAMESPACE, - list: (filters = {}) => - Object.freeze([...RESOURCE_NAMESPACE, "list", canonicalize(filters)]), - /** @param {string} resourceId */ - detail: (resourceId) => - Object.freeze([...RESOURCE_NAMESPACE, "detail", String(resourceId)]), - }), -}); - -export const QUERY_REGISTRY = Object.freeze({ - RESOURCE: Object.freeze({ - namespace: RESOURCE_NAMESPACE, - serialization: "canonical-object-order", - identity: "no-pii-token-or-raw-url", - invalidation: "resource namespace after successful mutation", - version: 1, - persistence: "disabled", - }), -}); +export const QUERY_REGISTRY = Object.freeze({}); /** @param {unknown} value @returns {unknown} */ export function canonicalize(value) { diff --git a/src/contracts/route-runtime-contract.js b/src/contracts/route-runtime-contract.js index 19e149f..6f79606 100644 --- a/src/contracts/route-runtime-contract.js +++ b/src/contracts/route-runtime-contract.js @@ -1,11 +1,11 @@ /** - * @typedef {"none" | "NotFoundSplat" | "SampleResourceListQuery"} RouteCodecId + * @typedef {"none" | "NotFoundSplat"} RouteCodecId */ /** @param {Readonly<{routeId: string, moduleId: string, paramsCodec: RouteCodecId, searchCodec: RouteCodecId}>} value */ const runtime = (value) => Object.freeze(value); -export const ROUTE_RUNTIME_CONTRACT = Object.freeze({ +export const PLATFORM_ROUTE_RUNTIME_CONTRACT = Object.freeze({ APP_HOME: runtime({ routeId: "APP_HOME", moduleId: "home-page", @@ -30,12 +30,6 @@ export const ROUTE_RUNTIME_CONTRACT = Object.freeze({ paramsCodec: "none", searchCodec: "none", }), - SAMPLE_RESOURCE_LIST: runtime({ - routeId: "SAMPLE_RESOURCE_LIST", - moduleId: "sample-contract-page", - paramsCodec: "none", - searchCodec: "SampleResourceListQuery", - }), NOT_FOUND: runtime({ routeId: "NOT_FOUND", moduleId: "not-found-page", diff --git a/src/contracts/routes.js b/src/contracts/routes.js index 050bb06..8dba85e 100644 --- a/src/contracts/routes.js +++ b/src/contracts/routes.js @@ -17,7 +17,7 @@ /** @param {RouteDefinition} definition */ const route = (definition) => Object.freeze(definition); -export const ROUTE_REGISTRY = Object.freeze({ +export const PLATFORM_ROUTE_REGISTRY = Object.freeze({ APP_HOME: route({ routeId: "APP_HOME", path: "/", @@ -70,19 +70,6 @@ export const ROUTE_REGISTRY = Object.freeze({ navigationLabel: "인증 연동", navigationOrder: 40, }), - 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", - title: "보호된 연동 지점", - navigationLabel: "보호된 연동 지점", - navigationOrder: 50, - }), NOT_FOUND: route({ routeId: "NOT_FOUND", path: "*", @@ -97,28 +84,3 @@ export const ROUTE_REGISTRY = Object.freeze({ navigationOrder: null, }), }); - -export const NAVIGATION_ROUTES = Object.freeze( - Object.values(ROUTE_REGISTRY) - .filter((definition) => definition.navigationOrder !== null) - .sort( - (left, right) => - /** @type {number} */ (left.navigationOrder) - - /** @type {number} */ (right.navigationOrder), - ), -); - -/** @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/domain/models/resource.js b/src/domain/models/resource.js deleted file mode 100644 index 7a41ed2..0000000 --- a/src/domain/models/resource.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * @typedef {{ - * id: string, - * displayName: string, - * createdAt: string | null - * }} Resource - */ - -/** @param {Resource} values @returns {Readonly} */ -export function createResource(values) { - if (!values.id || !values.displayName) { - throw new TypeError("Resource invariants require id and displayName"); - } - return Object.freeze({ - id: values.id, - displayName: values.displayName, - createdAt: values.createdAt, - }); -} diff --git a/src/features/installed-feature-adapters.ts b/src/features/installed-feature-adapters.ts new file mode 100644 index 0000000..99de178 --- /dev/null +++ b/src/features/installed-feature-adapters.ts @@ -0,0 +1,15 @@ +import { createReferenceFeatureInstalledInput } from "./reference-feature/adapters/create-reference-feature-input.js"; + +export function createInstalledFeatureInputs( + context: Parameters[0], +): Readonly> { + const installed = [createReferenceFeatureInstalledInput(context)]; + return Object.freeze( + Object.fromEntries( + installed.map((contribution) => [ + contribution.featureId, + contribution.input, + ]), + ), + ); +} diff --git a/src/features/installed-feature-contracts.js b/src/features/installed-feature-contracts.js new file mode 100644 index 0000000..d21258a --- /dev/null +++ b/src/features/installed-feature-contracts.js @@ -0,0 +1,48 @@ +import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../contracts/route-runtime-contract.js"; +import { PLATFORM_ROUTE_REGISTRY } from "../contracts/routes.js"; +import { REFERENCE_FEATURE_CONTRACT } from "./reference-feature/contracts/reference-feature-contract.js"; + +export const INSTALLED_FEATURE_CONTRACTS = Object.freeze([ + REFERENCE_FEATURE_CONTRACT, +]); + +export const ROUTE_REGISTRY = Object.freeze({ + ...PLATFORM_ROUTE_REGISTRY, + ...REFERENCE_FEATURE_CONTRACT.routes, +}); +export const ROUTE_RUNTIME_CONTRACT = Object.freeze({ + ...PLATFORM_ROUTE_RUNTIME_CONTRACT, + ...REFERENCE_FEATURE_CONTRACT.routeRuntimeContracts, +}); +export const API_OPERATIONS = Object.freeze({ + ...REFERENCE_FEATURE_CONTRACT.apiOperations, +}); +export const QUERY_REGISTRY = Object.freeze({ + ...REFERENCE_FEATURE_CONTRACT.queryRegistry, +}); + +export const NAVIGATION_ROUTES = Object.freeze( + Object.values(ROUTE_REGISTRY) + .filter((definition) => definition.navigationOrder !== null) + .sort( + (left, right) => + /** @type {number} */ (left.navigationOrder) - + /** @type {number} */ (right.navigationOrder), + ), +); + +/** @param {string} routeId */ +export function getRoute(routeId) { + const registry = + /** @type {Readonly>} */ ( + 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/features/installed-feature-runtimes.tsx b/src/features/installed-feature-runtimes.tsx new file mode 100644 index 0000000..c545c3b --- /dev/null +++ b/src/features/installed-feature-runtimes.tsx @@ -0,0 +1,16 @@ +import { PLATFORM_ROUTE_CODECS } from "../presentation/routes/platform-route-codecs.js"; +import { PLATFORM_ROUTE_RUNTIME } from "../presentation/routes/route-runtime.js"; +import { + REFERENCE_FEATURE_ROUTE_CODECS, + REFERENCE_FEATURE_ROUTE_RUNTIME, +} from "./reference-feature/presentation/reference-feature-runtime.js"; + +export const ROUTE_CODECS = Object.freeze({ + ...PLATFORM_ROUTE_CODECS, + ...REFERENCE_FEATURE_ROUTE_CODECS, +}); + +export const ROUTE_RUNTIME = Object.freeze({ + ...PLATFORM_ROUTE_RUNTIME, + ...REFERENCE_FEATURE_ROUTE_RUNTIME, +}); diff --git a/src/features/reference-feature/README.md b/src/features/reference-feature/README.md new file mode 100644 index 0000000..ef1d454 --- /dev/null +++ b/src/features/reference-feature/README.md @@ -0,0 +1,39 @@ +# Reference feature ownership and removal + +이 모듈은 제품 도메인이 아니라 새 기능의 수직 경계를 검증하는 제거 가능한 +reference implementation이다. + +## 소유 경계 + +- `domain`: 외부 DTO와 React를 모르는 불변 model +- `application`: UI가 호출하는 list/create input과 gateway 계약 +- `adapters`: HTTP executor를 gateway로 투영하는 outbound adapter +- `contracts`: route/API/query contribution, Zod DTO와 request schema, mapper +- `presentation`: route input을 query/mutation controller로 연결하는 inbound + adapter와 page + +generic application은 `features.get(featureId)` catalog만 제공한다. feature hook이 +자신의 input shape를 확인하며 page는 HTTP client, storage, auth owner, output +port나 TanStack API를 직접 import하지 않는다. + +## 설치 지점 + +- 직렬화 계약: `src/features/installed-feature-contracts.js` +- component/codec: `src/features/installed-feature-runtimes.tsx` +- bootstrap input 조립: `src/features/installed-feature-adapters.ts` + +새 기능도 이 세 지점에 contribution을 합성하되 feature ID를 generic application, +router나 HTTP client에 하드코딩하지 않는다. + +## 검증과 제거 + +```sh +corepack pnpm test:reference-feature +corepack pnpm test:sample-removal +``` + +첫 명령은 URL filter와 query key/HTTP request의 동일성, schema/mapper, 모든 +query/mutation 상태와 production composition을 검증한다. 두 번째 명령은 임시 +복제본에서 이 source/test 디렉터리를 제거하고 installed catalog를 빈 목록으로 +재생성한 뒤 typecheck, architecture, registry, unit/integration, home smoke, +production build와 fixture ID 잔여 0개를 검사한다. diff --git a/src/features/reference-feature/adapters/create-reference-feature-input.ts b/src/features/reference-feature/adapters/create-reference-feature-input.ts new file mode 100644 index 0000000..36a9698 --- /dev/null +++ b/src/features/reference-feature/adapters/create-reference-feature-input.ts @@ -0,0 +1,43 @@ +import { createReferenceFeatureInput } from "../application/reference-feature-api.js"; +import { + REFERENCE_FEATURE_CONTRACT, + REFERENCE_FEATURE_ID, +} from "../contracts/reference-feature-contract.js"; +import { mapReferenceOperation } from "../contracts/reference-mapper.js"; +import { + validateReferencePayload, + validateReferenceRequest, +} from "../contracts/reference-schemas.js"; +import { createReferenceHttpGateway } from "./reference-http-gateway.js"; + +type HttpContract = Readonly<{ + getOperation(operationId: string): unknown; + validatePayload: typeof validateReferencePayload; + validateRequest: typeof validateReferenceRequest; + mapPayload: typeof mapReferenceOperation; +}>; + +type HttpExecutor = Parameters[0]; + +export function createReferenceFeatureInstalledInput(context: Readonly<{ + createHttpClient(contract: HttpContract): HttpExecutor; +}>) { + const operations = + REFERENCE_FEATURE_CONTRACT.apiOperations as Readonly>; + const http = context.createHttpClient({ + getOperation(operationId) { + const operation = operations[operationId]; + if (!operation) { + throw new Error(`Unknown reference operation: ${operationId}`); + } + return operation; + }, + validatePayload: validateReferencePayload, + validateRequest: validateReferenceRequest, + mapPayload: mapReferenceOperation, + }); + return Object.freeze({ + featureId: REFERENCE_FEATURE_ID, + input: createReferenceFeatureInput(createReferenceHttpGateway(http)), + }); +} diff --git a/src/features/reference-feature/adapters/reference-http-gateway.ts b/src/features/reference-feature/adapters/reference-http-gateway.ts new file mode 100644 index 0000000..5497a4d --- /dev/null +++ b/src/features/reference-feature/adapters/reference-http-gateway.ts @@ -0,0 +1,56 @@ +import type { ApiFailure } from "../../../contracts/errors.js"; +import type { + ReferenceGateway, + ReferenceListFilters, +} from "../application/reference-feature-api.js"; +import type { ReferenceResource } from "../domain/reference-resource.js"; + +type HttpResult = + | Readonly<{ ok: true; value: unknown }> + | Readonly<{ ok: false; error: ApiFailure }>; + +type HttpExecutor = Readonly<{ + execute( + request: Readonly<{ + operationId: string; + routeId: string; + searchParams?: unknown; + body?: unknown; + signal?: AbortSignal; + }>, + ): Promise; +}>; + +export function createReferenceHttpGateway( + http: HttpExecutor, +): ReferenceGateway { + return Object.freeze({ + async list( + filters: ReferenceListFilters, + context?: Readonly<{ signal?: AbortSignal }>, + ) { + const result = await http.execute({ + operationId: "LIST_REFERENCE_RESOURCES", + routeId: "REFERENCE_RESOURCE_LIST", + searchParams: filters, + signal: context?.signal, + }); + return result.ok + ? { + ok: true as const, + value: result.value as readonly ReferenceResource[], + } + : result; + }, + async create(command: Readonly<{ name: string }>) { + const result = await http.execute({ + operationId: "CREATE_REFERENCE_RESOURCE", + routeId: "REFERENCE_RESOURCE_LIST", + body: command, + }); + return result.ok + ? { ok: true as const, value: result.value as ReferenceResource } + : result; + }, + }); +} diff --git a/src/features/reference-feature/application/reference-feature-api.ts b/src/features/reference-feature/application/reference-feature-api.ts new file mode 100644 index 0000000..c8d0e40 --- /dev/null +++ b/src/features/reference-feature/application/reference-feature-api.ts @@ -0,0 +1,58 @@ +import type { ApiFailure } from "../../../contracts/errors.js"; +import { + toReferenceView, + type ReferenceResourceView, +} from "../contracts/reference-mapper.js"; +import type { ReferenceResource } from "../domain/reference-resource.js"; + +export type ReferenceListFilters = Readonly<{ + cursor?: string; + limit: number; + tags?: readonly string[]; +}>; + +export type ReferenceResult = + | Readonly<{ ok: true; value: Value }> + | Readonly<{ ok: false; error: ApiFailure }>; + +export type ReferenceFeatureInput = Readonly<{ + listResources( + filters: ReferenceListFilters, + context?: Readonly<{ signal?: AbortSignal }>, + ): Promise>; + createResource( + command: Readonly<{ name: string }>, + ): Promise>; +}>; + +export type ReferenceGateway = Readonly<{ + list( + filters: ReferenceListFilters, + context?: Readonly<{ signal?: AbortSignal }>, + ): Promise>; + create( + command: Readonly<{ name: string }>, + ): Promise>; +}>; + +export function createReferenceFeatureInput( + gateway: ReferenceGateway, +): ReferenceFeatureInput { + return Object.freeze({ + async listResources(filters, context) { + const result = await gateway.list(filters, context); + return result.ok + ? { + ok: true as const, + value: result.value.map((resource) => toReferenceView(resource)), + } + : result; + }, + async createResource(command) { + const result = await gateway.create(command); + return result.ok + ? { ok: true as const, value: toReferenceView(result.value) } + : result; + }, + }); +} diff --git a/src/features/reference-feature/contracts/reference-feature-contract.js b/src/features/reference-feature/contracts/reference-feature-contract.js new file mode 100644 index 0000000..b34fe03 --- /dev/null +++ b/src/features/reference-feature/contracts/reference-feature-contract.js @@ -0,0 +1,75 @@ +import { canonicalize } from "../../../contracts/query-keys.js"; + +export const REFERENCE_FEATURE_ID = "reference-feature"; +const REFERENCE_NAMESPACE = Object.freeze(["reference-resource", 1]); + +export const referenceQueryKeys = Object.freeze({ + all: () => REFERENCE_NAMESPACE, + list: (filters = {}) => + Object.freeze([...REFERENCE_NAMESPACE, "list", canonicalize(filters)]), +}); + +export const REFERENCE_FEATURE_CONTRACT = Object.freeze({ + featureId: REFERENCE_FEATURE_ID, + routes: Object.freeze({ + REFERENCE_RESOURCE_LIST: Object.freeze({ + routeId: "REFERENCE_RESOURCE_LIST", + path: "/examples/reference-resources", + paramsSchema: null, + searchSchema: "ReferenceResourceListQuery", + access: "integration-defined", + loadingSurface: "reference-resource-list", + errorSurface: "feature-boundary", + chunkId: "route-reference-resources", + title: "Reference feature", + navigationLabel: "Reference feature", + navigationOrder: 50, + }), + }), + routeRuntimeContracts: Object.freeze({ + REFERENCE_RESOURCE_LIST: Object.freeze({ + routeId: "REFERENCE_RESOURCE_LIST", + moduleId: "reference-resource-page", + paramsCodec: "none", + searchCodec: "ReferenceResourceListQuery", + }), + }), + apiOperations: Object.freeze({ + LIST_REFERENCE_RESOURCES: Object.freeze({ + method: "GET", + path: "/api/reference-resources", + operationId: "LIST_REFERENCE_RESOURCES", + auth: "external-session", + timeoutMs: null, + idempotency: "safe", + retry: "runtime", + requestSource: "search", + requestSchema: "ReferenceResourceListQuery", + responseSchema: "ReferenceResourceListPayload", + owner: "feature-frontend-reference-feature-vertical-slice", + }), + CREATE_REFERENCE_RESOURCE: Object.freeze({ + method: "POST", + path: "/api/reference-resources", + operationId: "CREATE_REFERENCE_RESOURCE", + auth: "external-session", + timeoutMs: null, + idempotency: "keyed", + retry: "runtime", + requestSource: "body", + requestSchema: "CreateReferenceResourceCommand", + responseSchema: "ReferenceResourcePayload", + owner: "feature-frontend-reference-feature-vertical-slice", + }), + }), + queryRegistry: Object.freeze({ + REFERENCE_RESOURCE: Object.freeze({ + namespace: REFERENCE_NAMESPACE, + serialization: "canonical-object-order", + identity: "no-pii-token-or-raw-url", + invalidation: "reference resource namespace after successful mutation", + version: 1, + persistence: "disabled", + }), + }), +}); diff --git a/src/features/reference-feature/contracts/reference-mapper.ts b/src/features/reference-feature/contracts/reference-mapper.ts new file mode 100644 index 0000000..634732b --- /dev/null +++ b/src/features/reference-feature/contracts/reference-mapper.ts @@ -0,0 +1,54 @@ +import { + createReferenceResource, + type ReferenceResource, +} from "../domain/reference-resource.js"; + +export type ReferenceResourceView = Readonly<{ + resourceId: string; + title: string; + createdAtLabel: string | null; + optimistic?: boolean; +}>; + +function mapReferenceDto(value: unknown): ReferenceResource { + if (!value || typeof value !== "object") { + throw new TypeError("Validated reference DTO is required"); + } + const dto = value as Record; + if (typeof dto.id !== "string" || typeof dto.name !== "string") { + throw new TypeError("Validated reference DTO invariants were breached"); + } + return createReferenceResource({ + id: dto.id, + displayName: dto.name, + createdAt: typeof dto.createdAt === "string" ? dto.createdAt : null, + }); +} + +export function mapReferenceOperation( + operationId: string, + payload: unknown, +): ReferenceResource | readonly ReferenceResource[] { + if (operationId === "LIST_REFERENCE_RESOURCES") { + if (!Array.isArray(payload)) throw new TypeError("Expected a reference list"); + return payload.map(mapReferenceDto); + } + if (operationId === "CREATE_REFERENCE_RESOURCE") { + return mapReferenceDto(payload); + } + throw new TypeError(`No reference mapper registered for ${operationId}`); +} + +export function toReferenceView( + resource: ReferenceResource, + formatDate: (value: Date) => string = (value) => + new Intl.DateTimeFormat("ko-KR").format(value), +): ReferenceResourceView { + return Object.freeze({ + resourceId: resource.id, + title: resource.displayName, + createdAtLabel: resource.createdAt + ? formatDate(new Date(resource.createdAt)) + : null, + }); +} diff --git a/src/features/reference-feature/contracts/reference-schemas.ts b/src/features/reference-feature/contracts/reference-schemas.ts new file mode 100644 index 0000000..08d8066 --- /dev/null +++ b/src/features/reference-feature/contracts/reference-schemas.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; + +export const referenceResourceListQuerySchema = z + .object({ + cursor: z.string().min(1).optional(), + limit: z.coerce.number().int().min(1).max(100).default(20), + tags: z + .preprocess( + (value) => + value === undefined + ? undefined + : Array.isArray(value) + ? value + : [value], + z.array(z.string().trim().min(1)), + ) + .optional(), + }) + .strict(); + +const referenceResourceDtoSchema = z + .object({ + id: z.string().min(1), + name: z.string().min(1), + createdAt: z.string().datetime().optional(), + }) + .strict(); + +const payloadSchemas = { + ReferenceResourceListPayload: z.array(referenceResourceDtoSchema), + ReferenceResourcePayload: referenceResourceDtoSchema, +} satisfies Record; + +const requestSchemas = { + ReferenceResourceListQuery: referenceResourceListQuerySchema, + CreateReferenceResourceCommand: z + .object({ + name: z.string().trim().min(1).max(120), + }) + .strict(), +} satisfies Record; + +function project(result: z.ZodSafeParseResult) { + return result.success + ? ({ success: true as const, data: structuredClone(result.data) }) + : ({ + success: false as const, + issues: result.error.issues.map((issue) => ({ + path: issue.path.join("."), + code: issue.code, + })), + }); +} + +export function validateReferencePayload(schemaId: string, value: unknown) { + const schema = payloadSchemas[schemaId as keyof typeof payloadSchemas]; + return schema + ? project(schema.safeParse(value)) + : { success: false as const, issues: [{ path: "", code: "SCHEMA_NOT_REGISTERED" }] }; +} + +export function validateReferenceRequest(schemaId: string, value: unknown) { + const schema = requestSchemas[schemaId as keyof typeof requestSchemas]; + return schema + ? project(schema.safeParse(value)) + : { success: false as const, issues: [{ path: "", code: "SCHEMA_NOT_REGISTERED" }] }; +} diff --git a/src/features/reference-feature/domain/reference-resource.ts b/src/features/reference-feature/domain/reference-resource.ts new file mode 100644 index 0000000..4aba209 --- /dev/null +++ b/src/features/reference-feature/domain/reference-resource.ts @@ -0,0 +1,14 @@ +export type ReferenceResource = Readonly<{ + id: string; + displayName: string; + createdAt: string | null; +}>; + +export function createReferenceResource( + values: ReferenceResource, +): ReferenceResource { + if (!values.id || !values.displayName) { + throw new TypeError("Reference resource invariants require id and displayName"); + } + return Object.freeze({ ...values }); +} diff --git a/src/features/reference-feature/presentation/reference-feature-runtime.tsx b/src/features/reference-feature/presentation/reference-feature-runtime.tsx new file mode 100644 index 0000000..cd16f99 --- /dev/null +++ b/src/features/reference-feature/presentation/reference-feature-runtime.tsx @@ -0,0 +1,14 @@ +import { lazy } from "react"; + +import { referenceResourceListQuerySchema } from "../contracts/reference-schemas.js"; + +export const REFERENCE_FEATURE_ROUTE_CODECS = { + ReferenceResourceListQuery: referenceResourceListQuerySchema, +} as const; + +export const REFERENCE_FEATURE_ROUTE_RUNTIME = { + REFERENCE_RESOURCE_LIST: Object.freeze({ + moduleId: "reference-resource-page", + Component: lazy(() => import("./reference-resource-page.js")), + }), +} as const; diff --git a/src/features/reference-feature/presentation/reference-resource-page.tsx b/src/features/reference-feature/presentation/reference-resource-page.tsx new file mode 100644 index 0000000..c65a4f7 --- /dev/null +++ b/src/features/reference-feature/presentation/reference-resource-page.tsx @@ -0,0 +1,60 @@ +import { useState, type FormEvent } from "react"; + +import { AsyncSurface } from "../../../presentation/components/async-surface.jsx"; +import { Button } from "../../../presentation/components/ui/button.jsx"; +import { PageHeader } from "../../../presentation/components/page-header.jsx"; +import { useReferenceFeature } from "./use-reference-feature.js"; + +export default function ReferenceResourcePage() { + const { filters, query, mutation } = useReferenceFeature(); + const [name, setName] = useState(""); + + async function submit(event: FormEvent) { + event.preventDefault(); + const result = await mutation.submit({ name }); + if (result.ok) setName(""); + } + + return ( +
+ +

+ limit {filters.limit} + {filters.tags?.length ? ` · tags ${filters.tags.join(", ")}` : ""} +

+ +
    + {(query.data ?? []).map((resource) => ( +
  • + {resource.title} +
  • + ))} +
+
+ +
void submit(event)}> + + setName(event.currentTarget.value)} + required + /> + +
+
+
+ ); +} diff --git a/src/features/reference-feature/presentation/use-reference-feature.ts b/src/features/reference-feature/presentation/use-reference-feature.ts new file mode 100644 index 0000000..8a4def4 --- /dev/null +++ b/src/features/reference-feature/presentation/use-reference-feature.ts @@ -0,0 +1,62 @@ +import { useApplication } from "../../../presentation/providers/application-provider.js"; +import { + useApplicationMutation, + useApplicationQuery, +} from "../../../presentation/adapters/query/application-query.js"; +import { useRouteInput } from "../../../presentation/routes/app-router.js"; +import type { ReferenceResourceView } from "../contracts/reference-mapper.js"; +import { + REFERENCE_FEATURE_ID, + referenceQueryKeys, +} from "../contracts/reference-feature-contract.js"; +import type { + ReferenceFeatureInput, + ReferenceListFilters, +} from "../application/reference-feature-api.js"; + +function useReferenceFeatureInput(): ReferenceFeatureInput { + const candidate = useApplication().features.get(REFERENCE_FEATURE_ID); + if ( + !candidate || + typeof candidate !== "object" || + typeof (candidate as ReferenceFeatureInput).listResources !== "function" || + typeof (candidate as ReferenceFeatureInput).createResource !== "function" + ) { + throw new Error("Reference feature application input is invalid"); + } + return candidate as ReferenceFeatureInput; +} + +export function useReferenceFeature() { + const input = useReferenceFeatureInput(); + const routeInput = useRouteInput(); + const filters = routeInput.search as ReferenceListFilters; + const queryKey = referenceQueryKeys.list(filters); + const query = useApplicationQuery({ + queryKey, + execute: ({ signal }) => input.listResources(filters, { signal }), + }); + const mutation = useApplicationMutation({ + execute: input.createResource, + invalidate: [referenceQueryKeys.all()], + currentData: true, + optimistic: { + queryKey, + update(previous, command: Readonly<{ name: string }>) { + const current = Array.isArray(previous) + ? (previous as readonly ReferenceResourceView[]) + : []; + return [ + ...current, + { + resourceId: `optimistic:${command.name}`, + title: command.name, + createdAtLabel: null, + optimistic: true, + }, + ]; + }, + }, + }); + return Object.freeze({ filters, query, mutation }); +} diff --git a/src/presentation/layouts/app-shell.jsx b/src/presentation/layouts/app-shell.jsx index 6147dd7..04ed26d 100644 --- a/src/presentation/layouts/app-shell.jsx +++ b/src/presentation/layouts/app-shell.jsx @@ -1,7 +1,10 @@ import { useEffect, useState } from "react"; import { NavLink, Outlet, useLocation } from "react-router-dom"; -import { NAVIGATION_ROUTES, routePath } from "../../contracts/routes.js"; +import { + NAVIGATION_ROUTES, + routePath, +} from "../../features/installed-feature-contracts.js"; import { useSession } from "../providers/session-provider.jsx"; import { useTheme } from "../providers/theme-provider.jsx"; diff --git a/src/presentation/pages/home-page.jsx b/src/presentation/pages/home-page.jsx index d13fefb..a35bb5a 100644 --- a/src/presentation/pages/home-page.jsx +++ b/src/presentation/pages/home-page.jsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; -import { routePath } from "../../contracts/routes.js"; +import { routePath } from "../../features/installed-feature-contracts.js"; import { PageHeader } from "../components/page-header.jsx"; import { useApplication } from "../providers/application-provider.js"; diff --git a/src/presentation/pages/not-found-page.jsx b/src/presentation/pages/not-found-page.jsx index a1463b7..187b584 100644 --- a/src/presentation/pages/not-found-page.jsx +++ b/src/presentation/pages/not-found-page.jsx @@ -1,6 +1,6 @@ import { Link } from "react-router-dom"; -import { routePath } from "../../contracts/routes.js"; +import { routePath } from "../../features/installed-feature-contracts.js"; import { PageHeader } from "../components/page-header.jsx"; export default function NotFoundPage() { diff --git a/src/presentation/pages/sample-contract-page.jsx b/src/presentation/pages/sample-contract-page.jsx deleted file mode 100644 index a8f8aab..0000000 --- a/src/presentation/pages/sample-contract-page.jsx +++ /dev/null @@ -1,23 +0,0 @@ -import { PageHeader } from "../components/page-header.jsx"; -import { useSession } from "../providers/session-provider.jsx"; - -export default function SampleContractPage() { - const { sessionState } = useSession(); - - return ( -
- -
-

라우트 접근 허용

-

- 현재 세션 상태는 {sessionState}입니다. 서버의 - 권한 검증은 이 클라이언트 라우트 정책과 별도로 유지해야 합니다. -

-
-
- ); -} diff --git a/src/presentation/routes/app-router.tsx b/src/presentation/routes/app-router.tsx index a528c81..74b9358 100644 --- a/src/presentation/routes/app-router.tsx +++ b/src/presentation/routes/app-router.tsx @@ -21,8 +21,8 @@ import { import { getRoute, ROUTE_REGISTRY, - type RouteDefinition, -} from "../../contracts/routes.js"; +} from "../../features/installed-feature-contracts.js"; +import type { RouteDefinition } from "../../contracts/routes.js"; import { FeatureBoundary, RouteBoundary, @@ -43,7 +43,7 @@ import { type ParsedRouteInput, type RouteId, } from "./route-codecs.js"; -import { ROUTE_RUNTIME } from "./route-runtime.js"; +import { ROUTE_RUNTIME } from "../../features/installed-feature-runtimes.js"; const RouteInputContext = createContext(null); diff --git a/src/presentation/routes/navigation-policy.js b/src/presentation/routes/navigation-policy.js index 5170793..d08e222 100644 --- a/src/presentation/routes/navigation-policy.js +++ b/src/presentation/routes/navigation-policy.js @@ -1,4 +1,4 @@ -import { getRoute } from "../../contracts/routes.js"; +import { getRoute } from "../../features/installed-feature-contracts.js"; /** * @param {string} routeId diff --git a/src/presentation/routes/platform-route-codecs.ts b/src/presentation/routes/platform-route-codecs.ts new file mode 100644 index 0000000..830f2e6 --- /dev/null +++ b/src/presentation/routes/platform-route-codecs.ts @@ -0,0 +1,6 @@ +import { z } from "zod"; + +export const PLATFORM_ROUTE_CODECS = { + none: z.object({}).strict(), + NotFoundSplat: z.object({ "*": z.string().optional() }).strict(), +} as const; diff --git a/src/presentation/routes/route-codecs.ts b/src/presentation/routes/route-codecs.ts index 9376165..6391cf0 100644 --- a/src/presentation/routes/route-codecs.ts +++ b/src/presentation/routes/route-codecs.ts @@ -1,36 +1,8 @@ -import { z } from "zod"; - -import { getRoute } from "../../contracts/routes.js"; -import { ROUTE_RUNTIME_CONTRACT } from "../../contracts/route-runtime-contract.js"; +import { getRoute, ROUTE_RUNTIME_CONTRACT } from "../../features/installed-feature-contracts.js"; +import { ROUTE_CODECS } from "../../features/installed-feature-runtimes.js"; export type RouteId = keyof typeof ROUTE_RUNTIME_CONTRACT; -const emptyCodec = z.object({}).strict(); -const notFoundSplatCodec = z.object({ "*": z.string().optional() }).strict(); -const sampleResourceListQuery = z - .object({ - cursor: z.string().min(1).optional(), - limit: z.coerce.number().int().min(1).max(100).default(20), - tags: z - .preprocess( - (value) => - value === undefined - ? undefined - : Array.isArray(value) - ? value - : [value], - z.array(z.string().trim().min(1)), - ) - .optional(), - }) - .strict(); - -const codecs = { - none: emptyCodec, - NotFoundSplat: notFoundSplatCodec, - SampleResourceListQuery: sampleResourceListQuery, -} as const; - export type ParsedRouteInput = Readonly<{ routeId: RouteId; params: Readonly>; @@ -44,17 +16,23 @@ export type RouteInputResult = code: "ROUTE_PARAMS_INVALID" | "ROUTE_SEARCH_INVALID"; }>; +function codecById(codecId: string) { + const codec = ROUTE_CODECS[codecId as keyof typeof ROUTE_CODECS]; + if (!codec) throw new TypeError(`Unregistered route codec: ${codecId}`); + return codec; +} + export function parseRouteInput( routeId: RouteId, rawParams: Readonly>, rawSearch: URLSearchParams, ): RouteInputResult { const runtime = ROUTE_RUNTIME_CONTRACT[routeId]; - const params = codecs[runtime.paramsCodec].safeParse(rawParams); + const params = codecById(runtime.paramsCodec).safeParse(rawParams); if (!params.success) { return { success: false, code: "ROUTE_PARAMS_INVALID" }; } - const search = codecs[runtime.searchCodec].safeParse( + const search = codecById(runtime.searchCodec).safeParse( searchRecord(rawSearch), ); if (!search.success) { @@ -84,14 +62,18 @@ export function buildRouteUrl( throw new TypeError("The not-found route cannot build a canonical URL"); } const runtime = ROUTE_RUNTIME_CONTRACT[routeId]; - const params = codecs[runtime.paramsCodec].parse(input.params ?? {}); - const search = codecs[runtime.searchCodec].parse(input.search ?? {}); + const params = codecById(runtime.paramsCodec).parse(input.params ?? {}); + const search = codecById(runtime.searchCodec).parse(input.search ?? {}); const parsedParams: Record = { ...params }; const parsedSearch: Record = { ...search }; let path = definition.path; path = path.replace( /:([A-Za-z][A-Za-z0-9_]*)|\{([A-Za-z][A-Za-z0-9_]*)\}/g, - (_token, colonName: string | undefined, braceName: string | undefined) => { + ( + _token: string, + colonName: string | undefined, + braceName: string | undefined, + ) => { const name = colonName ?? braceName ?? ""; const value = parsedParams[name]; if (typeof value !== "string" && typeof value !== "number") { diff --git a/src/presentation/routes/route-runtime.tsx b/src/presentation/routes/route-runtime.tsx index 7955125..fc68869 100644 --- a/src/presentation/routes/route-runtime.tsx +++ b/src/presentation/routes/route-runtime.tsx @@ -4,8 +4,7 @@ import { type LazyExoticComponent, } from "react"; -import { ROUTE_RUNTIME_CONTRACT } from "../../contracts/route-runtime-contract.js"; -import type { RouteId } from "./route-codecs.js"; +import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../../contracts/route-runtime-contract.js"; type RouteModule = Readonly<{ default: ComponentType }>; type RouteRuntime = Readonly<{ @@ -14,16 +13,16 @@ type RouteRuntime = Readonly<{ }>; function runtime( - routeId: RouteId, + routeId: keyof typeof PLATFORM_ROUTE_RUNTIME_CONTRACT, load: () => Promise, ): RouteRuntime { return Object.freeze({ - moduleId: ROUTE_RUNTIME_CONTRACT[routeId].moduleId, + moduleId: PLATFORM_ROUTE_RUNTIME_CONTRACT[routeId].moduleId, Component: lazy(load), }); } -export const ROUTE_RUNTIME = { +export const PLATFORM_ROUTE_RUNTIME = { APP_HOME: runtime("APP_HOME", () => import("../pages/home-page.jsx")), EXAMPLES_UI: runtime( "EXAMPLES_UI", @@ -37,12 +36,8 @@ export const ROUTE_RUNTIME = { "EXAMPLES_AUTH", () => import("../examples/auth-example-page.jsx"), ), - SAMPLE_RESOURCE_LIST: runtime( - "SAMPLE_RESOURCE_LIST", - () => import("../pages/sample-contract-page.jsx"), - ), NOT_FOUND: runtime( "NOT_FOUND", () => import("../pages/not-found-page.jsx"), ), -} satisfies Record; +} satisfies Record; diff --git a/src/sample/contract-fixture/design-token-showcase.jsx b/src/sample/contract-fixture/design-token-showcase.jsx deleted file mode 100644 index 0629453..0000000 --- a/src/sample/contract-fixture/design-token-showcase.jsx +++ /dev/null @@ -1,15 +0,0 @@ -export function DesignTokenShowcase() { - return ( -
-

- Design token fixture -

-

- Semantic tokens style loading, empty, and terminal surfaces. -

- -
- ); -} diff --git a/src/sample/contract-fixture/sample-facade.js b/src/sample/contract-fixture/sample-facade.js deleted file mode 100644 index 62f4538..0000000 --- a/src/sample/contract-fixture/sample-facade.js +++ /dev/null @@ -1,53 +0,0 @@ -import { toResourceViewModel } from "../../application/view-models/resource-view-model.js"; -import { queryKeys } from "../../contracts/query-keys.js"; - -/** - * @param {{ - * http: { execute(operationId: string, input?: Record): Promise< - * {ok: true, value: unknown} | {ok: false, error: import("../../contracts/errors.js").ApiFailure} - * > }, - * cache: import("../../application/ports/query-cache-port.js").QueryCachePort - * }} ports - */ -export function createSampleFacade(ports) { - return Object.freeze({ - async listResources(filters = {}) { - const key = queryKeys.resource.list(filters); - const result = await ports.http.execute("LIST_SAMPLE_RESOURCES", { - routeId: "SAMPLE_RESOURCE_LIST", - }); - if (!result.ok) return result; - - const models = - /** @type {Array} */ ( - result.value - ); - const cached = ports.cache.write(key, models); - if (!cached.ok) return cached; - return { - ok: /** @type {true} */ (true), - value: models.map((model) => toResourceViewModel(model)), - }; - }, - - /** @param {{ name: string }} command */ - async createResource(command) { - const result = await ports.http.execute("CREATE_SAMPLE_RESOURCE", { - body: command, - routeId: "SAMPLE_RESOURCE_LIST", - }); - if (!result.ok) return result; - - const invalidated = await ports.cache.invalidate(queryKeys.resource.all()); - if (!invalidated.ok) return invalidated; - return { - ok: /** @type {true} */ (true), - value: toResourceViewModel( - /** @type {import("../../domain/models/resource.js").Resource} */ ( - result.value - ), - ), - }; - }, - }); -} diff --git a/src/sample/contract-fixture/sample-resource-page.jsx b/src/sample/contract-fixture/sample-resource-page.jsx deleted file mode 100644 index 6ace8ca..0000000 --- a/src/sample/contract-fixture/sample-resource-page.jsx +++ /dev/null @@ -1,61 +0,0 @@ -import { useEffect, useState } from "react"; - -import { deriveAsyncState } from "../../application/view-models/async-state.js"; -import { AsyncSurface } from "../../presentation/components/async-surface.jsx"; - -/** - * @typedef {{ - * loading: boolean, - * resources?: Array<{resourceId: string, title: string, createdAtLabel: string | null}>, - * failure?: import("../../contracts/errors.js").ApiFailure - * }} SamplePageState - */ - -/** - * @param {{ - * facade: ReturnType - * }} props - */ -export function SampleResourcePage({ facade }) { - const [result, setResult] = useState( - /** @type {SamplePageState} */ ({ - loading: true, - resources: undefined, - failure: undefined, - }), - ); - - useEffect(() => { - let active = true; - void facade.listResources().then((outcome) => { - if (!active) return; - setResult( - outcome.ok - ? { loading: false, resources: outcome.value, failure: undefined } - : { loading: false, resources: undefined, failure: outcome.error }, - ); - }); - return () => { - active = false; - }; - }, [facade]); - - const state = deriveAsyncState({ - isInitialLoading: result.loading, - data: result.resources, - failure: result.failure, - }); - - return ( -
-

샘플 리소스

- -
    - {(result.resources ?? []).map((resource) => ( -
  • {resource.title}
  • - ))} -
-
-
- ); -} diff --git a/tests/component/design-tokens.test.jsx b/tests/component/design-tokens.test.jsx index 44e8db8..5942590 100644 --- a/tests/component/design-tokens.test.jsx +++ b/tests/component/design-tokens.test.jsx @@ -3,12 +3,17 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; -import { DesignTokenShowcase } from "../../src/sample/contract-fixture/design-token-showcase.jsx"; +import { Button } from "../../src/presentation/components/ui/button.jsx"; +import { Card } from "../../src/presentation/components/ui/card.jsx"; describe("design-token fixture", () => { it("uses static semantic primitive classes", () => { - render(); - expect(screen.getByRole("region")).toHaveClass("ui-panel"); + render( + + + , + ); + expect(screen.getByRole("article")).toHaveClass("ui-card"); expect(screen.getByRole("button")).toHaveClass("ui-button"); }); }); diff --git a/tests/component/router.test.jsx b/tests/component/router.test.jsx index 29d1b80..c89c296 100644 --- a/tests/component/router.test.jsx +++ b/tests/component/router.test.jsx @@ -2,34 +2,29 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; -import { - createAnonymousSessionAdapter, - createDemoSessionAdapter, -} from "../../src/adapters/auth/external-session-adapter.js"; +import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.js"; import { ApplicationProvider } from "../../src/presentation/providers/application-provider.js"; import { AppRouter } from "../../src/presentation/routes/app-router.jsx"; import { createTestApplication } from "../helpers/create-test-application.js"; -/** - * @param {import("../../src/application/ports/auth-session-port.js").AuthSessionPort} session - * @param {Parameters[0]} [overrides] - */ -function renderRouter(session, overrides = {}) { +function renderRouter() { return render( , ); } -describe("application router", () => { - it("renders the app shell and not-found route without an API request", async () => { +describe("generic application router", () => { + it("renders the app shell and not-found route without a feature input", async () => { window.history.pushState({}, "", "/missing"); - renderRouter(createAnonymousSessionAdapter()); + renderRouter(); expect( await screen.findByRole("heading", { @@ -40,10 +35,10 @@ describe("application router", () => { expect(screen.getByRole("main")).toBeVisible(); }); - it("navigates between registry-backed example routes", async () => { + it("navigates between registry-backed platform routes", async () => { const user = userEvent.setup(); window.history.pushState({}, "", "/"); - renderRouter(createAnonymousSessionAdapter()); + renderRouter(); await user.click( await screen.findByRole("link", { name: "UI 구성요소" }), @@ -58,62 +53,4 @@ describe("application router", () => { screen.getByRole("heading", { name: "UI 구성요소", level: 1 }), ).toHaveFocus(); }); - - it("reacts to demo sign-in and opens the protected integration route", async () => { - const user = userEvent.setup(); - const authSession = createDemoSessionAdapter(); - window.history.pushState({}, "", "/sample/resources"); - renderRouter(authSession); - - expect( - await screen.findByRole("heading", { name: "세션이 필요합니다." }), - ).toBeVisible(); - await user.click(screen.getByRole("button", { name: "로그인 시작" })); - - expect( - await screen.findByRole("heading", { name: "보호된 연동 지점" }), - ).toBeVisible(); - expect(screen.getByText("인증됨")).toBeVisible(); - }); - - it("fails closed when the auth integration does not change state", async () => { - const user = userEvent.setup(); - window.history.pushState({}, "", "/sample/resources"); - renderRouter(createAnonymousSessionAdapter()); - - await user.click( - await screen.findByRole("button", { name: "로그인 시작" }), - ); - - expect( - screen.getByRole("heading", { name: "세션이 필요합니다." }), - ).toBeVisible(); - }); - - it("rejects invalid route search before any application query runs", async () => { - const getCurrent = vi.fn(async () => ({ - buildId: "test-build", - releaseId: "test-release", - configSchemaVersion: "1", - apiContractVersion: "1", - assetManifestHash: "test-hash", - routeChunks: { "route-sample-resources": "assets/sample.js" }, - })); - window.history.pushState({}, "", "/sample/resources?limit=invalid"); - renderRouter(createDemoSessionAdapter("authenticated"), { - releaseInfo: { getCurrent, refresh: getCurrent }, - }); - - expect( - await screen.findByRole("heading", { - name: "올바르지 않은 주소입니다.", - }), - ).toBeVisible(); - expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1); - expect(screen.getByText("안전한 탐색 링크를 사용해 주세요.")).toHaveAttribute( - "data-route-error", - "ROUTE_SEARCH_INVALID", - ); - expect(getCurrent).not.toHaveBeenCalled(); - }); }); diff --git a/tests/component/runtime-application.test.jsx b/tests/component/runtime-application.test.jsx index b3bdb44..3e5a49f 100644 --- a/tests/component/runtime-application.test.jsx +++ b/tests/component/runtime-application.test.jsx @@ -35,7 +35,6 @@ const releaseManifest = { "route-examples-ui": "assets/ui.js", "route-examples-states": "assets/states.js", "route-examples-auth": "assets/auth.js", - "route-sample-resources": "assets/sample.js", "route-not-found": "assets/not-found.js", }, }; diff --git a/tests/component/sample-resource-page.test.jsx b/tests/component/sample-resource-page.test.jsx deleted file mode 100644 index 63dc553..0000000 --- a/tests/component/sample-resource-page.test.jsx +++ /dev/null @@ -1,70 +0,0 @@ -// @vitest-environment jsdom - -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; - -import { SampleResourcePage } from "../../src/sample/contract-fixture/sample-resource-page.jsx"; - -describe("removable sample feature page", () => { - it("renders the API-to-view-model result through AsyncSurface", async () => { - /** @type {Parameters[0]["facade"]} */ - const facade = { - listResources: async () => ({ - ok: /** @type {const} */ (true), - value: [ - { - resourceId: "resource-1", - title: "Example", - createdAtLabel: null, - }, - ], - }), - createResource: async () => ({ - ok: /** @type {const} */ (true), - value: { - resourceId: "resource-created", - title: "Created", - createdAtLabel: null, - }, - }), - }; - render(); - - expect(screen.getByLabelText("불러오는 중")).toBeVisible(); - expect(await screen.findByText("Example")).toBeVisible(); - }); - - it("renders normalized terminal errors without raw DTO fields", async () => { - /** @type {Parameters[0]["facade"]} */ - const facade = { - listResources: async () => ({ - ok: /** @type {const} */ (false), - error: { - kind: "SERVER_FAILURE", - code: "SERVER_FAILURE", - retryable: true, - operationId: "LIST_SAMPLE_RESOURCES", - attemptCount: 1, - userMessageKey: "error.server_failure", - action: "retry", - }, - }), - createResource: async () => ({ - ok: /** @type {const} */ (true), - value: { - resourceId: "resource-created", - title: "Created", - createdAtLabel: null, - }, - }), - }; - render(); - - const alert = await screen.findByRole("alert"); - expect(alert).toHaveTextContent("요청을 완료하지 못했습니다."); - expect(alert).toHaveAttribute( - "data-message-key", - "error.server_failure", - ); - }); -}); diff --git a/tests/e2e/accessibility.spec.js b/tests/e2e/accessibility.spec.js index 76a4bdb..8fb5389 100644 --- a/tests/e2e/accessibility.spec.js +++ b/tests/e2e/accessibility.spec.js @@ -1,14 +1,10 @@ import AxeBuilder from "@axe-core/playwright"; import { expect, test } from "@playwright/test"; +import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.js"; -for (const route of [ - "/", - "/examples/ui", - "/examples/states", - "/examples/auth", - "/sample/resources", - "/not-found", -]) { +for (const route of Object.values(ROUTE_REGISTRY).map((definition) => + definition.path === "*" ? "/not-found" : definition.path, +)) { test(`@a11y ${route} has no critical or serious axe violations`, async ({ page, }) => { diff --git a/tests/e2e/app-shell.spec.js b/tests/e2e/app-shell.spec.js index f2bc6be..d4d7e07 100644 --- a/tests/e2e/app-shell.spec.js +++ b/tests/e2e/app-shell.spec.js @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.js"; test("boots the public app shell", async ({ page }) => { await page.goto("/"); @@ -27,7 +28,11 @@ test("navigates to a registry-backed example without a page reload", async ({ test("opens the protected integration route through the local demo seam", async ({ page, }) => { - await page.goto("/sample/resources"); + const protectedRoute = Object.values(ROUTE_REGISTRY).find( + (definition) => definition.access === "integration-defined", + ); + if (!protectedRoute) throw new Error("An integration route is required"); + await page.goto(protectedRoute.path); await expect( page.getByRole("heading", { name: "세션이 필요합니다." }), ).toBeVisible(); @@ -35,7 +40,7 @@ test("opens the protected integration route through the local demo seam", async await page.getByRole("button", { name: "로그인 시작" }).click(); await expect( - page.getByRole("heading", { name: "보호된 연동 지점" }), + page.getByRole("heading", { name: protectedRoute.title }), ).toBeVisible(); await expect(page.getByText("인증됨")).toBeVisible(); }); diff --git a/tests/features/reference-feature/reference-contract.test.ts b/tests/features/reference-feature/reference-contract.test.ts new file mode 100644 index 0000000..c1d9576 --- /dev/null +++ b/tests/features/reference-feature/reference-contract.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; + +import { + buildRouteUrl, + parseRouteInput, +} from "../../../src/presentation/routes/route-codecs.js"; +import { + mapReferenceOperation, + toReferenceView, +} from "../../../src/features/reference-feature/contracts/reference-mapper.js"; +import { + validateReferencePayload, + validateReferenceRequest, +} from "../../../src/features/reference-feature/contracts/reference-schemas.js"; +import { + REFERENCE_FEATURE_CONTRACT, + referenceQueryKeys, +} from "../../../src/features/reference-feature/contracts/reference-feature-contract.js"; + +describe("reference feature boundary contracts", () => { + it("round-trips one canonical filter through URL and query identity", () => { + const filters = { + tags: ["open", "new"], + cursor: "a/b", + limit: 5, + }; + const url = buildRouteUrl("REFERENCE_RESOURCE_LIST", { search: filters }); + expect(url).toBe( + "/examples/reference-resources?cursor=a%2Fb&limit=5&tags=open&tags=new", + ); + const parsed = parseRouteInput( + "REFERENCE_RESOURCE_LIST", + {}, + new URL(url, "https://app.test").searchParams, + ); + expect(parsed).toMatchObject({ + success: true, + data: { search: filters }, + }); + expect(referenceQueryKeys.list(filters).at(-1)).toEqual(filters); + }); + + it("rejects unknown search and malformed DTO before mapping", () => { + expect( + parseRouteInput( + "REFERENCE_RESOURCE_LIST", + {}, + new URLSearchParams("unknown=value"), + ), + ).toEqual({ success: false, code: "ROUTE_SEARCH_INVALID" }); + expect( + validateReferencePayload("ReferenceResourceListPayload", [ + { id: "unsafe", name: 42 }, + ]), + ).toMatchObject({ success: false }); + expect(() => + mapReferenceOperation("LIST_REFERENCE_RESOURCES", [ + { id: "unsafe", name: 42 }, + ]), + ).toThrow(); + }); + + it("normalizes request input and maps only owned domain fields", () => { + expect( + validateReferenceRequest("CreateReferenceResourceCommand", { + name: " Example ", + }), + ).toMatchObject({ success: true, data: { name: "Example" } }); + const model = mapReferenceOperation("CREATE_REFERENCE_RESOURCE", { + id: "reference-1", + name: "Example", + createdAt: "2026-07-26T00:00:00.000Z", + }); + if (!("id" in model)) throw new Error("expected one model"); + expect(toReferenceView(model, () => "formatted")).toEqual({ + resourceId: "reference-1", + title: "Example", + createdAtLabel: "formatted", + }); + }); + + it("owns route, operation and query contributions in one removable contract", () => { + expect(Object.keys(REFERENCE_FEATURE_CONTRACT.routes)).toEqual([ + "REFERENCE_RESOURCE_LIST", + ]); + expect(Object.keys(REFERENCE_FEATURE_CONTRACT.apiOperations)).toEqual([ + "LIST_REFERENCE_RESOURCES", + "CREATE_REFERENCE_RESOURCE", + ]); + expect(Object.keys(REFERENCE_FEATURE_CONTRACT.queryRegistry)).toEqual([ + "REFERENCE_RESOURCE", + ]); + }); +}); diff --git a/tests/features/reference-feature/reference-page.test.tsx b/tests/features/reference-feature/reference-page.test.tsx new file mode 100644 index 0000000..20293cd --- /dev/null +++ b/tests/features/reference-feature/reference-page.test.tsx @@ -0,0 +1,234 @@ +// @vitest-environment jsdom + +import { + QueryClient, + QueryClientProvider, +} from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { createDemoSessionAdapter } from "../../../src/adapters/auth/external-session-adapter.js"; +import type { + ReferenceFeatureInput, + ReferenceResult, +} from "../../../src/features/reference-feature/application/reference-feature-api.js"; +import { REFERENCE_FEATURE_ID } from "../../../src/features/reference-feature/contracts/reference-feature-contract.js"; +import type { ReferenceResourceView } from "../../../src/features/reference-feature/contracts/reference-mapper.js"; +import { createFailure } from "../../../src/contracts/errors.js"; +import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.js"; +import { AppRouter } from "../../../src/presentation/routes/app-router.js"; +import { createTestApplication } from "../../helpers/create-test-application.js"; + +function renderReference( + input: ReferenceFeatureInput, + url = "/examples/reference-resources?limit=5", +) { + window.history.pushState({}, "", url); + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: Infinity }, + mutations: { retry: false }, + }, + }); + return render( + + + + + , + ); +} + +function inputWith( + overrides: Partial = {}, +): ReferenceFeatureInput { + return { + listResources: async () => ({ ok: true, value: [] }), + createResource: async ({ name }) => ({ + ok: true, + value: { + resourceId: "created", + title: name, + createdAtLabel: null, + }, + }), + ...overrides, + }; +} + +describe("reference feature page states", () => { + it("renders loading, success and empty states through the installed route", async () => { + let resolveList: + | ((result: ReferenceResult) => void) + | undefined; + const pending = new Promise< + ReferenceResult + >((resolve) => { + resolveList = resolve; + }); + const loaded = renderReference( + inputWith({ listResources: async () => pending }), + ); + expect(await screen.findByLabelText("불러오는 중")).toBeVisible(); + resolveList?.({ + ok: true, + value: [ + { + resourceId: "reference-1", + title: "Loaded", + createdAtLabel: null, + }, + ], + }); + expect(await screen.findByText("Loaded")).toBeVisible(); + loaded.unmount(); + + renderReference(inputWith(), "/examples/reference-resources?limit=10"); + expect( + await screen.findByRole("heading", { + name: "표시할 항목이 없습니다.", + }), + ).toBeVisible(); + }); + + it("rejects invalid URL input before the feature application input", async () => { + const listResources = vi.fn(); + renderReference( + inputWith({ listResources }), + "/examples/reference-resources?limit=invalid", + ); + expect( + await screen.findByRole("heading", { + name: "올바르지 않은 주소입니다.", + }), + ).toBeVisible(); + expect(listResources).not.toHaveBeenCalled(); + }); + + it("renders backend forbidden even when the client access hint allowed entry", async () => { + renderReference( + inputWith({ + listResources: async () => ({ + ok: false, + error: createFailure( + "FORBIDDEN", + "LIST_REFERENCE_RESOURCES", + 0, + ), + }), + }), + ); + expect(await screen.findByRole("alert")).toHaveTextContent( + "이 작업을 수행할 권한이 없습니다.", + ); + }); + + it("deduplicates optimistic create and rolls back a conflict", async () => { + const user = userEvent.setup(); + let finish: + | ((result: ReferenceResult) => void) + | undefined; + const createResource = vi.fn( + () => + new Promise>((resolve) => { + finish = resolve; + }), + ); + renderReference( + inputWith({ + listResources: async () => ({ + ok: true, + value: [ + { + resourceId: "existing", + title: "Existing", + createdAtLabel: null, + }, + ], + }), + createResource, + }), + ); + await screen.findByText("Existing"); + await user.type(screen.getByLabelText("새 항목 이름"), "Conflicting"); + const submit = screen.getByRole("button", { name: "추가" }); + await user.dblClick(submit); + + await waitFor(() => expect(createResource).toHaveBeenCalledOnce()); + expect(await screen.findByText("Conflicting")).toHaveAttribute( + "data-optimistic", + "true", + ); + expect(screen.getByText("mutation-pending")).toBeVisible(); + + finish?.({ + ok: false, + error: createFailure( + "CONFLICT", + "CREATE_REFERENCE_RESOURCE", + 0, + ), + }); + expect( + await screen.findByRole("button", { name: "충돌 해결" }), + ).toBeVisible(); + expect(screen.queryByText("Conflicting")).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "충돌 해결" })); + await waitFor(() => + expect(screen.queryByText("mutation-conflict")).not.toBeInTheDocument(), + ); + }); + + it("keeps stale data visible during refresh failure and recovers on retry", async () => { + const user = userEvent.setup(); + const listResources = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + value: [ + { + resourceId: "existing", + title: "Existing", + createdAtLabel: null, + }, + ], + }) + .mockResolvedValueOnce({ + ok: false, + error: createFailure( + "SERVER_FAILURE", + "LIST_REFERENCE_RESOURCES", + 0, + ), + }) + .mockResolvedValue({ + ok: true, + value: [ + { + resourceId: "recovered", + title: "Recovered", + createdAtLabel: null, + }, + ], + }); + renderReference(inputWith({ listResources })); + await screen.findByText("Existing"); + await user.type(screen.getByLabelText("새 항목 이름"), "Created"); + await user.click(screen.getByRole("button", { name: "추가" })); + + expect(await screen.findByText("stale-degraded")).toBeVisible(); + expect(screen.getByText("Existing")).toBeVisible(); + await user.click(screen.getByRole("button", { name: "다시 시도" })); + expect(await screen.findByText("Recovered")).toBeVisible(); + await waitFor(() => + expect(screen.queryByText("stale-degraded")).not.toBeInTheDocument(), + ); + expect(listResources).toHaveBeenCalledTimes(3); + }); +}); diff --git a/tests/features/reference-feature/reference-production-vertical.test.tsx b/tests/features/reference-feature/reference-production-vertical.test.tsx new file mode 100644 index 0000000..4d0608f --- /dev/null +++ b/tests/features/reference-feature/reference-production-vertical.test.tsx @@ -0,0 +1,129 @@ +// @vitest-environment jsdom + +import { HttpResponse, http } from "msw"; +import { setupServer } from "msw/node"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; + +import { createRuntimeComposition } from "../../../src/bootstrap/create-runtime-composition.js"; +import { RuntimeApplication } from "../../../src/bootstrap/runtime-application.jsx"; + +const runtimeConfig = { + APP_ENV: "local", + API_BASE_URL: "https://api.test", + REQUEST_TIMEOUT_MS: 10_000, + MAX_RETRY_ATTEMPTS: 0, + TELEMETRY_ENABLED: false, + AUTH_MODE: "demo", + CONFIG_SCHEMA_VERSION: "1", + API_CONTRACT_VERSION: "1", + RELEASE_MANIFEST_URL: "/release-manifest.json", + BUILD_ID: "local-build", + RELEASE_ID: "local-release", +}; +const releaseManifest = { + schemaVersion: 1, + appVersion: "0.1.0", + buildId: "local-build", + commitSha: "local", + configSchemaVersion: "1", + apiContractVersion: "1", + assetManifestHash: "test-hash", + releaseId: "local-release", + builtAt: "2026-07-26T00:00:00.000Z", + routeChunks: { + "route-home": "assets/home.js", + "route-examples-ui": "assets/ui.js", + "route-examples-states": "assets/states.js", + "route-examples-auth": "assets/auth.js", + "route-reference-resources": "assets/reference.js", + "route-not-found": "assets/not-found.js", + }, +}; + +const listRequests = vi.fn(); +const createRequests = vi.fn(); +const resources = [{ id: "reference-1", name: "Existing" }]; +const server = setupServer( + http.get("http://app.test/config.json", () => + HttpResponse.json(runtimeConfig), + ), + http.get("http://app.test/release-manifest.json", () => + HttpResponse.json(releaseManifest), + ), + http.get("https://api.test/api/reference-resources", ({ request }) => { + listRequests(new URL(request.url).search); + return HttpResponse.json({ + success: true, + data: resources, + meta: { requestId: "request-list", traceId: "trace-list" }, + }); + }), + http.post("https://api.test/api/reference-resources", async ({ request }) => { + const body = (await request.json()) as { name: string }; + createRequests(body); + const created = { id: "reference-created", name: body.name }; + resources.push(created); + return HttpResponse.json({ + success: true, + data: created, + meta: { requestId: "request-create", traceId: "trace-create" }, + }); + }), +); + +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterEach(() => { + listRequests.mockClear(); + createRequests.mockClear(); + resources.splice(1); +}); +afterAll(() => server.close()); + +const absoluteFetch: typeof fetch = (input, init) => { + if (input instanceof Request) return fetch(input, init); + const url = new URL( + input instanceof URL ? input.href : input, + "http://app.test", + ); + return fetch(url, init); +}; + +describe("reference feature production vertical path", () => { + it("traverses bootstrap, router, application, HTTP schema/mapper and query cache", async () => { + const user = userEvent.setup(); + const composition = await createRuntimeComposition({ + fetcher: absoluteFetch, + host: {}, + }); + window.history.pushState( + {}, + "", + "/examples/reference-resources?tags=open&tags=new&limit=5", + ); + render(); + + await user.click( + await screen.findByRole("button", { name: "로그인 시작" }), + ); + expect(await screen.findByText("Existing")).toBeVisible(); + expect(listRequests).toHaveBeenCalledWith( + "?limit=5&tags=open&tags=new", + ); + + await user.type(screen.getByLabelText("새 항목 이름"), " Created "); + await user.click(screen.getByRole("button", { name: "추가" })); + expect(await screen.findByText("Created")).toBeVisible(); + expect(createRequests).toHaveBeenCalledWith({ name: "Created" }); + expect(listRequests.mock.calls.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/tests/fixtures/typecheck/invalid-route-runtime.ts b/tests/fixtures/typecheck/invalid-route-runtime.ts index 86df381..143e358 100644 --- a/tests/fixtures/typecheck/invalid-route-runtime.ts +++ b/tests/fixtures/typecheck/invalid-route-runtime.ts @@ -1,4 +1,4 @@ -import { ROUTE_RUNTIME_CONTRACT } from "../../../src/contracts/route-runtime-contract.js"; +import { ROUTE_RUNTIME_CONTRACT } from "../../../src/features/installed-feature-contracts.js"; type RouteId = keyof typeof ROUTE_RUNTIME_CONTRACT; diff --git a/tests/helpers/create-test-application.js b/tests/helpers/create-test-application.js index c54f83b..f254c02 100644 --- a/tests/helpers/create-test-application.js +++ b/tests/helpers/create-test-application.js @@ -7,46 +7,48 @@ import { createApplication } from "../../src/application/create-application.js"; * preferences?: import("../../src/application/ports/storage-port.js").StoragePort, * diagnostics?: import("../../src/application/ports/telemetry-port.js").TelemetryPort, * releaseInfo?: import("../../src/application/ports/release-info-port.js").ReleaseInfoPort, - * navigation?: { reload(): void } + * navigation?: { reload(): void }, + * featureInputs?: Readonly> * }} [overrides] */ export function createTestApplication(overrides = {}) { - return createApplication({ - session: overrides.session ?? createAnonymousSessionAdapter(), - preferences: - overrides.preferences ?? - { - read: () => ({ ok: /** @type {const} */ (true), value: "system" }), - write: () => ({ ok: /** @type {const} */ (true) }), - remove: () => ({ ok: /** @type {const} */ (true) }), - }, - diagnostics: overrides.diagnostics ?? { emit: () => {} }, - releaseInfo: - overrides.releaseInfo ?? - { - getCurrent: async () => ({ - buildId: "test-build", - releaseId: "test-release", - configSchemaVersion: "1", - apiContractVersion: "1", - assetManifestHash: "test-hash", - routeChunks: { - "route-home": "assets/home.js", - "route-sample-resources": "assets/sample.js", - }, - }), - refresh: async () => ({ - buildId: "test-build", - releaseId: "test-release", - configSchemaVersion: "1", - apiContractVersion: "1", - assetManifestHash: "test-hash", - routeChunks: { - "route-home": "assets/home.js", - "route-sample-resources": "assets/sample.js", - }, - }), - }, - navigation: overrides.navigation ?? { reload: () => {} }, - }); + return createApplication( + { + session: overrides.session ?? createAnonymousSessionAdapter(), + preferences: + overrides.preferences ?? + { + read: () => ({ ok: /** @type {const} */ (true), value: "system" }), + write: () => ({ ok: /** @type {const} */ (true) }), + remove: () => ({ ok: /** @type {const} */ (true) }), + }, + diagnostics: overrides.diagnostics ?? { emit: () => {} }, + releaseInfo: + overrides.releaseInfo ?? + { + getCurrent: async () => ({ + buildId: "test-build", + releaseId: "test-release", + configSchemaVersion: "1", + apiContractVersion: "1", + assetManifestHash: "test-hash", + routeChunks: { + "route-home": "assets/home.js", + }, + }), + refresh: async () => ({ + buildId: "test-build", + releaseId: "test-release", + configSchemaVersion: "1", + apiContractVersion: "1", + assetManifestHash: "test-hash", + routeChunks: { + "route-home": "assets/home.js", + }, + }), + }, + navigation: overrides.navigation ?? { reload: () => {} }, + }, + overrides.featureInputs, + ); } diff --git a/tests/helpers/http-contract-fixture.ts b/tests/helpers/http-contract-fixture.ts new file mode 100644 index 0000000..732ac3e --- /dev/null +++ b/tests/helpers/http-contract-fixture.ts @@ -0,0 +1,108 @@ +import { z } from "zod"; + +import type { createHttpClient } from "../../src/adapters/http/client.js"; +import { canonicalize } from "../../src/contracts/query-keys.js"; + +const entitySchema = z + .object({ + id: z.string().min(1), + name: z.string().min(1), + }) + .passthrough(); +const payloadSchemas = { + EntityListPayload: z.array(entitySchema), + EntityPayload: entitySchema, +}; +const requestSchemas = { + EntityListQuery: z + .object({ + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(100).default(20), + tags: z.array(z.string().trim().min(1)).optional(), + }) + .strict(), + CreateEntityCommand: z + .object({ name: z.string().trim().min(1).max(120) }) + .strict(), +}; + +export const TEST_OPERATIONS = Object.freeze({ + LIST_ENTITIES: Object.freeze({ + method: "GET", + path: "/api/entities", + operationId: "LIST_ENTITIES", + auth: "external-session", + timeoutMs: null, + idempotency: "safe", + retry: "runtime", + requestSource: "search", + requestSchema: "EntityListQuery", + responseSchema: "EntityListPayload", + owner: "test-fixture", + }), + CREATE_ENTITY: Object.freeze({ + method: "POST", + path: "/api/entities", + operationId: "CREATE_ENTITY", + auth: "external-session", + timeoutMs: null, + idempotency: "keyed", + retry: "runtime", + requestSource: "body", + requestSchema: "CreateEntityCommand", + responseSchema: "EntityPayload", + owner: "test-fixture", + }), +}); + +export const entityQueryKeys = Object.freeze({ + list: (filters: Readonly> = {}) => + Object.freeze(["entity", 1, canonicalize(filters)]), +}); + +type Validation = + | Readonly<{ success: true; data: unknown }> + | Readonly<{ success: false }>; + +function project(schema: z.ZodType | undefined, value: unknown): Validation { + const result = schema?.safeParse(value); + if (!result?.success) return { success: false }; + return { success: true, data: structuredClone(result.data) }; +} + +type HttpDependencies = Parameters[0]; + +export const TEST_HTTP_CONTRACT = Object.freeze({ + getOperation(operationId: string) { + const operation = + TEST_OPERATIONS[operationId as keyof typeof TEST_OPERATIONS]; + if (!operation) throw new Error(`Unknown test operation: ${operationId}`); + return operation; + }, + validatePayload(schemaId: string, value: unknown) { + return project( + payloadSchemas[schemaId as keyof typeof payloadSchemas], + value, + ); + }, + validateRequest(schemaId: string, value: unknown) { + return project( + requestSchemas[schemaId as keyof typeof requestSchemas], + value, + ); + }, + mapPayload(operationId: string, payload: unknown) { + const mapOne = (value: unknown) => { + const entity = value as { id: string; name: string }; + return { id: entity.id, displayName: entity.name }; + }; + if (operationId === "LIST_ENTITIES") { + return (payload as readonly unknown[]).map(mapOne); + } + if (operationId === "CREATE_ENTITY") return mapOne(payload); + throw new Error(`Unknown test mapper: ${operationId}`); + }, +}) satisfies Pick< + HttpDependencies, + "getOperation" | "validatePayload" | "validateRequest" | "mapPayload" +>; diff --git a/tests/integration/auth-recovery.test.js b/tests/integration/auth-recovery.test.js index 72955b0..cae4d1d 100644 --- a/tests/integration/auth-recovery.test.js +++ b/tests/integration/auth-recovery.test.js @@ -4,11 +4,12 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest import { createExternalAuthSessionAdapter } from "../../src/adapters/auth/external-session-adapter.js"; import { createHttpClient } from "../../src/adapters/http/client.js"; +import { TEST_HTTP_CONTRACT } from "../helpers/http-contract-fixture.js"; /** @type {number[]} */ let responseStatuses = []; const server = setupServer( - http.get("https://api.test/api/sample/resources", () => { + http.get("https://api.test/api/entities", () => { const status = responseStatuses.shift() ?? 200; if (status === 401) { return HttpResponse.json( @@ -37,6 +38,11 @@ afterAll(() => server.close()); const clock = { now: () => 0, sleep: async () => {} }; +/** @param {Parameters[0]} options */ +function testClient(options) { + return createHttpClient({ ...TEST_HTTP_CONTRACT, ...options }); +} + /** * @param {Partial[0]>} overrides * @returns {Parameters[0]} @@ -63,13 +69,13 @@ describe("bounded 401 session recovery", () => { recoverSession, notifyUnauthenticated: vi.fn(), })); - const client = createHttpClient({ + const client = testClient({ baseUrl: "https://api.test", authSession, clock, }); - await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({ + await expect(client.execute("LIST_ENTITIES")).resolves.toMatchObject({ ok: true, }); expect(recoverSession).toHaveBeenCalledTimes(1); @@ -83,13 +89,13 @@ describe("bounded 401 session recovery", () => { recoverSession: async () => "restored", notifyUnauthenticated, })); - const client = createHttpClient({ + const client = testClient({ baseUrl: "https://api.test", authSession, clock, }); - await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({ + await expect(client.execute("LIST_ENTITIES")).resolves.toMatchObject({ ok: false, error: { kind: "AUTH_REQUIRED" }, }); @@ -104,13 +110,13 @@ describe("bounded 401 session recovery", () => { recoverSession: async () => "restored", notifyUnauthenticated: vi.fn(), })); - const client = createHttpClient({ + const client = testClient({ baseUrl: "https://api.test", authSession: attachFailure, clock, }); - await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({ + await expect(client.execute("LIST_ENTITIES")).resolves.toMatchObject({ ok: false, error: { kind: "AUTH_INTEGRATION_FAILURE" }, }); diff --git a/tests/integration/http-client.test.js b/tests/integration/http-client.test.js index 6ca6640..c0208ee 100644 --- a/tests/integration/http-client.test.js +++ b/tests/integration/http-client.test.js @@ -3,10 +3,11 @@ import { setupServer } from "msw/node"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { createHttpClient } from "../../src/adapters/http/client.js"; +import { TEST_HTTP_CONTRACT } from "../helpers/http-contract-fixture.js"; let attempts = 0; const server = setupServer( - http.get("https://api.test/api/sample/resources", () => { + http.get("https://api.test/api/entities", () => { attempts += 1; if (attempts < 3) { return HttpResponse.json( @@ -34,16 +35,21 @@ const clock = { sleep: async () => {}, }; +/** @param {Parameters[0]} options */ +function testClient(options) { + return createHttpClient({ ...TEST_HTTP_CONTRACT, ...options }); +} + describe("shared HTTP client", () => { it("retries a safe request at most twice and returns validated data", async () => { - const client = createHttpClient({ + const client = testClient({ baseUrl: "https://api.test", clock, random: () => 0, }); await expect( - client.execute("LIST_SAMPLE_RESOURCES", { routeId: "SAMPLE_RESOURCE_LIST" }), + client.execute("LIST_ENTITIES", { routeId: "TEST_ROUTE" }), ).resolves.toMatchObject({ ok: true, value: [{ id: "resource-1", displayName: "Example" }], @@ -55,12 +61,12 @@ describe("shared HTTP client", () => { it("rejects a non-JSON response without exposing its body", async () => { server.use( http.get( - "https://api.test/api/sample/resources", + "https://api.test/api/entities", () => new HttpResponse("raw body", { status: 502 }), ), ); - const client = createHttpClient({ baseUrl: "https://api.test", clock }); - const result = await client.execute("LIST_SAMPLE_RESOURCES"); + const client = testClient({ baseUrl: "https://api.test", clock }); + const result = await client.execute("LIST_ENTITIES"); expect(result).toMatchObject({ ok: false, @@ -72,21 +78,21 @@ describe("shared HTTP client", () => { it("classifies malformed JSON and invalid payloads at the boundary", async () => { server.use( http.get( - "https://api.test/api/sample/resources", + "https://api.test/api/entities", () => new HttpResponse("{", { headers: { "Content-Type": "application/json" }, }), ), ); - const client = createHttpClient({ baseUrl: "https://api.test", clock }); - await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({ + const client = testClient({ baseUrl: "https://api.test", clock }); + await expect(client.execute("LIST_ENTITIES")).resolves.toMatchObject({ ok: false, error: { kind: "MALFORMED_JSON" }, }); server.use( - http.get("https://api.test/api/sample/resources", () => + http.get("https://api.test/api/entities", () => HttpResponse.json({ success: true, data: [{ id: "resource-1", name: 42 }], @@ -94,7 +100,7 @@ describe("shared HTTP client", () => { }), ), ); - await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({ + await expect(client.execute("LIST_ENTITIES")).resolves.toMatchObject({ ok: false, error: { kind: "SCHEMA_MISMATCH" }, }); @@ -102,7 +108,7 @@ describe("shared HTTP client", () => { it("guards mapper exceptions as UNKNOWN_FAILURE", async () => { server.use( - http.get("https://api.test/api/sample/resources", () => + http.get("https://api.test/api/entities", () => HttpResponse.json({ success: true, data: [{ id: "resource-1", name: "Example" }], @@ -110,7 +116,7 @@ describe("shared HTTP client", () => { }), ), ); - const client = createHttpClient({ + const client = testClient({ baseUrl: "https://api.test", clock, mapPayload: () => { @@ -118,7 +124,7 @@ describe("shared HTTP client", () => { }, }); - const result = await client.execute("LIST_SAMPLE_RESOURCES"); + const result = await client.execute("LIST_ENTITIES"); expect(result).toMatchObject({ ok: false, error: { kind: "UNKNOWN_FAILURE" }, diff --git a/tests/integration/http-execution-contract.test.js b/tests/integration/http-execution-contract.test.js index e8e766d..07896a6 100644 --- a/tests/integration/http-execution-contract.test.js +++ b/tests/integration/http-execution-contract.test.js @@ -1,7 +1,10 @@ import { describe, expect, it, vi } from "vitest"; import { createHttpClient } from "../../src/adapters/http/client.js"; -import { queryKeys } from "../../src/contracts/query-keys.js"; +import { + entityQueryKeys, + TEST_HTTP_CONTRACT, +} from "../helpers/http-contract-fixture.js"; /** @param {unknown} data */ function successResponse(data) { @@ -40,6 +43,11 @@ function recordingScheduler() { }; } +/** @param {Parameters[0]} options */ +function testClient(options) { + return createHttpClient({ ...TEST_HTTP_CONTRACT, ...options }); +} + describe("HTTP operation execution contract", () => { it("sends parsed search/body values and aligns canonical query identity", async () => { const requests = /** @type {Request[]} */ ([]); @@ -51,7 +59,7 @@ describe("HTTP operation execution contract", () => { } return successResponse([]); }); - const client = createHttpClient({ + const client = testClient({ baseUrl: "https://api.test", fetcher, clock: immediateClock(), @@ -60,21 +68,21 @@ describe("HTTP operation execution contract", () => { const filters = { tags: ["open", "new"], cursor: "a/b", limit: 5 }; await client.execute({ - operationId: "LIST_SAMPLE_RESOURCES", - routeId: "SAMPLE_RESOURCE_LIST", + operationId: "LIST_ENTITIES", + routeId: "TEST_ROUTE", searchParams: filters, }); await client.execute({ - operationId: "CREATE_SAMPLE_RESOURCE", - routeId: "SAMPLE_RESOURCE_LIST", + operationId: "CREATE_ENTITY", + routeId: "TEST_ROUTE", body: { name: " Trimmed " }, idempotencyKey: "logical-command", }); expect(requests[0].url).toBe( - "https://api.test/api/sample/resources?cursor=a%2Fb&limit=5&tags=open&tags=new", + "https://api.test/api/entities?cursor=a%2Fb&limit=5&tags=open&tags=new", ); - expect(queryKeys.resource.list(filters).at(-1)).toEqual(filters); + expect(entityQueryKeys.list(filters).at(-1)).toEqual(filters); await expect(requests[1].json()).resolves.toEqual({ name: "Trimmed" }); expect(requests[1].headers.get("Idempotency-Key")).toBe("logical-command"); expect(scheduler.setTimeout).toHaveBeenCalledTimes(2); @@ -84,7 +92,7 @@ describe("HTTP operation execution contract", () => { it("performs no fetch or timer work for invalid request input", async () => { const fetcher = vi.fn(); const scheduler = recordingScheduler(); - const client = createHttpClient({ + const client = testClient({ baseUrl: "https://api.test", fetcher, scheduler, @@ -92,8 +100,8 @@ describe("HTTP operation execution contract", () => { await expect( client.execute({ - operationId: "CREATE_SAMPLE_RESOURCE", - routeId: "SAMPLE_RESOURCE_LIST", + operationId: "CREATE_ENTITY", + routeId: "TEST_ROUTE", body: { name: " " }, }), ).resolves.toMatchObject({ @@ -114,7 +122,7 @@ describe("HTTP operation execution contract", () => { async (maxRetryAttempts, totalAttempts) => { const fetcher = vi.fn(async () => failureResponse(503)); const scheduler = recordingScheduler(); - const client = createHttpClient({ + const client = testClient({ baseUrl: "https://api.test", fetcher, clock: immediateClock(), @@ -124,8 +132,8 @@ describe("HTTP operation execution contract", () => { await expect( client.execute({ - operationId: "LIST_SAMPLE_RESOURCES", - routeId: "SAMPLE_RESOURCE_LIST", + operationId: "LIST_ENTITIES", + routeId: "TEST_ROUTE", }), ).resolves.toMatchObject({ ok: false, @@ -149,15 +157,15 @@ describe("HTTP operation execution contract", () => { ); }), ); - const client = createHttpClient({ + const client = testClient({ baseUrl: "https://api.test", fetcher, scheduler, maxRetryAttempts: 0, }); const timeoutResult = client.execute({ - operationId: "LIST_SAMPLE_RESOURCES", - routeId: "SAMPLE_RESOURCE_LIST", + operationId: "LIST_ENTITIES", + routeId: "TEST_ROUTE", }); await vi.waitFor(() => expect(scheduler.callbacks).toHaveLength(1)); scheduler.callbacks[0](); @@ -170,8 +178,8 @@ describe("HTTP operation execution contract", () => { const add = vi.spyOn(caller.signal, "addEventListener"); const remove = vi.spyOn(caller.signal, "removeEventListener"); const abortResult = client.execute({ - operationId: "LIST_SAMPLE_RESOURCES", - routeId: "SAMPLE_RESOURCE_LIST", + operationId: "LIST_ENTITIES", + routeId: "TEST_ROUTE", signal: caller.signal, }); await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2)); @@ -196,11 +204,11 @@ describe("HTTP operation execution contract", () => { retry: "never", requestSource: "none", requestSchema: "unused", - responseSchema: "SampleResourcePayload", + responseSchema: "EntityPayload", owner: "test", }); const unsafeFetch = vi.fn(async () => failureResponse(503)); - const unsafeClient = createHttpClient({ + const unsafeClient = testClient({ baseUrl: "https://api.test", fetcher: unsafeFetch, clock: immediateClock(), @@ -213,14 +221,14 @@ describe("HTTP operation execution contract", () => { expect(unsafeFetch).toHaveBeenCalledOnce(); const statusFetch = vi.fn(async () => failureResponse(500)); - const statusClient = createHttpClient({ + const statusClient = testClient({ baseUrl: "https://api.test", fetcher: statusFetch, clock: immediateClock(), }); await statusClient.execute({ - operationId: "LIST_SAMPLE_RESOURCES", - routeId: "SAMPLE_RESOURCE_LIST", + operationId: "LIST_ENTITIES", + routeId: "TEST_ROUTE", }); expect(statusFetch).toHaveBeenCalledOnce(); @@ -228,15 +236,15 @@ describe("HTTP operation execution contract", () => { successResponse([{ id: "one", name: 42 }]), ); const schemaScheduler = recordingScheduler(); - const schemaClient = createHttpClient({ + const schemaClient = testClient({ baseUrl: "https://api.test", fetcher: schemaFetch, clock: immediateClock(), scheduler: schemaScheduler, }); await schemaClient.execute({ - operationId: "LIST_SAMPLE_RESOURCES", - routeId: "SAMPLE_RESOURCE_LIST", + operationId: "LIST_ENTITIES", + routeId: "TEST_ROUTE", }); expect(schemaFetch).toHaveBeenCalledOnce(); expect(schemaScheduler.clearTimeout).toHaveBeenCalledOnce(); diff --git a/tests/integration/sample-vertical-slice.test.js b/tests/integration/sample-vertical-slice.test.js deleted file mode 100644 index 414ac28..0000000 --- a/tests/integration/sample-vertical-slice.test.js +++ /dev/null @@ -1,53 +0,0 @@ -import { HttpResponse, http } from "msw"; -import { setupServer } from "msw/node"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; - -import { createHttpClient } from "../../src/adapters/http/client.js"; -import { - createQueryCacheAdapter, - createQueryClient, -} from "../../src/adapters/query-cache/tanstack-query-cache.js"; -import { queryKeys } from "../../src/contracts/query-keys.js"; -import { createSampleFacade } from "../../src/sample/contract-fixture/sample-facade.js"; - -const server = setupServer( - http.get("https://api.test/api/sample/resources", () => - HttpResponse.json({ - success: true, - data: [{ id: "resource-1", name: "Example" }], - meta: { requestId: "request-1", traceId: "trace-1" }, - }), - ), -); - -beforeAll(() => server.listen({ onUnhandledRequest: "error" })); -afterAll(() => server.close()); - -describe("sample vertical contract fixture", () => { - it("traverses API, schema, mapper, application facade, and cache", async () => { - const queryClient = createQueryClient(); - const cache = createQueryCacheAdapter(queryClient); - const facade = createSampleFacade({ - http: createHttpClient({ - baseUrl: "https://api.test", - clock: { now: () => 0, sleep: async () => {} }, - }), - cache, - }); - - await expect(facade.listResources()).resolves.toEqual({ - ok: true, - value: [ - { - resourceId: "resource-1", - title: "Example", - createdAtLabel: null, - }, - ], - }); - expect(cache.read(queryKeys.resource.list({}))).toMatchObject({ - ok: true, - value: [{ id: "resource-1", displayName: "Example" }], - }); - }); -}); diff --git a/tests/runtime-schema/http-schema.test.js b/tests/runtime-schema/http-schema.test.js index 9fd8d6e..42b1cb0 100644 --- a/tests/runtime-schema/http-schema.test.js +++ b/tests/runtime-schema/http-schema.test.js @@ -6,49 +6,34 @@ import { validateOperationRequest, } from "../../src/adapters/http/schema-registry.js"; -describe("HTTP runtime schema boundary", () => { +describe("HTTP platform schema boundary", () => { it("rejects an invalid top-level envelope", () => { expect(validateEnvelope({ success: true }).success).toBe(false); }); - it("rejects an invalid operation payload with safe issue metadata", () => { - const result = validateOperationPayload("SampleResourceListPayload", [ - { id: "resource-1", name: 42 }, - ]); - - expect(result).toMatchObject({ - success: false, - issues: [{ path: "0.name" }], - }); - expect(JSON.stringify(result)).not.toContain("resource-1"); - }); - - it("returns a deep-cloned additive-tolerant payload", () => { - const source = [{ id: "resource-1", name: "Example", additive: "accepted" }]; - const result = validateOperationPayload("SampleResourceListPayload", source); - - expect(result).toMatchObject({ + it("accepts and clones a generic valid response envelope", () => { + const source = { success: true, - data: [{ id: "resource-1", additive: "accepted" }], - }); - if (!result.success) throw new Error("expected valid sample payload"); + data: [], + meta: { requestId: "request-1", traceId: "trace-1" }, + }; + const result = validateEnvelope(source); + expect(result).toMatchObject({ success: true, data: source }); + if (!result.success) throw new Error("expected valid envelope"); expect(result.data).not.toBe(source); }); - it("validates outbound commands before transport", () => { - expect( - validateOperationRequest("CreateSampleResourceCommand", { name: "" }).success, - ).toBe(false); - expect( - validateOperationRequest("CreateSampleResourceCommand", { name: "Example" }) - .success, - ).toBe(true); - }); - - it("fails closed for an unregistered schema", () => { - expect(validateOperationPayload("UnknownPayload", {})).toMatchObject({ - success: false, - issues: [{ code: "SCHEMA_NOT_REGISTERED" }], + it("fails closed without leaking input when a feature schema is absent", () => { + const result = validateOperationPayload("UnknownPayload", { + secret: "not-projected", }); + expect(result).toMatchObject({ + success: false, + issues: [{ path: "", code: "SCHEMA_NOT_REGISTERED" }], + }); + expect(JSON.stringify(result)).not.toContain("not-projected"); + expect( + validateOperationRequest("UnknownCommand", { name: "Example" }).success, + ).toBe(false); }); }); diff --git a/tests/unit/application-boundary.test.ts b/tests/unit/application-boundary.test.ts index 30e5628..8cd4ffd 100644 --- a/tests/unit/application-boundary.test.ts +++ b/tests/unit/application-boundary.test.ts @@ -16,10 +16,15 @@ describe("application input/output boundary", () => { "diagnostics", "runtime", "recovery", + "features", ]); expect(application).not.toHaveProperty("storage"); expect(application).not.toHaveProperty("telemetry"); expect(application).not.toHaveProperty("releaseInfo"); + expect(application.features.has("not-installed")).toBe(false); + expect(() => application.features.get("not-installed")).toThrow( + "Application feature is not installed", + ); await expect(application.runtime.getReleaseSummary()).resolves.toEqual({ buildId: "test-build", releaseId: "test-release", diff --git a/tests/unit/boundary-mapper.test.js b/tests/unit/boundary-mapper.test.js index 838b1c0..8229697 100644 --- a/tests/unit/boundary-mapper.test.js +++ b/tests/unit/boundary-mapper.test.js @@ -1,52 +1,11 @@ import { describe, expect, it } from "vitest"; -import { - mapOperationPayload, - mapResourceDto, -} from "../../src/adapters/http/resource-mapper.js"; -import { toResourceViewModel } from "../../src/application/view-models/resource-view-model.js"; +import { mapOperationPayload } from "../../src/adapters/http/resource-mapper.js"; -describe("DTO to model to view-model mapping", () => { - it("contains raw DTO names at the HTTP boundary", () => { - const model = mapResourceDto({ - id: "resource-1", - name: "Example", - createdAt: "2026-07-25T00:00:00.000Z", - backendOnly: "not propagated", - }); - - expect(model).toEqual({ - id: "resource-1", - displayName: "Example", - createdAt: "2026-07-25T00:00:00.000Z", - }); - expect(model).not.toHaveProperty("name"); - expect(model).not.toHaveProperty("backendOnly"); - }); - - it("maps operation payloads and rejects missing mappers", () => { - expect( - mapOperationPayload("LIST_SAMPLE_RESOURCES", [ - { id: "resource-1", name: "Example" }, - ]), - ).toEqual([ - { id: "resource-1", displayName: "Example", createdAt: null }, - ]); +describe("platform DTO mapper boundary", () => { + it("fails closed when no feature mapper was injected", () => { expect(() => mapOperationPayload("UNKNOWN", {})).toThrow( "No boundary mapper registered", ); }); - - it("projects an application-owned render-ready shape", () => { - const model = mapResourceDto({ - id: "resource-1", - name: "Example", - createdAt: null, - }); - expect(toResourceViewModel(model)).toEqual({ - resourceId: "resource-1", - title: "Example", - createdAtLabel: null, - }); - }); }); diff --git a/tests/unit/error-classification.test.js b/tests/unit/error-classification.test.js index 2a44a8b..4c9c29d 100644 --- a/tests/unit/error-classification.test.js +++ b/tests/unit/error-classification.test.js @@ -49,7 +49,7 @@ describe("frontend failure classification", () => { }; const result = createFailure( "SERVER_FAILURE", - "LIST_SAMPLE_RESOURCES", + "LIST_ENTITIES", 0, untrustedDetails, ); diff --git a/tests/unit/manual-a11y-evidence.test.js b/tests/unit/manual-a11y-evidence.test.js index 80dabff..4d87d45 100644 --- a/tests/unit/manual-a11y-evidence.test.js +++ b/tests/unit/manual-a11y-evidence.test.js @@ -4,7 +4,7 @@ import { MANUAL_A11Y_ROUTE_IDS, validateManualA11yEvidence, } from "../../scripts/lib/manual-a11y-evidence.mjs"; -import { ROUTE_REGISTRY } from "../../src/contracts/routes.js"; +import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.js"; const reviewed = `Status: reviewed Route ID: APP_HOME diff --git a/tests/unit/navigation-policy.test.js b/tests/unit/navigation-policy.test.js index d235481..a6690c2 100644 --- a/tests/unit/navigation-policy.test.js +++ b/tests/unit/navigation-policy.test.js @@ -1,126 +1,35 @@ import { describe, expect, it } from "vitest"; +import { + NAVIGATION_ROUTES, + ROUTE_REGISTRY, +} from "../../src/features/installed-feature-contracts.js"; import { createRedirectLoopGuard, decideRouteAccess, } from "../../src/presentation/routes/navigation-policy.js"; -import { - NAVIGATION_ROUTES, - 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", - "navigationLabel": "시작", - "navigationOrder": 10, - "paramsSchema": null, - "path": "/", - "routeId": "APP_HOME", - "searchSchema": null, - "title": "시작", - }, - "EXAMPLES_AUTH": { - "access": "public", - "chunkId": "route-examples-auth", - "errorSurface": "route-boundary", - "loadingSurface": "example-page", - "navigationLabel": "인증 연동", - "navigationOrder": 40, - "paramsSchema": null, - "path": "/examples/auth", - "routeId": "EXAMPLES_AUTH", - "searchSchema": null, - "title": "인증 연동", - }, - "EXAMPLES_STATES": { - "access": "public", - "chunkId": "route-examples-states", - "errorSurface": "route-boundary", - "loadingSurface": "example-page", - "navigationLabel": "화면 상태", - "navigationOrder": 30, - "paramsSchema": null, - "path": "/examples/states", - "routeId": "EXAMPLES_STATES", - "searchSchema": null, - "title": "화면 상태", - }, - "EXAMPLES_UI": { - "access": "public", - "chunkId": "route-examples-ui", - "errorSurface": "route-boundary", - "loadingSurface": "example-page", - "navigationLabel": "UI 구성요소", - "navigationOrder": 20, - "paramsSchema": null, - "path": "/examples/ui", - "routeId": "EXAMPLES_UI", - "searchSchema": null, - "title": "UI 구성요소", - }, - "NOT_FOUND": { - "access": "public", - "chunkId": "route-not-found", - "errorSurface": "not-found", - "loadingSurface": "none", - "navigationLabel": null, - "navigationOrder": null, - "paramsSchema": "NotFoundSplat", - "path": "*", - "routeId": "NOT_FOUND", - "searchSchema": null, - "title": "페이지를 찾을 수 없음", - }, - "SAMPLE_RESOURCE_LIST": { - "access": "integration-defined", - "chunkId": "route-sample-resources", - "errorSurface": "feature-boundary", - "loadingSurface": "sample-resource-list", - "navigationLabel": "보호된 연동 지점", - "navigationOrder": 50, - "paramsSchema": null, - "path": "/sample/resources", - "routeId": "SAMPLE_RESOURCE_LIST", - "searchSchema": "SampleResourceListQuery", - "title": "보호된 연동 지점", - }, - } - `); - }); +describe("installed route registry", () => { it("derives visible navigation in explicit order", () => { - expect(NAVIGATION_ROUTES.map(({ routeId }) => routeId)).toEqual([ - "APP_HOME", - "EXAMPLES_UI", - "EXAMPLES_STATES", - "EXAMPLES_AUTH", - "SAMPLE_RESOURCE_LIST", - ]); + const expected = Object.values(ROUTE_REGISTRY) + .filter((route) => route.navigationOrder !== null) + .sort( + (left, right) => + /** @type {number} */ (left.navigationOrder) - + /** @type {number} */ (right.navigationOrder), + ) + .map((route) => route.routeId); + expect(NAVIGATION_ROUTES.map(({ routeId }) => routeId)).toEqual(expected); }); - it("treats client access as a UX hint, not authorization", () => { + it("allows public routes without consulting a product permission", () => { 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", () => { + it("bounds automatic redirects by pair and maximum hops", () => { const guard = createRedirectLoopGuard(2); expect(guard.allow("/private", "/signin")).toBe(true); expect(guard.allow("/private", "/signin")).toBe(false); diff --git a/tests/unit/query-cache.test.js b/tests/unit/query-cache.test.js index f92732a..c5bac1f 100644 --- a/tests/unit/query-cache.test.js +++ b/tests/unit/query-cache.test.js @@ -5,18 +5,27 @@ import { createQueryCacheAdapter, createQueryClient, } from "../../src/adapters/query-cache/tanstack-query-cache.js"; -import { queryKeys } from "../../src/contracts/query-keys.js"; +import { canonicalize } from "../../src/contracts/query-keys.js"; + +const queryKeys = Object.freeze({ + all: () => Object.freeze(["entity", 1]), + list: (filters = {}) => + Object.freeze(["entity", 1, "list", canonicalize(filters)]), + /** @param {string} entityId */ + detail: (entityId) => + Object.freeze(["entity", 1, "detail", String(entityId)]), +}); describe("query key registry", () => { it("canonicalizes filter order into the same stable key", () => { - expect(queryKeys.resource.list({ page: 1, status: "open" })).toEqual( - queryKeys.resource.list({ status: "open", page: 1 }), + expect(queryKeys.list({ page: 1, status: "open" })).toEqual( + queryKeys.list({ status: "open", page: 1 }), ); }); it("contains no raw URL or token material", () => { - expect(JSON.stringify(queryKeys.resource.detail("resource-1"))).toBe( - '["resource",1,"detail","resource-1"]', + expect(JSON.stringify(queryKeys.detail("entity-1"))).toBe( + '["entity",1,"detail","entity-1"]', ); }); }); @@ -25,7 +34,7 @@ describe("TanStack QueryCachePort adapter", () => { it("reads, writes, and invalidates only the declared namespace", async () => { const client = createQueryClient(); const adapter = createQueryCacheAdapter(client); - const listKey = queryKeys.resource.list({ page: 1 }); + const listKey = queryKeys.list({ page: 1 }); const otherKey = ["other", 1]; expect(adapter.write(listKey, [{ id: "resource-1" }])).toEqual({ ok: true }); @@ -35,7 +44,7 @@ describe("TanStack QueryCachePort adapter", () => { value: [{ id: "resource-1" }], }); - await adapter.invalidate(queryKeys.resource.all()); + await adapter.invalidate(queryKeys.all()); expect(client.getQueryState(listKey)?.isInvalidated).toBe(true); expect(client.getQueryState(otherKey)?.isInvalidated).toBe(false); }); diff --git a/tests/unit/route-runtime.test.ts b/tests/unit/route-runtime.test.ts index b1285c5..62a3697 100644 --- a/tests/unit/route-runtime.test.ts +++ b/tests/unit/route-runtime.test.ts @@ -1,15 +1,14 @@ import { describe, expect, it } from "vitest"; -import { ROUTE_RUNTIME_CONTRACT } from "../../src/contracts/route-runtime-contract.js"; -import { ROUTE_REGISTRY } from "../../src/contracts/routes.js"; import { - buildRouteUrl, - parseRouteInput, -} from "../../src/presentation/routes/route-codecs.js"; -import { ROUTE_RUNTIME } from "../../src/presentation/routes/route-runtime.js"; + ROUTE_REGISTRY, + ROUTE_RUNTIME_CONTRACT, +} from "../../src/features/installed-feature-contracts.js"; +import { ROUTE_RUNTIME } from "../../src/features/installed-feature-runtimes.js"; +import { parseRouteInput } from "../../src/presentation/routes/route-codecs.js"; -describe("typed route contract and runtime", () => { - it("keeps contract, runtime contribution, and executable module complete", () => { +describe("typed installed route catalog", () => { + it("keeps contract and executable runtime contributions complete", () => { expect(Object.keys(ROUTE_RUNTIME_CONTRACT).sort()).toEqual( Object.keys(ROUTE_REGISTRY).sort(), ); @@ -18,46 +17,7 @@ describe("typed route contract and runtime", () => { ); }); - it("round-trips canonical search through the registered codec", () => { - const url = buildRouteUrl("SAMPLE_RESOURCE_LIST", { - search: { - tags: ["open", "new"], - cursor: "a/b", - limit: 5, - }, - }); - expect(url).toBe( - "/sample/resources?cursor=a%2Fb&limit=5&tags=open&tags=new", - ); - const parsedUrl = new URL(url, "https://app.test"); - expect( - parseRouteInput( - "SAMPLE_RESOURCE_LIST", - {}, - parsedUrl.searchParams, - ), - ).toEqual({ - success: true, - data: { - routeId: "SAMPLE_RESOURCE_LIST", - params: {}, - search: { - cursor: "a/b", - limit: 5, - tags: ["open", "new"], - }, - }, - }); - }); - - it("rejects unknown search and accepts the not-found splat owner", () => { - expect( - parseRouteInput( - "SAMPLE_RESOURCE_LIST", - {}, - new URLSearchParams("unknown=value"), - ), - ).toEqual({ success: false, code: "ROUTE_SEARCH_INVALID" }); + it("accepts the platform not-found splat owner", () => { expect( parseRouteInput( "NOT_FOUND", diff --git a/tests/unit/runtime-adapters.test.js b/tests/unit/runtime-adapters.test.js index d57e3a1..40181d1 100644 --- a/tests/unit/runtime-adapters.test.js +++ b/tests/unit/runtime-adapters.test.js @@ -4,6 +4,7 @@ import { createRuntimeAdapters, createRuntimeHttpClient, } from "../../src/bootstrap/runtime-adapters.js"; +import { TEST_HTTP_CONTRACT } from "../helpers/http-contract-fixture.js"; const runtime = { config: { @@ -118,23 +119,26 @@ describe("runtime adapter composition", () => { release, host: {}, })).outputPorts.session; - const client = createRuntimeHttpClient({ - runtime: - /** @type {Parameters[0]["runtime"]} */ ( - runtime - ), - authSession: - /** @type {import("../../src/application/ports/auth-session-port.js").AuthSessionPort} */ ( - authSession - ), - fetcher, - clock: { now: () => 0, sleep: async () => {} }, - scheduler, - }); + const client = createRuntimeHttpClient( + { + runtime: + /** @type {Parameters[0]["runtime"]} */ ( + runtime + ), + authSession: + /** @type {import("../../src/application/ports/auth-session-port.js").AuthSessionPort} */ ( + authSession + ), + fetcher, + clock: { now: () => 0, sleep: async () => {} }, + scheduler, + }, + TEST_HTTP_CONTRACT, + ); await client.execute({ - operationId: "LIST_SAMPLE_RESOURCES", - routeId: "SAMPLE_RESOURCE_LIST", + operationId: "LIST_ENTITIES", + routeId: "TEST_ROUTE", }); expect(fetcher).toHaveBeenCalledOnce(); diff --git a/tests/unit/telemetry.test.js b/tests/unit/telemetry.test.js index 67ccb0a..c5c8106 100644 --- a/tests/unit/telemetry.test.js +++ b/tests/unit/telemetry.test.js @@ -13,7 +13,7 @@ const validAttributes = { error_kind: "SERVER_FAILURE", http_status_group: "5xx", attempt_count_bucket: "3", - route_id: "SAMPLE_RESOURCE_LIST", + route_id: "TEST_ROUTE", }; describe("telemetry registry and redaction", () => {