Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c11be43f20 | ||
|
|
980981bc86 |
+33
-1
@@ -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" },
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -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:
|
||||
+10
-4
@@ -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"],
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 수직 테스트가 실행된다.
|
||||
|
||||
#### 비동기·복구 상태의 불변식이 닫혀 있지 않다
|
||||
|
||||
|
||||
@@ -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 품질
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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을
|
||||
|
||||
@@ -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: {
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
+185
-45
@@ -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<Record<string, import("../contracts/routes.js").RouteDefinition>>} */ (
|
||||
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<Record<string, unknown>>): unknown;
|
||||
}>;
|
||||
export function createInstalledFeatureInputs(_context: FeatureContext) {
|
||||
void _context;
|
||||
return Object.freeze({});
|
||||
}
|
||||
`;
|
||||
|
||||
/** @param {string} directory @returns {Promise<string[]>} */
|
||||
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",
|
||||
`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
||||
`<testsuite name="sample-removal" tests="2" failures="${passed ? 0 : 1}">` +
|
||||
`<testcase name="no-product-import"/>` +
|
||||
`<testcase name="production-build">${passed ? "" : "<failure/>"}</testcase>` +
|
||||
`<testsuite name="reference-feature-removal" tests="${checks.length}" failures="${passed ? 0 : 1}">` +
|
||||
checks
|
||||
.map(
|
||||
([name, result]) =>
|
||||
`<testcase name="${name}">${result ? "" : `<failure>${[...residue, ...builtResidue].join(", ")}</failure>`}</testcase>`,
|
||||
)
|
||||
.join("") +
|
||||
`</testsuite>\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`,
|
||||
);
|
||||
|
||||
@@ -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 {{
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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<string, unknown>} */ (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}`);
|
||||
}
|
||||
|
||||
@@ -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<Record<string, z.ZodType>>} */ (Object.freeze({
|
||||
SampleResourceListPayload: z.array(sampleResourceSchema),
|
||||
SampleResourcePayload: sampleResourceSchema,
|
||||
}));
|
||||
/** @type {Readonly<Record<string, z.ZodType>>} */ (Object.freeze({}));
|
||||
|
||||
const requestSchemas =
|
||||
/** @type {Readonly<Record<string, z.ZodType>>} */ (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<Record<string, z.ZodType>>} */ (Object.freeze({}));
|
||||
|
||||
/** @param {unknown} value */
|
||||
export function validateEnvelope(value) {
|
||||
|
||||
@@ -15,6 +15,7 @@ export type { ApplicationApi, ApplicationOutputPorts };
|
||||
*/
|
||||
export function createApplication(
|
||||
outputPorts: ApplicationOutputPorts,
|
||||
featureInputs: Readonly<Record<string, unknown>> = {},
|
||||
): 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -47,4 +47,8 @@ export type ApplicationApi = Readonly<{
|
||||
| Readonly<{ action: "support"; reason: string }>
|
||||
>;
|
||||
}>;
|
||||
features: Readonly<{
|
||||
has(featureId: string): boolean;
|
||||
get(featureId: string): unknown;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* @template Query
|
||||
* @template Model
|
||||
* @typedef {{ execute(query: Query, context?: RequestContext): Promise<Result<Model>> }} ResourceQueryPort
|
||||
*/
|
||||
|
||||
/**
|
||||
* @template Command
|
||||
* @template Model
|
||||
* @typedef {{ execute(command: Command, context?: RequestContext): Promise<Result<Model>> }} ResourceCommandPort
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* operationId: string,
|
||||
* routeId: string,
|
||||
* signal?: AbortSignal,
|
||||
* idempotencyKey?: string
|
||||
* }} RequestContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* @template Value
|
||||
* @typedef {{ ok: true, value: Value, meta?: Record<string, unknown> } |
|
||||
* { ok: false, error: import("../../contracts/errors.js").ApiFailure }} Result
|
||||
*/
|
||||
|
||||
export {};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -16,7 +16,8 @@ import { createApplication } from "../application/create-application.js";
|
||||
* release: Release
|
||||
* }): Promise<{
|
||||
* outputPorts: OutputPorts,
|
||||
* infrastructure: Infrastructure
|
||||
* infrastructure: Infrastructure,
|
||||
* featureInputs?: Readonly<Record<string, unknown>>
|
||||
* }>
|
||||
* }} factories
|
||||
* @returns {Promise<Readonly<{
|
||||
@@ -30,7 +31,10 @@ export async function createCompositionRoot(factories) {
|
||||
const config = await factories.loadConfig();
|
||||
const release = await factories.loadRelease(config);
|
||||
const adapters = await factories.createAdapters({ config, release });
|
||||
const application = createApplication(adapters.outputPorts);
|
||||
const application = createApplication(
|
||||
adapters.outputPorts,
|
||||
adapters.featureInputs,
|
||||
);
|
||||
|
||||
return Object.freeze({
|
||||
config,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { createBrowserStorageAdapter } from "../adapters/storage/browser-storage-adapter.js";
|
||||
import { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.js";
|
||||
import { fetchReleaseManifest } from "./load-release-manifest.js";
|
||||
import { createInstalledFeatureInputs } from "../features/installed-feature-adapters.js";
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} host
|
||||
@@ -54,7 +55,7 @@ function storageOrUndefined(value) {
|
||||
* scheduler?: Parameters<typeof createHttpClient>[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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<string, ApiOperation>} */ (API_OPERATIONS);
|
||||
export function getApiOperation(operationId, operations = API_OPERATIONS) {
|
||||
const registry = /** @type {Record<string, ApiOperation>} */ (operations);
|
||||
const selected = registry[operationId];
|
||||
if (!selected) {
|
||||
throw new Error(`Unregistered API operation: ${operationId}`);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1
-39
@@ -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<string, Readonly<RouteDefinition>>} */ (
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
/**
|
||||
* @typedef {{
|
||||
* id: string,
|
||||
* displayName: string,
|
||||
* createdAt: string | null
|
||||
* }} Resource
|
||||
*/
|
||||
|
||||
/** @param {Resource} values @returns {Readonly<Resource>} */
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createReferenceFeatureInstalledInput } from "./reference-feature/adapters/create-reference-feature-input.js";
|
||||
|
||||
export function createInstalledFeatureInputs(
|
||||
context: Parameters<typeof createReferenceFeatureInstalledInput>[0],
|
||||
): Readonly<Record<string, unknown>> {
|
||||
const installed = [createReferenceFeatureInstalledInput(context)];
|
||||
return Object.freeze(
|
||||
Object.fromEntries(
|
||||
installed.map((contribution) => [
|
||||
contribution.featureId,
|
||||
contribution.input,
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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<Record<string, import("../contracts/routes.js").RouteDefinition>>} */ (
|
||||
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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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개를 검사한다.
|
||||
@@ -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<typeof createReferenceHttpGateway>[0];
|
||||
|
||||
export function createReferenceFeatureInstalledInput(context: Readonly<{
|
||||
createHttpClient(contract: HttpContract): HttpExecutor;
|
||||
}>) {
|
||||
const operations =
|
||||
REFERENCE_FEATURE_CONTRACT.apiOperations as Readonly<Record<string, unknown>>;
|
||||
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)),
|
||||
});
|
||||
}
|
||||
@@ -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<HttpResult>;
|
||||
}>;
|
||||
|
||||
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;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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<Value> =
|
||||
| Readonly<{ ok: true; value: Value }>
|
||||
| Readonly<{ ok: false; error: ApiFailure }>;
|
||||
|
||||
export type ReferenceFeatureInput = Readonly<{
|
||||
listResources(
|
||||
filters: ReferenceListFilters,
|
||||
context?: Readonly<{ signal?: AbortSignal }>,
|
||||
): Promise<ReferenceResult<readonly ReferenceResourceView[]>>;
|
||||
createResource(
|
||||
command: Readonly<{ name: string }>,
|
||||
): Promise<ReferenceResult<ReferenceResourceView>>;
|
||||
}>;
|
||||
|
||||
export type ReferenceGateway = Readonly<{
|
||||
list(
|
||||
filters: ReferenceListFilters,
|
||||
context?: Readonly<{ signal?: AbortSignal }>,
|
||||
): Promise<ReferenceResult<readonly ReferenceResource[]>>;
|
||||
create(
|
||||
command: Readonly<{ name: string }>,
|
||||
): Promise<ReferenceResult<ReferenceResource>>;
|
||||
}>;
|
||||
|
||||
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;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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<string, z.ZodType>;
|
||||
|
||||
const requestSchemas = {
|
||||
ReferenceResourceListQuery: referenceResourceListQuerySchema,
|
||||
CreateReferenceResourceCommand: z
|
||||
.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
})
|
||||
.strict(),
|
||||
} satisfies Record<string, z.ZodType>;
|
||||
|
||||
function project(result: z.ZodSafeParseResult<unknown>) {
|
||||
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" }] };
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const result = await mutation.submit({ name });
|
||||
if (result.ok) setName("");
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="ui-page">
|
||||
<PageHeader
|
||||
eyebrow="제거 가능한 수직 슬라이스"
|
||||
title="Reference feature"
|
||||
description="URL codec, application input, HTTP/schema/mapper와 query/mutation 상태를 한 경로로 검증합니다."
|
||||
/>
|
||||
<p data-testid="reference-filter">
|
||||
limit {filters.limit}
|
||||
{filters.tags?.length ? ` · tags ${filters.tags.join(", ")}` : ""}
|
||||
</p>
|
||||
<AsyncSurface state={query.state} onRetry={query.retry}>
|
||||
<ul aria-label="Reference resources">
|
||||
{(query.data ?? []).map((resource) => (
|
||||
<li
|
||||
key={resource.resourceId}
|
||||
data-optimistic={resource.optimistic || undefined}
|
||||
>
|
||||
{resource.title}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AsyncSurface>
|
||||
<AsyncSurface
|
||||
state={mutation.state}
|
||||
onResolveConflict={mutation.resolveConflict}
|
||||
>
|
||||
<form onSubmit={(event) => void submit(event)}>
|
||||
<label htmlFor="reference-resource-name">새 항목 이름</label>
|
||||
<input
|
||||
id="reference-resource-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Button type="submit" disabled={mutation.state.overlay.mutationPending}>
|
||||
추가
|
||||
</Button>
|
||||
</form>
|
||||
</AsyncSurface>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 (
|
||||
<section className="ui-page">
|
||||
<PageHeader
|
||||
eyebrow="보호 라우트"
|
||||
title="보호된 연동 지점"
|
||||
description="실제 도메인 기능이 인증된 세션과 연결되는 위치를 보여주는 중립적인 계약 화면입니다."
|
||||
/>
|
||||
<section className="ui-panel" aria-labelledby="protected-state-title">
|
||||
<h2 id="protected-state-title">라우트 접근 허용</h2>
|
||||
<p>
|
||||
현재 세션 상태는 <strong>{sessionState}</strong>입니다. 서버의
|
||||
권한 검증은 이 클라이언트 라우트 정책과 별도로 유지해야 합니다.
|
||||
</p>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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<ParsedRouteInput | null>(null);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getRoute } from "../../contracts/routes.js";
|
||||
import { getRoute } from "../../features/installed-feature-contracts.js";
|
||||
|
||||
/**
|
||||
* @param {string} routeId
|
||||
|
||||
@@ -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;
|
||||
@@ -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<Record<string, unknown>>;
|
||||
@@ -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<Record<string, string | undefined>>,
|
||||
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<string, unknown> = { ...params };
|
||||
const parsedSearch: Record<string, unknown> = { ...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") {
|
||||
|
||||
@@ -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<RouteModule>,
|
||||
): 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<RouteId, RouteRuntime>;
|
||||
} satisfies Record<keyof typeof PLATFORM_ROUTE_RUNTIME_CONTRACT, RouteRuntime>;
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
export function DesignTokenShowcase() {
|
||||
return (
|
||||
<section className="ui-panel" aria-labelledby="token-showcase-title">
|
||||
<h2 id="token-showcase-title" className="text-xl font-semibold">
|
||||
Design token fixture
|
||||
</h2>
|
||||
<p className="text-content-muted">
|
||||
Semantic tokens style loading, empty, and terminal surfaces.
|
||||
</p>
|
||||
<button className="ui-button" type="button">
|
||||
Token action
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown>): 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<import("../../domain/models/resource.js").Resource>} */ (
|
||||
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
|
||||
),
|
||||
),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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<typeof import("./sample-facade.js").createSampleFacade>
|
||||
* }} 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 (
|
||||
<main>
|
||||
<h1>샘플 리소스</h1>
|
||||
<AsyncSurface state={state}>
|
||||
<ul>
|
||||
{(result.resources ?? []).map((resource) => (
|
||||
<li key={resource.resourceId}>{resource.title}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AsyncSurface>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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(<DesignTokenShowcase />);
|
||||
expect(screen.getByRole("region")).toHaveClass("ui-panel");
|
||||
render(
|
||||
<Card title="Design token fixture">
|
||||
<Button>Token action</Button>
|
||||
</Card>,
|
||||
);
|
||||
expect(screen.getByRole("article")).toHaveClass("ui-card");
|
||||
expect(screen.getByRole("button")).toHaveClass("ui-button");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof createTestApplication>[0]} [overrides]
|
||||
*/
|
||||
function renderRouter(session, overrides = {}) {
|
||||
function renderRouter() {
|
||||
return render(
|
||||
<ApplicationProvider
|
||||
application={createTestApplication({ ...overrides, session })}
|
||||
application={createTestApplication({
|
||||
session: createAnonymousSessionAdapter(),
|
||||
})}
|
||||
>
|
||||
<AppRouter />
|
||||
</ApplicationProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<typeof SampleResourcePage>[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(<SampleResourcePage facade={facade} />);
|
||||
|
||||
expect(screen.getByLabelText("불러오는 중")).toBeVisible();
|
||||
expect(await screen.findByText("Example")).toBeVisible();
|
||||
});
|
||||
|
||||
it("renders normalized terminal errors without raw DTO fields", async () => {
|
||||
/** @type {Parameters<typeof SampleResourcePage>[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(<SampleResourcePage facade={facade} />);
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert).toHaveTextContent("요청을 완료하지 못했습니다.");
|
||||
expect(alert).toHaveAttribute(
|
||||
"data-message-key",
|
||||
"error.server_failure",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<QueryClientProvider client={client}>
|
||||
<ApplicationProvider
|
||||
application={createTestApplication({
|
||||
session: createDemoSessionAdapter("authenticated"),
|
||||
featureInputs: { [REFERENCE_FEATURE_ID]: input },
|
||||
})}
|
||||
>
|
||||
<AppRouter />
|
||||
</ApplicationProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function inputWith(
|
||||
overrides: Partial<ReferenceFeatureInput> = {},
|
||||
): 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<readonly ReferenceResourceView[]>) => void)
|
||||
| undefined;
|
||||
const pending = new Promise<
|
||||
ReferenceResult<readonly ReferenceResourceView[]>
|
||||
>((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<ReferenceResourceView>) => void)
|
||||
| undefined;
|
||||
const createResource = vi.fn(
|
||||
() =>
|
||||
new Promise<ReferenceResult<ReferenceResourceView>>((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<ReferenceFeatureInput["listResources"]>()
|
||||
.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);
|
||||
});
|
||||
});
|
||||
@@ -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(<RuntimeApplication composition={composition} />);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -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;
|
||||
|
||||
|
||||
@@ -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<Record<string, unknown>>
|
||||
* }} [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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Record<string, unknown>> = {}) =>
|
||||
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<typeof createHttpClient>[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"
|
||||
>;
|
||||
@@ -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<typeof createHttpClient>[0]} options */
|
||||
function testClient(options) {
|
||||
return createHttpClient({ ...TEST_HTTP_CONTRACT, ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Partial<Parameters<typeof createExternalAuthSessionAdapter>[0]>} overrides
|
||||
* @returns {Parameters<typeof createExternalAuthSessionAdapter>[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" },
|
||||
});
|
||||
|
||||
@@ -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<typeof createHttpClient>[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("<secret>raw body</secret>", { 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" },
|
||||
|
||||
@@ -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<typeof createHttpClient>[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();
|
||||
|
||||
@@ -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" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,7 +49,7 @@ describe("frontend failure classification", () => {
|
||||
};
|
||||
const result = createFailure(
|
||||
"SERVER_FAILURE",
|
||||
"LIST_SAMPLE_RESOURCES",
|
||||
"LIST_ENTITIES",
|
||||
0,
|
||||
untrustedDetails,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof createRuntimeHttpClient>[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<typeof createRuntimeHttpClient>[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();
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user