Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c11be43f20 | ||
|
|
980981bc86 |
+33
-1
@@ -29,10 +29,42 @@ module.exports = {
|
|||||||
from: { path: "^src/adapters" },
|
from: { path: "^src/adapters" },
|
||||||
to: { path: "^src/(presentation|bootstrap)" },
|
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",
|
name: "concrete-adapters-compose-only-in-bootstrap",
|
||||||
severity: "error",
|
severity: "error",
|
||||||
from: { path: "^src/(domain|application|presentation|contracts|sample)" },
|
from: { path: "^src/(domain|application|presentation|contracts)" },
|
||||||
to: { path: "^src/adapters" },
|
to: { path: "^src/adapters" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
# SAMPLE_RESOURCE_LIST accessibility review
|
# SAMPLE_RESOURCE_LIST accessibility review
|
||||||
|
|
||||||
Status: pending-manual-review
|
Status: pending-manual-review
|
||||||
Route ID: SAMPLE_RESOURCE_LIST
|
Route ID: REFERENCE_RESOURCE_LIST
|
||||||
Release ID:
|
Release ID:
|
||||||
Reviewer:
|
Reviewer:
|
||||||
Reviewed at:
|
Reviewed at:
|
||||||
+10
-4
@@ -109,9 +109,15 @@
|
|||||||
},
|
},
|
||||||
"FE-GATE-007": {
|
"FE-GATE-007": {
|
||||||
"name": "integration",
|
"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",
|
"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"
|
"retentionClass": "merge-cycle"
|
||||||
},
|
},
|
||||||
"FE-GATE-008": {
|
"FE-GATE-008": {
|
||||||
@@ -134,7 +140,7 @@
|
|||||||
"artifacts/tests/a11y-manual/EXAMPLES_UI.md",
|
"artifacts/tests/a11y-manual/EXAMPLES_UI.md",
|
||||||
"artifacts/tests/a11y-manual/EXAMPLES_STATES.md",
|
"artifacts/tests/a11y-manual/EXAMPLES_STATES.md",
|
||||||
"artifacts/tests/a11y-manual/EXAMPLES_AUTH.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/NOT_FOUND.md",
|
||||||
"artifacts/tests/a11y-manual/report.json"
|
"artifacts/tests/a11y-manual/report.json"
|
||||||
],
|
],
|
||||||
@@ -256,7 +262,7 @@
|
|||||||
"retentionClass": "release-coherence"
|
"retentionClass": "release-coherence"
|
||||||
},
|
},
|
||||||
"FE-GATE-020": {
|
"FE-GATE-020": {
|
||||||
"name": "sample-removal",
|
"name": "reference-feature-removal",
|
||||||
"steps": [{ "script": "test:sample-removal", "expect": "pass" }],
|
"steps": [{ "script": "test:sample-removal", "expect": "pass" }],
|
||||||
"logPath": "artifacts/quality/gates/FE-GATE-020.txt",
|
"logPath": "artifacts/quality/gates/FE-GATE-020.txt",
|
||||||
"evidence": ["artifacts/tests/sample-removal.xml"],
|
"evidence": ["artifacts/tests/sample-removal.xml"],
|
||||||
|
|||||||
@@ -3,17 +3,17 @@
|
|||||||
"registries": [
|
"registries": [
|
||||||
{
|
{
|
||||||
"registryId": "FE-REG-ROUTE",
|
"registryId": "FE-REG-ROUTE",
|
||||||
"path": "src/contracts/routes.js",
|
"path": "src/features/installed-feature-contracts.js",
|
||||||
"exportName": "ROUTE_REGISTRY",
|
"exportName": "ROUTE_REGISTRY",
|
||||||
"owner": "feature-routing-navigation-guard-contract",
|
"owner": "feature-routing-navigation-guard-contract",
|
||||||
"uniqueFields": ["routeId", "path", "chunkId"],
|
"uniqueFields": ["routeId", "path", "chunkId"],
|
||||||
"allowedValues": {
|
"allowedValues": {
|
||||||
"paramsSchema": [null, "NotFoundSplat"],
|
"paramsSchema": [null, "NotFoundSplat"],
|
||||||
"searchSchema": [null, "SampleResourceListQuery"],
|
"searchSchema": [null, "ReferenceResourceListQuery"],
|
||||||
"loadingSurface": [
|
"loadingSurface": [
|
||||||
"app-shell",
|
"app-shell",
|
||||||
"example-page",
|
"example-page",
|
||||||
"sample-resource-list",
|
"reference-resource-list",
|
||||||
"none"
|
"none"
|
||||||
],
|
],
|
||||||
"errorSurface": [
|
"errorSurface": [
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
"route-examples-ui",
|
"route-examples-ui",
|
||||||
"route-examples-states",
|
"route-examples-states",
|
||||||
"route-examples-auth",
|
"route-examples-auth",
|
||||||
"route-sample-resources",
|
"route-reference-resources",
|
||||||
"route-not-found"
|
"route-not-found"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"registryId": "FE-REG-ROUTE-RUNTIME",
|
"registryId": "FE-REG-ROUTE-RUNTIME",
|
||||||
"path": "src/contracts/route-runtime-contract.js",
|
"path": "src/features/installed-feature-contracts.js",
|
||||||
"exportName": "ROUTE_RUNTIME_CONTRACT",
|
"exportName": "ROUTE_RUNTIME_CONTRACT",
|
||||||
"owner": "feature-frontend-routing-release-recovery-runtime",
|
"owner": "feature-frontend-routing-release-recovery-runtime",
|
||||||
"requiredFields": [
|
"requiredFields": [
|
||||||
@@ -66,11 +66,11 @@
|
|||||||
"ui-gallery-page",
|
"ui-gallery-page",
|
||||||
"state-gallery-page",
|
"state-gallery-page",
|
||||||
"auth-example-page",
|
"auth-example-page",
|
||||||
"sample-contract-page",
|
"reference-resource-page",
|
||||||
"not-found-page"
|
"not-found-page"
|
||||||
],
|
],
|
||||||
"paramsCodec": ["none", "NotFoundSplat"],
|
"paramsCodec": ["none", "NotFoundSplat"],
|
||||||
"searchCodec": ["none", "SampleResourceListQuery"]
|
"searchCodec": ["none", "ReferenceResourceListQuery"]
|
||||||
},
|
},
|
||||||
"references": [
|
"references": [
|
||||||
{
|
{
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"registryId": "FE-REG-API",
|
"registryId": "FE-REG-API",
|
||||||
"path": "src/contracts/api-operations.js",
|
"path": "src/features/installed-feature-contracts.js",
|
||||||
"exportName": "API_OPERATIONS",
|
"exportName": "API_OPERATIONS",
|
||||||
"owner": "feature-api-client-response-envelope-contract",
|
"owner": "feature-api-client-response-envelope-contract",
|
||||||
"requiredFields": [
|
"requiredFields": [
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"registryId": "FE-REG-QUERY",
|
"registryId": "FE-REG-QUERY",
|
||||||
"path": "src/contracts/query-keys.js",
|
"path": "src/features/installed-feature-contracts.js",
|
||||||
"exportName": "QUERY_REGISTRY",
|
"exportName": "QUERY_REGISTRY",
|
||||||
"owner": "feature-server-state-caching-contract",
|
"owner": "feature-server-state-caching-contract",
|
||||||
"requiredFields": [
|
"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
|
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
|
release candidate and sign them. The required scope is derived from the route
|
||||||
registry: `APP_HOME`, `EXAMPLES_UI`, `EXAMPLES_STATES`, `EXAMPLES_AUTH`,
|
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,
|
gate rejects blank identity/timestamp/signature fields, pending verdicts,
|
||||||
mismatched release IDs, or missing routes.
|
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
|
query/mutation inbound adapter, HTTP 실행 계약과 executable route/release
|
||||||
recovery 계약은 구현됐다. 현재 선행 해결
|
recovery 계약, 제거 가능한 reference 수직 슬라이스는 구현됐다. 현재 선행 해결
|
||||||
대상은 다음과 같다.
|
대상은 다음과 같다.
|
||||||
|
|
||||||
1. 전체를 제거할 수 있는 실제 reference feature
|
1. 폼, 페이지 템플릿, 확장된 디자인 시스템과 컴포넌트 워크벤치
|
||||||
2. 폼, 페이지 템플릿, 확장된 디자인 시스템과 컴포넌트 워크벤치
|
2. 국제화, diagnostics, optional adapter recipe와 심화 품질 게이트
|
||||||
3. 국제화, diagnostics, optional adapter recipe와 심화 품질 게이트
|
|
||||||
|
|
||||||
따라서 현재 상태를 “프론트 공통부가 모두 구현됐다”고 표현하면 범위가 과장된다.
|
따라서 현재 상태를 “프론트 공통부가 모두 구현됐다”고 표현하면 범위가 과장된다.
|
||||||
더 정확한 표현은 다음과 같다.
|
더 정확한 표현은 다음과 같다.
|
||||||
@@ -70,7 +69,7 @@ recovery 계약은 구현됐다. 현재 선행 해결
|
|||||||
| 오류 모델 | 부분 준비 | error registry와 normalization 존재 | typed discriminated union과 계층별 mapper |
|
| 오류 모델 | 부분 준비 | error registry와 normalization 존재 | typed discriminated union과 계층별 mapper |
|
||||||
| 검증 | 부분 준비 | runtime/API Zod parse 결과를 실제 request에 사용 | route/form/domain 경계를 추가 |
|
| 검증 | 부분 준비 | runtime/API Zod parse 결과를 실제 request에 사용 | route/form/domain 경계를 추가 |
|
||||||
| 인증 연동 | 준비됨/프로젝트 선택 | opaque auth owner와 demo seam 존재 | 인증 방식별 recipe; 기본 token 저장소는 추가하지 않음 |
|
| 인증 연동 | 준비됨/프로젝트 선택 | 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 예제 |
|
| 클라이언트 상태 | 부분 준비 | local state, theme context, session external store | 상태 소유권 표와 typed external-store 예제 |
|
||||||
| 범용 global store | 프로젝트 선택 | 별도 라이브러리 없음 | 필요 조건에 따라 Zustand/Redux Toolkit/state machine 선택 |
|
| 범용 global store | 프로젝트 선택 | 별도 라이브러리 없음 | 필요 조건에 따라 Zustand/Redux Toolkit/state machine 선택 |
|
||||||
| 라우팅 | 준비됨 | Data Router, typed runtime map, codec, metadata consumer, bounded chunk recovery | reference feature route와 release E2E로 사용 범위 확장 |
|
| 라우팅 | 준비됨 | 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 화면에서 전체 상태 전시 |
|
| 비동기 상태 불변식 | 준비됨 | 배타적 typed overlay, stale latch, 실제 retry/conflict action | reference 화면에서 전체 상태 전시 |
|
||||||
| 단위·통합·E2E | 준비됨 | Vitest, RTL, MSW, Playwright 3엔진 | TS 테스트 검사, 실제 bootstrap 통합, 위험 시나리오 보강 |
|
| 단위·통합·E2E | 준비됨 | Vitest, RTL, MSW, Playwright 3엔진 | TS 테스트 검사, 실제 bootstrap 통합, 위험 시나리오 보강 |
|
||||||
| UI 회귀 검증 | 미제공 | axe/reflow는 있으나 visual baseline 없음 | Storybook 또는 동급 workshop과 시각 회귀 |
|
| 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을 자동 검증 |
|
| registry·compatibility 집행 | 부분 준비 | registry와 gate는 있으나 실제 before/after 및 orphan 검사가 제한적 | type/reference/orphan/diff/migration을 자동 검증 |
|
||||||
| 공급망 검사 | 부분 준비 | lockfile·문서·gate는 있으나 실제 transitive 취약점/license/SBOM 깊이가 부족 | pinned scanner와 policy exception/증적 연결 |
|
| 공급망 검사 | 부분 준비 | lockfile·문서·gate는 있으나 실제 transitive 취약점/license/SBOM 깊이가 부족 | pinned scanner와 policy exception/증적 연결 |
|
||||||
| realtime·offline·file 등 | 프로젝트 선택 | 현재 없음 | port/adapter recipe와 선택 기준 제공 |
|
| realtime·offline·file 등 | 프로젝트 선택 | 현재 없음 | port/adapter recipe와 선택 기준 제공 |
|
||||||
@@ -159,8 +158,8 @@ timeout, retry, decoder, mapper 책임을 분리해야 한다. application에는
|
|||||||
|
|
||||||
#### RP-04에서 route registry를 실행 계약으로 전환
|
#### RP-04에서 route registry를 실행 계약으로 전환
|
||||||
|
|
||||||
`src/contracts/routes.js`와 직렬화 가능한
|
platform route 계약과 `src/features/installed-feature-contracts.js`의 직렬화
|
||||||
`src/contracts/route-runtime-contract.js`를 기준으로
|
가능한 contribution을 기준으로
|
||||||
`src/presentation/routes/app-router.tsx`가 Data Router route object와
|
`src/presentation/routes/app-router.tsx`가 Data Router route object와
|
||||||
navigation을 생성한다. `route-runtime.tsx`는 lazy component의 실행 map만
|
navigation을 생성한다. `route-runtime.tsx`는 lazy component의 실행 map만
|
||||||
소유하며 contract/runtime 누락과 orphan은 TypeScript negative fixture와 registry
|
소유하며 contract/runtime 누락과 orphan은 TypeScript negative fixture와 registry
|
||||||
@@ -179,16 +178,18 @@ gate가 모두 거절한다.
|
|||||||
연결하고, no-store manifest 재조회와 build/release 쌍별 1회 reload를
|
연결하고, no-store manifest 재조회와 build/release 쌍별 1회 reload를
|
||||||
production application input까지 연결한다.
|
production application input까지 연결한다.
|
||||||
|
|
||||||
#### reference feature가 완전히 제거되지 않는다
|
#### RP-05에서 제거 가능한 reference feature 구현
|
||||||
|
|
||||||
현재 sample removal gate는 `src/sample/contract-fixture`만 삭제한다. sample API
|
`src/features/reference-feature`가 domain, application input, outbound gateway,
|
||||||
operation, schema, mapper, domain model, query key는 다른 production 경로에 남는다.
|
DTO/schema, mapper, route/API/query contract, query/mutation controller와 page를
|
||||||
반면 화면에 노출된 `/sample/resources`는 실제 query 수직 흐름을 실행하지 않는다.
|
한 소유 경계에 둔다. production composition은 generic feature input catalog를
|
||||||
|
통해 이 input을 주입하며 UI는 HTTP나 output port를 직접 보지 않는다.
|
||||||
|
|
||||||
reference feature는 domain, application input/output, schemas, operation,
|
`test:sample-removal`은 임시 복제본에서 feature source/tests를 삭제하고 installed
|
||||||
mapper, query controller, pages, tests를 한 소유 경계 아래 모아야 한다. 해당 모듈과
|
contract/runtime/adapter catalog를 빈 목록으로 재생성한다. 그 뒤 typecheck,
|
||||||
registry contribution을 제거한 뒤 typecheck, architecture, test, build가 모두
|
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/adapters` | HTTP, auth, storage, cache, telemetry 구현 | outbound adapter |
|
||||||
| `src/bootstrap` | runtime config와 구현 조립 | 유일한 composition root |
|
| `src/bootstrap` | runtime config와 구현 조립 | 유일한 composition root |
|
||||||
| `src/contracts` | 여러 계층의 registry가 혼재 | 소유 계층으로 분산 |
|
| `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는
|
- 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의
|
Data Router tree, codec, surface, title, navigation, chunk/release recovery의
|
||||||
단일 조립 입력이며 registry/type/build 검증이 누락과 orphan을 거절한다.
|
단일 조립 입력이며 registry/type/build 검증이 누락과 orphan을 거절한다.
|
||||||
|
|
||||||
후속 브랜치에서 닫아야 할 실행 불일치는 다음과 같다.
|
RP-05에서 두 번째 불일치도 닫혔다. feature별 domain/application/adapter/
|
||||||
|
contract/presentation은 `src/features/reference-feature`가 소유하고, generic
|
||||||
1. 제거 테스트는 `src/sample/contract-fixture`만 제거하며, sample API
|
installed catalog만 bootstrap과 router에 노출된다. 제거 gate는 feature와 test를
|
||||||
operation, Zod schema, mapper, domain model과 query key는 다른 경로에
|
삭제한 복제본에서 전체 P0 경로를 다시 실행한다.
|
||||||
남는다.
|
|
||||||
|
|
||||||
이 문서의 목표 구조는 기존 기반을 폐기하는 것이 아니라 이러한
|
이 문서의 목표 구조는 기존 기반을 폐기하는 것이 아니라 이러한
|
||||||
불일치를 제거하는 것이다.
|
불일치를 제거하는 것이다.
|
||||||
@@ -1222,11 +1221,11 @@ contract와 실패 분기를 우선한다.
|
|||||||
|
|
||||||
### 26.5 Reference feature
|
### 26.5 Reference feature
|
||||||
|
|
||||||
- [ ] route부터 API mapper와 화면까지 완전한 수직 경로가 실행된다.
|
- [x] route부터 API mapper와 화면까지 완전한 수직 경로가 실행된다.
|
||||||
- [ ] list/create 등 최소 query와 mutation 예제가 있다.
|
- [x] list/create 등 최소 query와 mutation 예제가 있다.
|
||||||
- [ ] loading/empty/error/refresh/conflict 상태가 있다.
|
- [x] loading/empty/error/refresh/conflict 상태가 있다.
|
||||||
- [ ] reference feature 전체 삭제 후 typecheck/test/build가 통과한다.
|
- [x] reference feature 전체 삭제 후 typecheck/architecture/registry/test/home/build가 통과한다.
|
||||||
- [ ] built asset에 reference operation, schema, mapper가 남지 않는다.
|
- [x] 제거 모드 built asset에 reference operation, schema, mapper가 남지 않는다.
|
||||||
|
|
||||||
### 26.6 품질
|
### 26.6 품질
|
||||||
|
|
||||||
|
|||||||
@@ -44,12 +44,13 @@ flowchart LR
|
|||||||
The current executable route tree is mounted only after runtime configuration
|
The current executable route tree is mounted only after runtime configuration
|
||||||
and release-manifest coherence pass. It receives the composed query client,
|
and release-manifest coherence pass. It receives the composed query client,
|
||||||
credential-opaque session port, storage port, telemetry port, and immutable
|
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
|
This describes the current starter composition, not the completed target. The
|
||||||
capability review found that raw outbound capabilities still reach the React
|
capability review found that raw outbound capabilities still reach the React
|
||||||
tree, the composed application facade is not yet its entry point, and several
|
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
|
partially connected. Use the following documents for the evidence and migration
|
||||||
plan:
|
plan:
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ RP-04 이후 현재 구현에는 다음 장점이 있다.
|
|||||||
- route heading focus와 비동기/render error boundary가 있다.
|
- route heading focus와 비동기/render error boundary가 있다.
|
||||||
- redirect loop와 chunk recovery가 bounded production call graph에 연결돼 있다.
|
- redirect loop와 chunk recovery가 bounded production call graph에 연결돼 있다.
|
||||||
|
|
||||||
`src/contracts/routes.js`, `src/contracts/route-runtime-contract.js`,
|
platform route 계약, `src/features/installed-feature-contracts.js`,
|
||||||
`src/presentation/routes/route-runtime.tsx`의 완전성은 TypeScript와 registry
|
`src/features/installed-feature-runtimes.tsx`의 완전성은 TypeScript와 registry
|
||||||
negative fixture가 함께 검사한다. params/search codec, loading/error surface,
|
negative fixture가 함께 검사한다. params/search codec, loading/error surface,
|
||||||
access, title, navigation, chunk ID는
|
access, title, navigation, chunk ID는
|
||||||
`src/presentation/routes/app-router.tsx`에서 모두 소비된다. built Vite
|
`src/presentation/routes/app-router.tsx`에서 모두 소비된다. built Vite
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ primitives, and state surfaces remain reusable.
|
|||||||
validated config + coherent release manifest
|
validated config + coherent release manifest
|
||||||
-> concrete adapters
|
-> concrete adapters
|
||||||
-> QueryClientProvider
|
-> QueryClientProvider
|
||||||
-> BrowserRouter
|
-> ApplicationProvider
|
||||||
|
-> RouterProvider
|
||||||
-> ThemeProvider
|
-> ThemeProvider
|
||||||
-> SessionProvider
|
-> SessionProvider
|
||||||
-> AppShell
|
-> AppShell
|
||||||
@@ -31,7 +32,7 @@ remain stable in the shell.
|
|||||||
| `EXAMPLES_UI` | `/examples/ui` | public | interactive primitives and tokens |
|
| `EXAMPLES_UI` | `/examples/ui` | public | interactive primitives and tokens |
|
||||||
| `EXAMPLES_STATES` | `/examples/states` | public | async and access state matrix |
|
| `EXAMPLES_STATES` | `/examples/states` | public | async and access state matrix |
|
||||||
| `EXAMPLES_AUTH` | `/examples/auth` | public | session integration controls |
|
| `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 |
|
| `NOT_FOUND` | `*` | public | safe navigation recovery |
|
||||||
|
|
||||||
Navigation labels and order come from `ROUTE_REGISTRY`; the sidebar does not
|
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
|
## Extending the starter
|
||||||
|
|
||||||
The steps below describe the current extension path. The platform review found
|
The steps below describe the current extension path. New platform work should follow
|
||||||
that several route metadata fields and the composed application facade are not
|
|
||||||
yet connected end to end. New platform work should follow
|
|
||||||
[routing, page templates, and reusable patterns](./routing-pages-and-patterns.md)
|
[routing, page templates, and reusable patterns](./routing-pages-and-patterns.md)
|
||||||
and the
|
and the
|
||||||
[TypeScript, state, and data-flow target](./typescript-state-and-data-flow.md)
|
[TypeScript, state, and data-flow target](./typescript-state-and-data-flow.md)
|
||||||
rather than adding another independent route or data-loading convention.
|
rather than adding another independent route or data-loading convention.
|
||||||
|
|
||||||
1. Register the route path, access hint, title, chunk, loading surface, error
|
1. Add a serializable contribution under the feature ownership boundary and
|
||||||
surface, and optional navigation metadata in `src/contracts/routes.js`.
|
install it through `src/features/installed-feature-contracts.js`.
|
||||||
2. Add a lazy page in `src/presentation/` and render it through `RouteSurface`.
|
2. Add the lazy component and route codecs through
|
||||||
3. Use application ports or use cases; do not import concrete adapters.
|
`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
|
4. Use the semantic tokens, UI primitives, and state surfaces before adding a
|
||||||
project-specific variant.
|
project-specific variant.
|
||||||
5. Add component behavior, all-engine E2E, automated axe, and signed manual
|
5. Add component behavior, all-engine E2E, automated axe, and signed manual
|
||||||
route evidence.
|
route evidence.
|
||||||
6. Run `test:sample-removal` to prove the visible starter still builds without
|
6. Run `test:sample-removal` to prove the generic starter typechecks, passes
|
||||||
`src/sample/contract-fixture`.
|
architecture/registry/tests/home smoke, and builds without the complete
|
||||||
|
reference feature.
|
||||||
|
|
||||||
Theme preference is the public `COLOR_SCHEME` storage contract. Authentication
|
Theme preference is the public `COLOR_SCHEME` storage contract. Authentication
|
||||||
tokens and other secrets remain forbidden storage keys.
|
tokens and other secrets remain forbidden storage keys.
|
||||||
|
|||||||
@@ -37,8 +37,9 @@ Arbitrary-value policy:
|
|||||||
- user-controlled or runtime-composed class strings are forbidden
|
- user-controlled or runtime-composed class strings are forbidden
|
||||||
- class variants must be selected from a closed static map
|
- class variants must be selected from a closed static map
|
||||||
|
|
||||||
The removable sample may demonstrate tokens, but production starter modules do
|
The removable reference feature may demonstrate tokens, but generic production
|
||||||
not import from `src/sample/contract-fixture`.
|
starter modules do not import its domain, application, adapter, or presentation
|
||||||
|
implementation.
|
||||||
|
|
||||||
This file documents the currently implemented token and primitive baseline.
|
This file documents the currently implemented token and primitive baseline.
|
||||||
The [design-system platform contract](./design-system-platform.md) defines the
|
The [design-system platform contract](./design-system-platform.md) defines the
|
||||||
|
|||||||
@@ -446,6 +446,14 @@ tests/support/query/
|
|||||||
helper는 production default를 복사하지 않는다. production factory를 호출하고
|
helper는 production default를 복사하지 않는다. production factory를 호출하고
|
||||||
필요한 시간·retry만 test override로 주입한다.
|
필요한 시간·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 테스트
|
## 8. Form 테스트
|
||||||
|
|
||||||
Form test는 field primitive, form controller, application command mapping을
|
Form test는 field primitive, form controller, application command mapping을
|
||||||
|
|||||||
@@ -196,6 +196,62 @@ export default [
|
|||||||
"no-restricted-imports": restrictedImports(layerPatterns.adapters),
|
"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}`],
|
files: [`tests/**/*.${sourceExtensions}`],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
|
|||||||
+2
-1
@@ -34,7 +34,8 @@
|
|||||||
"test:a11y": "playwright test --grep @a11y && node scripts/write-a11y-report.mjs",
|
"test:a11y": "playwright test --grep @a11y && node scripts/write-a11y-report.mjs",
|
||||||
"review:a11y-manual": "node scripts/verify-a11y-manual.mjs",
|
"review:a11y-manual": "node scripts/verify-a11y-manual.mjs",
|
||||||
"test:sample-removal": "node scripts/test-sample-removal.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",
|
"verify:lockfile": "corepack pnpm install --frozen-lockfile",
|
||||||
"generate:supply-chain": "node scripts/generate-supply-chain.mjs",
|
"generate:supply-chain": "node scripts/generate-supply-chain.mjs",
|
||||||
"scan:security": "node scripts/security-scan.mjs",
|
"scan:security": "node scripts/security-scan.mjs",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"route-examples-ui": "src/presentation/examples/ui-gallery-page.jsx",
|
"route-examples-ui": "src/presentation/examples/ui-gallery-page.jsx",
|
||||||
"route-examples-states": "src/presentation/examples/state-gallery-page.jsx",
|
"route-examples-states": "src/presentation/examples/state-gallery-page.jsx",
|
||||||
"route-examples-auth": "src/presentation/examples/auth-example-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"
|
"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 process from "node:process";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { ROUTE_REGISTRY } from "../src/contracts/routes.js";
|
import {
|
||||||
import { ROUTE_RUNTIME_CONTRACT } from "../src/contracts/route-runtime-contract.js";
|
ROUTE_REGISTRY,
|
||||||
|
ROUTE_RUNTIME_CONTRACT,
|
||||||
|
} from "../src/features/installed-feature-contracts.js";
|
||||||
import { runtimeConfigSchema } from "../src/bootstrap/runtime-config-schema.js";
|
import { runtimeConfigSchema } from "../src/bootstrap/runtime-config-schema.js";
|
||||||
|
|
||||||
const packageJson = JSON.parse(await readFile("package.json", "utf8"));
|
const packageJson = JSON.parse(await readFile("package.json", "utf8"));
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
export const MANUAL_A11Y_ROUTE_IDS = Object.freeze([
|
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.js";
|
||||||
"APP_HOME",
|
|
||||||
"EXAMPLES_UI",
|
export const MANUAL_A11Y_ROUTE_IDS = Object.freeze(
|
||||||
"EXAMPLES_STATES",
|
Object.values(ROUTE_REGISTRY).map((route) => route.routeId),
|
||||||
"EXAMPLES_AUTH",
|
);
|
||||||
"SAMPLE_RESOURCE_LIST",
|
|
||||||
"NOT_FOUND",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const REVIEW_FIELDS = Object.freeze([
|
const REVIEW_FIELDS = Object.freeze([
|
||||||
"M1 Keyboard",
|
"M1 Keyboard",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import process from "node:process";
|
|||||||
import { chromium } from "@playwright/test";
|
import { chromium } from "@playwright/test";
|
||||||
|
|
||||||
import { evaluateLabBudget } from "../src/application/policies/performance-budgets.js";
|
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(
|
const server = spawn(
|
||||||
"corepack",
|
"corepack",
|
||||||
@@ -67,8 +67,9 @@ try {
|
|||||||
}).observe({ type: "layout-shift", buffered: true });
|
}).observe({ type: "layout-shift", buffered: true });
|
||||||
});
|
});
|
||||||
await page.goto(baseUrl, { waitUntil: "networkidle" });
|
await page.goto(baseUrl, { waitUntil: "networkidle" });
|
||||||
const targetLabel =
|
const targetLabel = Object.values(ROUTE_REGISTRY).find(
|
||||||
ROUTE_REGISTRY.SAMPLE_RESOURCE_LIST.navigationLabel;
|
(definition) => definition.access === "integration-defined",
|
||||||
|
)?.navigationLabel;
|
||||||
if (!targetLabel) {
|
if (!targetLabel) {
|
||||||
throw new Error("Performance route must be present in navigation.");
|
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 { spawnSync } from "node:child_process";
|
||||||
|
import {
|
||||||
|
cp,
|
||||||
|
mkdir,
|
||||||
|
readFile,
|
||||||
|
readdir,
|
||||||
|
rm,
|
||||||
|
symlink,
|
||||||
|
writeFile,
|
||||||
|
} from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
const fixtureRoot = path.resolve(".tmp/sample-removal");
|
const fixtureRoot = path.resolve(".tmp/reference-feature-removal");
|
||||||
const sampleRoot = path.resolve("src/sample/contract-fixture");
|
|
||||||
const sourceRoot = path.resolve("src");
|
|
||||||
const pnpmCli = /** @type {string} */ (process.env.npm_execpath);
|
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[]>} */
|
/** @param {string} directory @returns {Promise<string[]>} */
|
||||||
async function sourceFiles(directory) {
|
async function filesBelow(directory) {
|
||||||
const entries = await readdir(directory, { withFileTypes: true });
|
const entries = await readdir(directory, { withFileTypes: true });
|
||||||
const nested = /** @type {string[][]} */ (await Promise.all(
|
const groups = await Promise.all(
|
||||||
entries.map((entry) => {
|
entries.map((entry) => {
|
||||||
const target = path.join(directory, entry.name);
|
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 rm(fixtureRoot, { recursive: true, force: true });
|
||||||
await mkdir(fixtureRoot, { recursive: 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 = [];
|
await rm(path.join(fixtureRoot, featureSource), {
|
||||||
for (const sourceFile of await sourceFiles(sourceRoot)) {
|
recursive: true,
|
||||||
if (sourceFile.startsWith(sampleRoot)) continue;
|
force: true,
|
||||||
const content = await readFile(sourceFile, "utf8");
|
});
|
||||||
if (/from\s+["'][^"']*sample\/contract-fixture/.test(content)) {
|
await rm(path.join(fixtureRoot, featureTests), {
|
||||||
incomingImports.push(path.relative(".", sourceFile));
|
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;
|
const checks = [
|
||||||
if (incomingImports.length === 0) {
|
["typecheck", runPnpm("check:types")],
|
||||||
await cp("src", path.join(fixtureRoot, "src"), {
|
["architecture", runPnpm("check:architecture")],
|
||||||
recursive: true,
|
["registry", runPnpm("check:registries")],
|
||||||
filter: (source) => !source.startsWith(sampleRoot),
|
["unit-integration", runPnpm("test:all")],
|
||||||
});
|
[
|
||||||
await cp("public", path.join(fixtureRoot, "public"), { recursive: true });
|
"home-smoke",
|
||||||
await cp("index.html", path.join(fixtureRoot, "index.html"));
|
runPnpm("exec", [
|
||||||
await cp("vite.config.js", path.join(fixtureRoot, "vite.config.js"));
|
"vitest",
|
||||||
|
"run",
|
||||||
const result = spawnSync(
|
"tests/component/router.test.jsx",
|
||||||
process.execPath,
|
"--reporter=default",
|
||||||
[
|
]),
|
||||||
pnpmCli,
|
],
|
||||||
"exec",
|
["build", runPnpm("build")],
|
||||||
"vite",
|
];
|
||||||
"build",
|
/** @type {string[]} */
|
||||||
fixtureRoot,
|
const builtResidue = [];
|
||||||
"--outDir",
|
for (const file of await filesBelow(path.join(fixtureRoot, "dist"))) {
|
||||||
path.join(fixtureRoot, "dist"),
|
if (!/\.(?:js|css|html|json)$/.test(file)) continue;
|
||||||
],
|
const content = await readFile(file, "utf8");
|
||||||
{ stdio: "inherit" },
|
if (
|
||||||
);
|
/REFERENCE_RESOURCE|reference-feature|reference-resource/i.test(content)
|
||||||
buildStatus = result.status ?? 1;
|
) {
|
||||||
|
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 });
|
await mkdir("artifacts/tests", { recursive: true });
|
||||||
const passed = incomingImports.length === 0 && buildStatus === 0;
|
|
||||||
await writeFile(
|
await writeFile(
|
||||||
"artifacts/tests/sample-removal.xml",
|
"artifacts/tests/sample-removal.xml",
|
||||||
`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
||||||
`<testsuite name="sample-removal" tests="2" failures="${passed ? 0 : 1}">` +
|
`<testsuite name="reference-feature-removal" tests="${checks.length}" failures="${passed ? 0 : 1}">` +
|
||||||
`<testcase name="no-product-import"/>` +
|
checks
|
||||||
`<testcase name="production-build">${passed ? "" : "<failure/>"}</testcase>` +
|
.map(
|
||||||
|
([name, result]) =>
|
||||||
|
`<testcase name="${name}">${result ? "" : `<failure>${[...residue, ...builtResidue].join(", ")}</failure>`}</testcase>`,
|
||||||
|
)
|
||||||
|
.join("") +
|
||||||
`</testsuite>\n`,
|
`</testsuite>\n`,
|
||||||
);
|
);
|
||||||
await rm(fixtureRoot, { recursive: true, force: true });
|
await rm(fixtureRoot, { recursive: true, force: true });
|
||||||
|
|
||||||
if (!passed) {
|
if (!passed) {
|
||||||
|
const failures = checks
|
||||||
|
.filter(([, result]) => !result)
|
||||||
|
.map(([name]) => name);
|
||||||
process.stderr.write(
|
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.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,
|
compareReleaseToRuntime,
|
||||||
RELEASE_TOKEN_REGISTRY,
|
RELEASE_TOKEN_REGISTRY,
|
||||||
} from "../src/contracts/release-tokens.js";
|
} from "../src/contracts/release-tokens.js";
|
||||||
import { ROUTE_RUNTIME_CONTRACT } from "../src/contracts/route-runtime-contract.js";
|
import {
|
||||||
import { ROUTE_REGISTRY } from "../src/contracts/routes.js";
|
ROUTE_REGISTRY,
|
||||||
|
ROUTE_RUNTIME_CONTRACT,
|
||||||
|
} from "../src/features/installed-feature-contracts.js";
|
||||||
|
|
||||||
const fixturesDocument =
|
const fixturesDocument =
|
||||||
/** @type {{
|
/** @type {{
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ const noAuthSession =
|
|||||||
* random?: () => number,
|
* random?: () => number,
|
||||||
* validatePayload?: (schemaId: string, value: unknown) =>
|
* validatePayload?: (schemaId: string, value: unknown) =>
|
||||||
* { success: true, data: unknown } | { success: false },
|
* { success: true, data: unknown } | { success: false },
|
||||||
|
* validateRequest?: (schemaId: string, value: unknown) =>
|
||||||
|
* { success: true, data: unknown } | { success: false },
|
||||||
* mapPayload?: (operationId: string, payload: unknown) => unknown,
|
* mapPayload?: (operationId: string, payload: unknown) => unknown,
|
||||||
* idempotencyKeyFactory?: () => string,
|
* idempotencyKeyFactory?: () => string,
|
||||||
* timeoutMs?: number,
|
* timeoutMs?: number,
|
||||||
@@ -61,6 +63,8 @@ export function createHttpClient(dependencies) {
|
|||||||
const random = dependencies.random ?? Math.random;
|
const random = dependencies.random ?? Math.random;
|
||||||
const validatePayload =
|
const validatePayload =
|
||||||
dependencies.validatePayload ?? validateOperationPayload;
|
dependencies.validatePayload ?? validateOperationPayload;
|
||||||
|
const validateRequest =
|
||||||
|
dependencies.validateRequest ?? validateOperationRequest;
|
||||||
const mapPayload = dependencies.mapPayload ?? mapOperationPayload;
|
const mapPayload = dependencies.mapPayload ?? mapOperationPayload;
|
||||||
const idempotencyKeyFactory =
|
const idempotencyKeyFactory =
|
||||||
dependencies.idempotencyKeyFactory ?? (() => crypto.randomUUID());
|
dependencies.idempotencyKeyFactory ?? (() => crypto.randomUUID());
|
||||||
@@ -193,7 +197,7 @@ export function createHttpClient(dependencies) {
|
|||||||
? input.body
|
? input.body
|
||||||
: {};
|
: {};
|
||||||
if (operation.requestSource !== "none") {
|
if (operation.requestSource !== "none") {
|
||||||
const requestValidation = validateOperationRequest(
|
const requestValidation = validateRequest(
|
||||||
operation.requestSchema,
|
operation.requestSchema,
|
||||||
requestValue,
|
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 */
|
/** @param {string} operationId @param {unknown} payload */
|
||||||
export function mapOperationPayload(operationId, payload) {
|
export function mapOperationPayload(operationId, payload) {
|
||||||
if (operationId === "LIST_SAMPLE_RESOURCES") {
|
void payload;
|
||||||
if (!Array.isArray(payload)) throw new TypeError("Expected a resource list");
|
|
||||||
return payload.map(mapResourceDto);
|
|
||||||
}
|
|
||||||
if (operationId === "CREATE_SAMPLE_RESOURCE") {
|
|
||||||
return mapResourceDto(payload);
|
|
||||||
}
|
|
||||||
throw new TypeError(`No boundary mapper registered for ${operationId}`);
|
throw new TypeError(`No boundary mapper registered for ${operationId}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,35 +37,11 @@ export const responseEnvelopeSchema = z.discriminatedUnion("success", [
|
|||||||
failureEnvelopeSchema,
|
failureEnvelopeSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const sampleResourceSchema = z
|
|
||||||
.object({
|
|
||||||
id: z.string().min(1),
|
|
||||||
name: z.string().min(1),
|
|
||||||
createdAt: z.string().optional(),
|
|
||||||
})
|
|
||||||
.passthrough();
|
|
||||||
|
|
||||||
const payloadSchemas =
|
const payloadSchemas =
|
||||||
/** @type {Readonly<Record<string, z.ZodType>>} */ (Object.freeze({
|
/** @type {Readonly<Record<string, z.ZodType>>} */ (Object.freeze({}));
|
||||||
SampleResourceListPayload: z.array(sampleResourceSchema),
|
|
||||||
SampleResourcePayload: sampleResourceSchema,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const requestSchemas =
|
const requestSchemas =
|
||||||
/** @type {Readonly<Record<string, z.ZodType>>} */ (Object.freeze({
|
/** @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(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
/** @param {unknown} value */
|
/** @param {unknown} value */
|
||||||
export function validateEnvelope(value) {
|
export function validateEnvelope(value) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type { ApplicationApi, ApplicationOutputPorts };
|
|||||||
*/
|
*/
|
||||||
export function createApplication(
|
export function createApplication(
|
||||||
outputPorts: ApplicationOutputPorts,
|
outputPorts: ApplicationOutputPorts,
|
||||||
|
featureInputs: Readonly<Record<string, unknown>> = {},
|
||||||
): ApplicationApi {
|
): ApplicationApi {
|
||||||
const session = Object.freeze({
|
const session = Object.freeze({
|
||||||
getSnapshot: () => outputPorts.session.getState(),
|
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({
|
return Object.freeze({
|
||||||
session,
|
session,
|
||||||
@@ -114,5 +127,6 @@ export function createApplication(
|
|||||||
diagnostics,
|
diagnostics,
|
||||||
runtime,
|
runtime,
|
||||||
recovery,
|
recovery,
|
||||||
|
features,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,4 +47,8 @@ export type ApplicationApi = Readonly<{
|
|||||||
| Readonly<{ action: "support"; reason: string }>
|
| 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 { ClockPort } from "../clock-port.js";
|
||||||
export type { QueryCachePort } from "../query-cache-port.js";
|
export type { QueryCachePort } from "../query-cache-port.js";
|
||||||
export type { ReleaseInfoPort } from "../release-info-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 { StoragePort } from "../storage-port.js";
|
||||||
export type { TelemetryPort } from "../telemetry-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
|
* release: Release
|
||||||
* }): Promise<{
|
* }): Promise<{
|
||||||
* outputPorts: OutputPorts,
|
* outputPorts: OutputPorts,
|
||||||
* infrastructure: Infrastructure
|
* infrastructure: Infrastructure,
|
||||||
|
* featureInputs?: Readonly<Record<string, unknown>>
|
||||||
* }>
|
* }>
|
||||||
* }} factories
|
* }} factories
|
||||||
* @returns {Promise<Readonly<{
|
* @returns {Promise<Readonly<{
|
||||||
@@ -30,7 +31,10 @@ export async function createCompositionRoot(factories) {
|
|||||||
const config = await factories.loadConfig();
|
const config = await factories.loadConfig();
|
||||||
const release = await factories.loadRelease(config);
|
const release = await factories.loadRelease(config);
|
||||||
const adapters = await factories.createAdapters({ config, release });
|
const adapters = await factories.createAdapters({ config, release });
|
||||||
const application = createApplication(adapters.outputPorts);
|
const application = createApplication(
|
||||||
|
adapters.outputPorts,
|
||||||
|
adapters.featureInputs,
|
||||||
|
);
|
||||||
|
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
config,
|
config,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { createBrowserStorageAdapter } from "../adapters/storage/browser-storage-adapter.js";
|
import { createBrowserStorageAdapter } from "../adapters/storage/browser-storage-adapter.js";
|
||||||
import { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.js";
|
import { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.js";
|
||||||
import { fetchReleaseManifest } from "./load-release-manifest.js";
|
import { fetchReleaseManifest } from "./load-release-manifest.js";
|
||||||
|
import { createInstalledFeatureInputs } from "../features/installed-feature-adapters.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Record<string, unknown>} host
|
* @param {Record<string, unknown>} host
|
||||||
@@ -54,7 +55,7 @@ function storageOrUndefined(value) {
|
|||||||
* scheduler?: Parameters<typeof createHttpClient>[0]["scheduler"]
|
* scheduler?: Parameters<typeof createHttpClient>[0]["scheduler"]
|
||||||
* }} context
|
* }} context
|
||||||
*/
|
*/
|
||||||
export function createRuntimeHttpClient(context) {
|
export function createRuntimeHttpClient(context, contract = {}) {
|
||||||
return createHttpClient({
|
return createHttpClient({
|
||||||
baseUrl: context.runtime.config.API_BASE_URL,
|
baseUrl: context.runtime.config.API_BASE_URL,
|
||||||
timeoutMs: context.runtime.config.REQUEST_TIMEOUT_MS,
|
timeoutMs: context.runtime.config.REQUEST_TIMEOUT_MS,
|
||||||
@@ -63,6 +64,7 @@ export function createRuntimeHttpClient(context) {
|
|||||||
fetcher: context.fetcher,
|
fetcher: context.fetcher,
|
||||||
clock: context.clock,
|
clock: context.clock,
|
||||||
scheduler: context.scheduler,
|
scheduler: context.scheduler,
|
||||||
|
...contract,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +118,17 @@ export async function createRuntimeAdapters(context) {
|
|||||||
location.reload();
|
location.reload();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const featureInputs = createInstalledFeatureInputs({
|
||||||
|
createHttpClient: (contract) =>
|
||||||
|
createRuntimeHttpClient(
|
||||||
|
{
|
||||||
|
runtime: context.runtime,
|
||||||
|
authSession,
|
||||||
|
fetcher: context.fetcher,
|
||||||
|
},
|
||||||
|
contract,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
outputPorts: Object.freeze({
|
outputPorts: Object.freeze({
|
||||||
@@ -128,5 +141,6 @@ export async function createRuntimeAdapters(context) {
|
|||||||
infrastructure: Object.freeze({
|
infrastructure: Object.freeze({
|
||||||
queryClient,
|
queryClient,
|
||||||
}),
|
}),
|
||||||
|
featureInputs,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,41 +14,11 @@
|
|||||||
* }} ApiOperation
|
* }} ApiOperation
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** @param {ApiOperation} definition */
|
export const API_OPERATIONS = Object.freeze({});
|
||||||
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",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
/** @param {string} operationId */
|
/** @param {string} operationId */
|
||||||
export function getApiOperation(operationId) {
|
export function getApiOperation(operationId, operations = API_OPERATIONS) {
|
||||||
const registry = /** @type {Record<string, ApiOperation>} */ (API_OPERATIONS);
|
const registry = /** @type {Record<string, ApiOperation>} */ (operations);
|
||||||
const selected = registry[operationId];
|
const selected = registry[operationId];
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
throw new Error(`Unregistered API operation: ${operationId}`);
|
throw new Error(`Unregistered API operation: ${operationId}`);
|
||||||
|
|||||||
@@ -1,26 +1,4 @@
|
|||||||
const RESOURCE_NAMESPACE = Object.freeze(["resource", 1]);
|
export const QUERY_REGISTRY = Object.freeze({});
|
||||||
|
|
||||||
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",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
/** @param {unknown} value @returns {unknown} */
|
/** @param {unknown} value @returns {unknown} */
|
||||||
export function canonicalize(value) {
|
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 */
|
/** @param {Readonly<{routeId: string, moduleId: string, paramsCodec: RouteCodecId, searchCodec: RouteCodecId}>} value */
|
||||||
const runtime = (value) => Object.freeze(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({
|
APP_HOME: runtime({
|
||||||
routeId: "APP_HOME",
|
routeId: "APP_HOME",
|
||||||
moduleId: "home-page",
|
moduleId: "home-page",
|
||||||
@@ -30,12 +30,6 @@ export const ROUTE_RUNTIME_CONTRACT = Object.freeze({
|
|||||||
paramsCodec: "none",
|
paramsCodec: "none",
|
||||||
searchCodec: "none",
|
searchCodec: "none",
|
||||||
}),
|
}),
|
||||||
SAMPLE_RESOURCE_LIST: runtime({
|
|
||||||
routeId: "SAMPLE_RESOURCE_LIST",
|
|
||||||
moduleId: "sample-contract-page",
|
|
||||||
paramsCodec: "none",
|
|
||||||
searchCodec: "SampleResourceListQuery",
|
|
||||||
}),
|
|
||||||
NOT_FOUND: runtime({
|
NOT_FOUND: runtime({
|
||||||
routeId: "NOT_FOUND",
|
routeId: "NOT_FOUND",
|
||||||
moduleId: "not-found-page",
|
moduleId: "not-found-page",
|
||||||
|
|||||||
+1
-39
@@ -17,7 +17,7 @@
|
|||||||
/** @param {RouteDefinition} definition */
|
/** @param {RouteDefinition} definition */
|
||||||
const route = (definition) => Object.freeze(definition);
|
const route = (definition) => Object.freeze(definition);
|
||||||
|
|
||||||
export const ROUTE_REGISTRY = Object.freeze({
|
export const PLATFORM_ROUTE_REGISTRY = Object.freeze({
|
||||||
APP_HOME: route({
|
APP_HOME: route({
|
||||||
routeId: "APP_HOME",
|
routeId: "APP_HOME",
|
||||||
path: "/",
|
path: "/",
|
||||||
@@ -70,19 +70,6 @@ export const ROUTE_REGISTRY = Object.freeze({
|
|||||||
navigationLabel: "인증 연동",
|
navigationLabel: "인증 연동",
|
||||||
navigationOrder: 40,
|
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({
|
NOT_FOUND: route({
|
||||||
routeId: "NOT_FOUND",
|
routeId: "NOT_FOUND",
|
||||||
path: "*",
|
path: "*",
|
||||||
@@ -97,28 +84,3 @@ export const ROUTE_REGISTRY = Object.freeze({
|
|||||||
navigationOrder: null,
|
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 { useEffect, useState } from "react";
|
||||||
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
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 { useSession } from "../providers/session-provider.jsx";
|
||||||
import { useTheme } from "../providers/theme-provider.jsx";
|
import { useTheme } from "../providers/theme-provider.jsx";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
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 { PageHeader } from "../components/page-header.jsx";
|
||||||
import { useApplication } from "../providers/application-provider.js";
|
import { useApplication } from "../providers/application-provider.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Link } from "react-router-dom";
|
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 { PageHeader } from "../components/page-header.jsx";
|
||||||
|
|
||||||
export default function NotFoundPage() {
|
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 {
|
import {
|
||||||
getRoute,
|
getRoute,
|
||||||
ROUTE_REGISTRY,
|
ROUTE_REGISTRY,
|
||||||
type RouteDefinition,
|
} from "../../features/installed-feature-contracts.js";
|
||||||
} from "../../contracts/routes.js";
|
import type { RouteDefinition } from "../../contracts/routes.js";
|
||||||
import {
|
import {
|
||||||
FeatureBoundary,
|
FeatureBoundary,
|
||||||
RouteBoundary,
|
RouteBoundary,
|
||||||
@@ -43,7 +43,7 @@ import {
|
|||||||
type ParsedRouteInput,
|
type ParsedRouteInput,
|
||||||
type RouteId,
|
type RouteId,
|
||||||
} from "./route-codecs.js";
|
} 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);
|
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
|
* @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, ROUTE_RUNTIME_CONTRACT } from "../../features/installed-feature-contracts.js";
|
||||||
|
import { ROUTE_CODECS } from "../../features/installed-feature-runtimes.js";
|
||||||
import { getRoute } from "../../contracts/routes.js";
|
|
||||||
import { ROUTE_RUNTIME_CONTRACT } from "../../contracts/route-runtime-contract.js";
|
|
||||||
|
|
||||||
export type RouteId = keyof typeof ROUTE_RUNTIME_CONTRACT;
|
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<{
|
export type ParsedRouteInput = Readonly<{
|
||||||
routeId: RouteId;
|
routeId: RouteId;
|
||||||
params: Readonly<Record<string, unknown>>;
|
params: Readonly<Record<string, unknown>>;
|
||||||
@@ -44,17 +16,23 @@ export type RouteInputResult =
|
|||||||
code: "ROUTE_PARAMS_INVALID" | "ROUTE_SEARCH_INVALID";
|
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(
|
export function parseRouteInput(
|
||||||
routeId: RouteId,
|
routeId: RouteId,
|
||||||
rawParams: Readonly<Record<string, string | undefined>>,
|
rawParams: Readonly<Record<string, string | undefined>>,
|
||||||
rawSearch: URLSearchParams,
|
rawSearch: URLSearchParams,
|
||||||
): RouteInputResult {
|
): RouteInputResult {
|
||||||
const runtime = ROUTE_RUNTIME_CONTRACT[routeId];
|
const runtime = ROUTE_RUNTIME_CONTRACT[routeId];
|
||||||
const params = codecs[runtime.paramsCodec].safeParse(rawParams);
|
const params = codecById(runtime.paramsCodec).safeParse(rawParams);
|
||||||
if (!params.success) {
|
if (!params.success) {
|
||||||
return { success: false, code: "ROUTE_PARAMS_INVALID" };
|
return { success: false, code: "ROUTE_PARAMS_INVALID" };
|
||||||
}
|
}
|
||||||
const search = codecs[runtime.searchCodec].safeParse(
|
const search = codecById(runtime.searchCodec).safeParse(
|
||||||
searchRecord(rawSearch),
|
searchRecord(rawSearch),
|
||||||
);
|
);
|
||||||
if (!search.success) {
|
if (!search.success) {
|
||||||
@@ -84,14 +62,18 @@ export function buildRouteUrl(
|
|||||||
throw new TypeError("The not-found route cannot build a canonical URL");
|
throw new TypeError("The not-found route cannot build a canonical URL");
|
||||||
}
|
}
|
||||||
const runtime = ROUTE_RUNTIME_CONTRACT[routeId];
|
const runtime = ROUTE_RUNTIME_CONTRACT[routeId];
|
||||||
const params = codecs[runtime.paramsCodec].parse(input.params ?? {});
|
const params = codecById(runtime.paramsCodec).parse(input.params ?? {});
|
||||||
const search = codecs[runtime.searchCodec].parse(input.search ?? {});
|
const search = codecById(runtime.searchCodec).parse(input.search ?? {});
|
||||||
const parsedParams: Record<string, unknown> = { ...params };
|
const parsedParams: Record<string, unknown> = { ...params };
|
||||||
const parsedSearch: Record<string, unknown> = { ...search };
|
const parsedSearch: Record<string, unknown> = { ...search };
|
||||||
let path = definition.path;
|
let path = definition.path;
|
||||||
path = path.replace(
|
path = path.replace(
|
||||||
/:([A-Za-z][A-Za-z0-9_]*)|\{([A-Za-z][A-Za-z0-9_]*)\}/g,
|
/:([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 name = colonName ?? braceName ?? "";
|
||||||
const value = parsedParams[name];
|
const value = parsedParams[name];
|
||||||
if (typeof value !== "string" && typeof value !== "number") {
|
if (typeof value !== "string" && typeof value !== "number") {
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import {
|
|||||||
type LazyExoticComponent,
|
type LazyExoticComponent,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import { ROUTE_RUNTIME_CONTRACT } from "../../contracts/route-runtime-contract.js";
|
import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../../contracts/route-runtime-contract.js";
|
||||||
import type { RouteId } from "./route-codecs.js";
|
|
||||||
|
|
||||||
type RouteModule = Readonly<{ default: ComponentType }>;
|
type RouteModule = Readonly<{ default: ComponentType }>;
|
||||||
type RouteRuntime = Readonly<{
|
type RouteRuntime = Readonly<{
|
||||||
@@ -14,16 +13,16 @@ type RouteRuntime = Readonly<{
|
|||||||
}>;
|
}>;
|
||||||
|
|
||||||
function runtime(
|
function runtime(
|
||||||
routeId: RouteId,
|
routeId: keyof typeof PLATFORM_ROUTE_RUNTIME_CONTRACT,
|
||||||
load: () => Promise<RouteModule>,
|
load: () => Promise<RouteModule>,
|
||||||
): RouteRuntime {
|
): RouteRuntime {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
moduleId: ROUTE_RUNTIME_CONTRACT[routeId].moduleId,
|
moduleId: PLATFORM_ROUTE_RUNTIME_CONTRACT[routeId].moduleId,
|
||||||
Component: lazy(load),
|
Component: lazy(load),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ROUTE_RUNTIME = {
|
export const PLATFORM_ROUTE_RUNTIME = {
|
||||||
APP_HOME: runtime("APP_HOME", () => import("../pages/home-page.jsx")),
|
APP_HOME: runtime("APP_HOME", () => import("../pages/home-page.jsx")),
|
||||||
EXAMPLES_UI: runtime(
|
EXAMPLES_UI: runtime(
|
||||||
"EXAMPLES_UI",
|
"EXAMPLES_UI",
|
||||||
@@ -37,12 +36,8 @@ export const ROUTE_RUNTIME = {
|
|||||||
"EXAMPLES_AUTH",
|
"EXAMPLES_AUTH",
|
||||||
() => import("../examples/auth-example-page.jsx"),
|
() => import("../examples/auth-example-page.jsx"),
|
||||||
),
|
),
|
||||||
SAMPLE_RESOURCE_LIST: runtime(
|
|
||||||
"SAMPLE_RESOURCE_LIST",
|
|
||||||
() => import("../pages/sample-contract-page.jsx"),
|
|
||||||
),
|
|
||||||
NOT_FOUND: runtime(
|
NOT_FOUND: runtime(
|
||||||
"NOT_FOUND",
|
"NOT_FOUND",
|
||||||
() => import("../pages/not-found-page.jsx"),
|
() => 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 { render, screen } from "@testing-library/react";
|
||||||
import { describe, expect, it } from "vitest";
|
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", () => {
|
describe("design-token fixture", () => {
|
||||||
it("uses static semantic primitive classes", () => {
|
it("uses static semantic primitive classes", () => {
|
||||||
render(<DesignTokenShowcase />);
|
render(
|
||||||
expect(screen.getByRole("region")).toHaveClass("ui-panel");
|
<Card title="Design token fixture">
|
||||||
|
<Button>Token action</Button>
|
||||||
|
</Card>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("article")).toHaveClass("ui-card");
|
||||||
expect(screen.getByRole("button")).toHaveClass("ui-button");
|
expect(screen.getByRole("button")).toHaveClass("ui-button");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,34 +2,29 @@
|
|||||||
|
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import {
|
import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.js";
|
||||||
createAnonymousSessionAdapter,
|
|
||||||
createDemoSessionAdapter,
|
|
||||||
} from "../../src/adapters/auth/external-session-adapter.js";
|
|
||||||
import { ApplicationProvider } from "../../src/presentation/providers/application-provider.js";
|
import { ApplicationProvider } from "../../src/presentation/providers/application-provider.js";
|
||||||
import { AppRouter } from "../../src/presentation/routes/app-router.jsx";
|
import { AppRouter } from "../../src/presentation/routes/app-router.jsx";
|
||||||
import { createTestApplication } from "../helpers/create-test-application.js";
|
import { createTestApplication } from "../helpers/create-test-application.js";
|
||||||
|
|
||||||
/**
|
function renderRouter() {
|
||||||
* @param {import("../../src/application/ports/auth-session-port.js").AuthSessionPort} session
|
|
||||||
* @param {Parameters<typeof createTestApplication>[0]} [overrides]
|
|
||||||
*/
|
|
||||||
function renderRouter(session, overrides = {}) {
|
|
||||||
return render(
|
return render(
|
||||||
<ApplicationProvider
|
<ApplicationProvider
|
||||||
application={createTestApplication({ ...overrides, session })}
|
application={createTestApplication({
|
||||||
|
session: createAnonymousSessionAdapter(),
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<AppRouter />
|
<AppRouter />
|
||||||
</ApplicationProvider>,
|
</ApplicationProvider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("application router", () => {
|
describe("generic application router", () => {
|
||||||
it("renders the app shell and not-found route without an API request", async () => {
|
it("renders the app shell and not-found route without a feature input", async () => {
|
||||||
window.history.pushState({}, "", "/missing");
|
window.history.pushState({}, "", "/missing");
|
||||||
renderRouter(createAnonymousSessionAdapter());
|
renderRouter();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole("heading", {
|
await screen.findByRole("heading", {
|
||||||
@@ -40,10 +35,10 @@ describe("application router", () => {
|
|||||||
expect(screen.getByRole("main")).toBeVisible();
|
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();
|
const user = userEvent.setup();
|
||||||
window.history.pushState({}, "", "/");
|
window.history.pushState({}, "", "/");
|
||||||
renderRouter(createAnonymousSessionAdapter());
|
renderRouter();
|
||||||
|
|
||||||
await user.click(
|
await user.click(
|
||||||
await screen.findByRole("link", { name: "UI 구성요소" }),
|
await screen.findByRole("link", { name: "UI 구성요소" }),
|
||||||
@@ -58,62 +53,4 @@ describe("application router", () => {
|
|||||||
screen.getByRole("heading", { name: "UI 구성요소", level: 1 }),
|
screen.getByRole("heading", { name: "UI 구성요소", level: 1 }),
|
||||||
).toHaveFocus();
|
).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-ui": "assets/ui.js",
|
||||||
"route-examples-states": "assets/states.js",
|
"route-examples-states": "assets/states.js",
|
||||||
"route-examples-auth": "assets/auth.js",
|
"route-examples-auth": "assets/auth.js",
|
||||||
"route-sample-resources": "assets/sample.js",
|
|
||||||
"route-not-found": "assets/not-found.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 AxeBuilder from "@axe-core/playwright";
|
||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.js";
|
||||||
|
|
||||||
for (const route of [
|
for (const route of Object.values(ROUTE_REGISTRY).map((definition) =>
|
||||||
"/",
|
definition.path === "*" ? "/not-found" : definition.path,
|
||||||
"/examples/ui",
|
)) {
|
||||||
"/examples/states",
|
|
||||||
"/examples/auth",
|
|
||||||
"/sample/resources",
|
|
||||||
"/not-found",
|
|
||||||
]) {
|
|
||||||
test(`@a11y ${route} has no critical or serious axe violations`, async ({
|
test(`@a11y ${route} has no critical or serious axe violations`, async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.js";
|
||||||
|
|
||||||
test("boots the public app shell", async ({ page }) => {
|
test("boots the public app shell", async ({ page }) => {
|
||||||
await page.goto("/");
|
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 ({
|
test("opens the protected integration route through the local demo seam", async ({
|
||||||
page,
|
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(
|
await expect(
|
||||||
page.getByRole("heading", { name: "세션이 필요합니다." }),
|
page.getByRole("heading", { name: "세션이 필요합니다." }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
@@ -35,7 +40,7 @@ test("opens the protected integration route through the local demo seam", async
|
|||||||
await page.getByRole("button", { name: "로그인 시작" }).click();
|
await page.getByRole("button", { name: "로그인 시작" }).click();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("heading", { name: "보호된 연동 지점" }),
|
page.getByRole("heading", { name: protectedRoute.title }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(page.getByText("인증됨")).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;
|
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,
|
* preferences?: import("../../src/application/ports/storage-port.js").StoragePort,
|
||||||
* diagnostics?: import("../../src/application/ports/telemetry-port.js").TelemetryPort,
|
* diagnostics?: import("../../src/application/ports/telemetry-port.js").TelemetryPort,
|
||||||
* releaseInfo?: import("../../src/application/ports/release-info-port.js").ReleaseInfoPort,
|
* releaseInfo?: import("../../src/application/ports/release-info-port.js").ReleaseInfoPort,
|
||||||
* navigation?: { reload(): void }
|
* navigation?: { reload(): void },
|
||||||
|
* featureInputs?: Readonly<Record<string, unknown>>
|
||||||
* }} [overrides]
|
* }} [overrides]
|
||||||
*/
|
*/
|
||||||
export function createTestApplication(overrides = {}) {
|
export function createTestApplication(overrides = {}) {
|
||||||
return createApplication({
|
return createApplication(
|
||||||
session: overrides.session ?? createAnonymousSessionAdapter(),
|
{
|
||||||
preferences:
|
session: overrides.session ?? createAnonymousSessionAdapter(),
|
||||||
overrides.preferences ??
|
preferences:
|
||||||
{
|
overrides.preferences ??
|
||||||
read: () => ({ ok: /** @type {const} */ (true), value: "system" }),
|
{
|
||||||
write: () => ({ ok: /** @type {const} */ (true) }),
|
read: () => ({ ok: /** @type {const} */ (true), value: "system" }),
|
||||||
remove: () => ({ ok: /** @type {const} */ (true) }),
|
write: () => ({ ok: /** @type {const} */ (true) }),
|
||||||
},
|
remove: () => ({ ok: /** @type {const} */ (true) }),
|
||||||
diagnostics: overrides.diagnostics ?? { emit: () => {} },
|
},
|
||||||
releaseInfo:
|
diagnostics: overrides.diagnostics ?? { emit: () => {} },
|
||||||
overrides.releaseInfo ??
|
releaseInfo:
|
||||||
{
|
overrides.releaseInfo ??
|
||||||
getCurrent: async () => ({
|
{
|
||||||
buildId: "test-build",
|
getCurrent: async () => ({
|
||||||
releaseId: "test-release",
|
buildId: "test-build",
|
||||||
configSchemaVersion: "1",
|
releaseId: "test-release",
|
||||||
apiContractVersion: "1",
|
configSchemaVersion: "1",
|
||||||
assetManifestHash: "test-hash",
|
apiContractVersion: "1",
|
||||||
routeChunks: {
|
assetManifestHash: "test-hash",
|
||||||
"route-home": "assets/home.js",
|
routeChunks: {
|
||||||
"route-sample-resources": "assets/sample.js",
|
"route-home": "assets/home.js",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
refresh: async () => ({
|
refresh: async () => ({
|
||||||
buildId: "test-build",
|
buildId: "test-build",
|
||||||
releaseId: "test-release",
|
releaseId: "test-release",
|
||||||
configSchemaVersion: "1",
|
configSchemaVersion: "1",
|
||||||
apiContractVersion: "1",
|
apiContractVersion: "1",
|
||||||
assetManifestHash: "test-hash",
|
assetManifestHash: "test-hash",
|
||||||
routeChunks: {
|
routeChunks: {
|
||||||
"route-home": "assets/home.js",
|
"route-home": "assets/home.js",
|
||||||
"route-sample-resources": "assets/sample.js",
|
},
|
||||||
},
|
}),
|
||||||
}),
|
},
|
||||||
},
|
navigation: overrides.navigation ?? { reload: () => {} },
|
||||||
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 { createExternalAuthSessionAdapter } from "../../src/adapters/auth/external-session-adapter.js";
|
||||||
import { createHttpClient } from "../../src/adapters/http/client.js";
|
import { createHttpClient } from "../../src/adapters/http/client.js";
|
||||||
|
import { TEST_HTTP_CONTRACT } from "../helpers/http-contract-fixture.js";
|
||||||
|
|
||||||
/** @type {number[]} */
|
/** @type {number[]} */
|
||||||
let responseStatuses = [];
|
let responseStatuses = [];
|
||||||
const server = setupServer(
|
const server = setupServer(
|
||||||
http.get("https://api.test/api/sample/resources", () => {
|
http.get("https://api.test/api/entities", () => {
|
||||||
const status = responseStatuses.shift() ?? 200;
|
const status = responseStatuses.shift() ?? 200;
|
||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
return HttpResponse.json(
|
return HttpResponse.json(
|
||||||
@@ -37,6 +38,11 @@ afterAll(() => server.close());
|
|||||||
|
|
||||||
const clock = { now: () => 0, sleep: async () => {} };
|
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
|
* @param {Partial<Parameters<typeof createExternalAuthSessionAdapter>[0]>} overrides
|
||||||
* @returns {Parameters<typeof createExternalAuthSessionAdapter>[0]}
|
* @returns {Parameters<typeof createExternalAuthSessionAdapter>[0]}
|
||||||
@@ -63,13 +69,13 @@ describe("bounded 401 session recovery", () => {
|
|||||||
recoverSession,
|
recoverSession,
|
||||||
notifyUnauthenticated: vi.fn(),
|
notifyUnauthenticated: vi.fn(),
|
||||||
}));
|
}));
|
||||||
const client = createHttpClient({
|
const client = testClient({
|
||||||
baseUrl: "https://api.test",
|
baseUrl: "https://api.test",
|
||||||
authSession,
|
authSession,
|
||||||
clock,
|
clock,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({
|
await expect(client.execute("LIST_ENTITIES")).resolves.toMatchObject({
|
||||||
ok: true,
|
ok: true,
|
||||||
});
|
});
|
||||||
expect(recoverSession).toHaveBeenCalledTimes(1);
|
expect(recoverSession).toHaveBeenCalledTimes(1);
|
||||||
@@ -83,13 +89,13 @@ describe("bounded 401 session recovery", () => {
|
|||||||
recoverSession: async () => "restored",
|
recoverSession: async () => "restored",
|
||||||
notifyUnauthenticated,
|
notifyUnauthenticated,
|
||||||
}));
|
}));
|
||||||
const client = createHttpClient({
|
const client = testClient({
|
||||||
baseUrl: "https://api.test",
|
baseUrl: "https://api.test",
|
||||||
authSession,
|
authSession,
|
||||||
clock,
|
clock,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({
|
await expect(client.execute("LIST_ENTITIES")).resolves.toMatchObject({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: { kind: "AUTH_REQUIRED" },
|
error: { kind: "AUTH_REQUIRED" },
|
||||||
});
|
});
|
||||||
@@ -104,13 +110,13 @@ describe("bounded 401 session recovery", () => {
|
|||||||
recoverSession: async () => "restored",
|
recoverSession: async () => "restored",
|
||||||
notifyUnauthenticated: vi.fn(),
|
notifyUnauthenticated: vi.fn(),
|
||||||
}));
|
}));
|
||||||
const client = createHttpClient({
|
const client = testClient({
|
||||||
baseUrl: "https://api.test",
|
baseUrl: "https://api.test",
|
||||||
authSession: attachFailure,
|
authSession: attachFailure,
|
||||||
clock,
|
clock,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({
|
await expect(client.execute("LIST_ENTITIES")).resolves.toMatchObject({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: { kind: "AUTH_INTEGRATION_FAILURE" },
|
error: { kind: "AUTH_INTEGRATION_FAILURE" },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { setupServer } from "msw/node";
|
|||||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { createHttpClient } from "../../src/adapters/http/client.js";
|
import { createHttpClient } from "../../src/adapters/http/client.js";
|
||||||
|
import { TEST_HTTP_CONTRACT } from "../helpers/http-contract-fixture.js";
|
||||||
|
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
const server = setupServer(
|
const server = setupServer(
|
||||||
http.get("https://api.test/api/sample/resources", () => {
|
http.get("https://api.test/api/entities", () => {
|
||||||
attempts += 1;
|
attempts += 1;
|
||||||
if (attempts < 3) {
|
if (attempts < 3) {
|
||||||
return HttpResponse.json(
|
return HttpResponse.json(
|
||||||
@@ -34,16 +35,21 @@ const clock = {
|
|||||||
sleep: async () => {},
|
sleep: async () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** @param {Parameters<typeof createHttpClient>[0]} options */
|
||||||
|
function testClient(options) {
|
||||||
|
return createHttpClient({ ...TEST_HTTP_CONTRACT, ...options });
|
||||||
|
}
|
||||||
|
|
||||||
describe("shared HTTP client", () => {
|
describe("shared HTTP client", () => {
|
||||||
it("retries a safe request at most twice and returns validated data", async () => {
|
it("retries a safe request at most twice and returns validated data", async () => {
|
||||||
const client = createHttpClient({
|
const client = testClient({
|
||||||
baseUrl: "https://api.test",
|
baseUrl: "https://api.test",
|
||||||
clock,
|
clock,
|
||||||
random: () => 0,
|
random: () => 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
client.execute("LIST_SAMPLE_RESOURCES", { routeId: "SAMPLE_RESOURCE_LIST" }),
|
client.execute("LIST_ENTITIES", { routeId: "TEST_ROUTE" }),
|
||||||
).resolves.toMatchObject({
|
).resolves.toMatchObject({
|
||||||
ok: true,
|
ok: true,
|
||||||
value: [{ id: "resource-1", displayName: "Example" }],
|
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 () => {
|
it("rejects a non-JSON response without exposing its body", async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get(
|
http.get(
|
||||||
"https://api.test/api/sample/resources",
|
"https://api.test/api/entities",
|
||||||
() => new HttpResponse("<secret>raw body</secret>", { status: 502 }),
|
() => new HttpResponse("<secret>raw body</secret>", { status: 502 }),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const client = createHttpClient({ baseUrl: "https://api.test", clock });
|
const client = testClient({ baseUrl: "https://api.test", clock });
|
||||||
const result = await client.execute("LIST_SAMPLE_RESOURCES");
|
const result = await client.execute("LIST_ENTITIES");
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -72,21 +78,21 @@ describe("shared HTTP client", () => {
|
|||||||
it("classifies malformed JSON and invalid payloads at the boundary", async () => {
|
it("classifies malformed JSON and invalid payloads at the boundary", async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get(
|
http.get(
|
||||||
"https://api.test/api/sample/resources",
|
"https://api.test/api/entities",
|
||||||
() =>
|
() =>
|
||||||
new HttpResponse("{", {
|
new HttpResponse("{", {
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const client = createHttpClient({ baseUrl: "https://api.test", clock });
|
const client = testClient({ baseUrl: "https://api.test", clock });
|
||||||
await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({
|
await expect(client.execute("LIST_ENTITIES")).resolves.toMatchObject({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: { kind: "MALFORMED_JSON" },
|
error: { kind: "MALFORMED_JSON" },
|
||||||
});
|
});
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.get("https://api.test/api/sample/resources", () =>
|
http.get("https://api.test/api/entities", () =>
|
||||||
HttpResponse.json({
|
HttpResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: [{ id: "resource-1", name: 42 }],
|
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,
|
ok: false,
|
||||||
error: { kind: "SCHEMA_MISMATCH" },
|
error: { kind: "SCHEMA_MISMATCH" },
|
||||||
});
|
});
|
||||||
@@ -102,7 +108,7 @@ describe("shared HTTP client", () => {
|
|||||||
|
|
||||||
it("guards mapper exceptions as UNKNOWN_FAILURE", async () => {
|
it("guards mapper exceptions as UNKNOWN_FAILURE", async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get("https://api.test/api/sample/resources", () =>
|
http.get("https://api.test/api/entities", () =>
|
||||||
HttpResponse.json({
|
HttpResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: [{ id: "resource-1", name: "Example" }],
|
data: [{ id: "resource-1", name: "Example" }],
|
||||||
@@ -110,7 +116,7 @@ describe("shared HTTP client", () => {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const client = createHttpClient({
|
const client = testClient({
|
||||||
baseUrl: "https://api.test",
|
baseUrl: "https://api.test",
|
||||||
clock,
|
clock,
|
||||||
mapPayload: () => {
|
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({
|
expect(result).toMatchObject({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: { kind: "UNKNOWN_FAILURE" },
|
error: { kind: "UNKNOWN_FAILURE" },
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { createHttpClient } from "../../src/adapters/http/client.js";
|
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 */
|
/** @param {unknown} data */
|
||||||
function successResponse(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", () => {
|
describe("HTTP operation execution contract", () => {
|
||||||
it("sends parsed search/body values and aligns canonical query identity", async () => {
|
it("sends parsed search/body values and aligns canonical query identity", async () => {
|
||||||
const requests = /** @type {Request[]} */ ([]);
|
const requests = /** @type {Request[]} */ ([]);
|
||||||
@@ -51,7 +59,7 @@ describe("HTTP operation execution contract", () => {
|
|||||||
}
|
}
|
||||||
return successResponse([]);
|
return successResponse([]);
|
||||||
});
|
});
|
||||||
const client = createHttpClient({
|
const client = testClient({
|
||||||
baseUrl: "https://api.test",
|
baseUrl: "https://api.test",
|
||||||
fetcher,
|
fetcher,
|
||||||
clock: immediateClock(),
|
clock: immediateClock(),
|
||||||
@@ -60,21 +68,21 @@ describe("HTTP operation execution contract", () => {
|
|||||||
const filters = { tags: ["open", "new"], cursor: "a/b", limit: 5 };
|
const filters = { tags: ["open", "new"], cursor: "a/b", limit: 5 };
|
||||||
|
|
||||||
await client.execute({
|
await client.execute({
|
||||||
operationId: "LIST_SAMPLE_RESOURCES",
|
operationId: "LIST_ENTITIES",
|
||||||
routeId: "SAMPLE_RESOURCE_LIST",
|
routeId: "TEST_ROUTE",
|
||||||
searchParams: filters,
|
searchParams: filters,
|
||||||
});
|
});
|
||||||
await client.execute({
|
await client.execute({
|
||||||
operationId: "CREATE_SAMPLE_RESOURCE",
|
operationId: "CREATE_ENTITY",
|
||||||
routeId: "SAMPLE_RESOURCE_LIST",
|
routeId: "TEST_ROUTE",
|
||||||
body: { name: " Trimmed " },
|
body: { name: " Trimmed " },
|
||||||
idempotencyKey: "logical-command",
|
idempotencyKey: "logical-command",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(requests[0].url).toBe(
|
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" });
|
await expect(requests[1].json()).resolves.toEqual({ name: "Trimmed" });
|
||||||
expect(requests[1].headers.get("Idempotency-Key")).toBe("logical-command");
|
expect(requests[1].headers.get("Idempotency-Key")).toBe("logical-command");
|
||||||
expect(scheduler.setTimeout).toHaveBeenCalledTimes(2);
|
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 () => {
|
it("performs no fetch or timer work for invalid request input", async () => {
|
||||||
const fetcher = vi.fn();
|
const fetcher = vi.fn();
|
||||||
const scheduler = recordingScheduler();
|
const scheduler = recordingScheduler();
|
||||||
const client = createHttpClient({
|
const client = testClient({
|
||||||
baseUrl: "https://api.test",
|
baseUrl: "https://api.test",
|
||||||
fetcher,
|
fetcher,
|
||||||
scheduler,
|
scheduler,
|
||||||
@@ -92,8 +100,8 @@ describe("HTTP operation execution contract", () => {
|
|||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
client.execute({
|
client.execute({
|
||||||
operationId: "CREATE_SAMPLE_RESOURCE",
|
operationId: "CREATE_ENTITY",
|
||||||
routeId: "SAMPLE_RESOURCE_LIST",
|
routeId: "TEST_ROUTE",
|
||||||
body: { name: " " },
|
body: { name: " " },
|
||||||
}),
|
}),
|
||||||
).resolves.toMatchObject({
|
).resolves.toMatchObject({
|
||||||
@@ -114,7 +122,7 @@ describe("HTTP operation execution contract", () => {
|
|||||||
async (maxRetryAttempts, totalAttempts) => {
|
async (maxRetryAttempts, totalAttempts) => {
|
||||||
const fetcher = vi.fn(async () => failureResponse(503));
|
const fetcher = vi.fn(async () => failureResponse(503));
|
||||||
const scheduler = recordingScheduler();
|
const scheduler = recordingScheduler();
|
||||||
const client = createHttpClient({
|
const client = testClient({
|
||||||
baseUrl: "https://api.test",
|
baseUrl: "https://api.test",
|
||||||
fetcher,
|
fetcher,
|
||||||
clock: immediateClock(),
|
clock: immediateClock(),
|
||||||
@@ -124,8 +132,8 @@ describe("HTTP operation execution contract", () => {
|
|||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
client.execute({
|
client.execute({
|
||||||
operationId: "LIST_SAMPLE_RESOURCES",
|
operationId: "LIST_ENTITIES",
|
||||||
routeId: "SAMPLE_RESOURCE_LIST",
|
routeId: "TEST_ROUTE",
|
||||||
}),
|
}),
|
||||||
).resolves.toMatchObject({
|
).resolves.toMatchObject({
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -149,15 +157,15 @@ describe("HTTP operation execution contract", () => {
|
|||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const client = createHttpClient({
|
const client = testClient({
|
||||||
baseUrl: "https://api.test",
|
baseUrl: "https://api.test",
|
||||||
fetcher,
|
fetcher,
|
||||||
scheduler,
|
scheduler,
|
||||||
maxRetryAttempts: 0,
|
maxRetryAttempts: 0,
|
||||||
});
|
});
|
||||||
const timeoutResult = client.execute({
|
const timeoutResult = client.execute({
|
||||||
operationId: "LIST_SAMPLE_RESOURCES",
|
operationId: "LIST_ENTITIES",
|
||||||
routeId: "SAMPLE_RESOURCE_LIST",
|
routeId: "TEST_ROUTE",
|
||||||
});
|
});
|
||||||
await vi.waitFor(() => expect(scheduler.callbacks).toHaveLength(1));
|
await vi.waitFor(() => expect(scheduler.callbacks).toHaveLength(1));
|
||||||
scheduler.callbacks[0]();
|
scheduler.callbacks[0]();
|
||||||
@@ -170,8 +178,8 @@ describe("HTTP operation execution contract", () => {
|
|||||||
const add = vi.spyOn(caller.signal, "addEventListener");
|
const add = vi.spyOn(caller.signal, "addEventListener");
|
||||||
const remove = vi.spyOn(caller.signal, "removeEventListener");
|
const remove = vi.spyOn(caller.signal, "removeEventListener");
|
||||||
const abortResult = client.execute({
|
const abortResult = client.execute({
|
||||||
operationId: "LIST_SAMPLE_RESOURCES",
|
operationId: "LIST_ENTITIES",
|
||||||
routeId: "SAMPLE_RESOURCE_LIST",
|
routeId: "TEST_ROUTE",
|
||||||
signal: caller.signal,
|
signal: caller.signal,
|
||||||
});
|
});
|
||||||
await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2));
|
await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2));
|
||||||
@@ -196,11 +204,11 @@ describe("HTTP operation execution contract", () => {
|
|||||||
retry: "never",
|
retry: "never",
|
||||||
requestSource: "none",
|
requestSource: "none",
|
||||||
requestSchema: "unused",
|
requestSchema: "unused",
|
||||||
responseSchema: "SampleResourcePayload",
|
responseSchema: "EntityPayload",
|
||||||
owner: "test",
|
owner: "test",
|
||||||
});
|
});
|
||||||
const unsafeFetch = vi.fn(async () => failureResponse(503));
|
const unsafeFetch = vi.fn(async () => failureResponse(503));
|
||||||
const unsafeClient = createHttpClient({
|
const unsafeClient = testClient({
|
||||||
baseUrl: "https://api.test",
|
baseUrl: "https://api.test",
|
||||||
fetcher: unsafeFetch,
|
fetcher: unsafeFetch,
|
||||||
clock: immediateClock(),
|
clock: immediateClock(),
|
||||||
@@ -213,14 +221,14 @@ describe("HTTP operation execution contract", () => {
|
|||||||
expect(unsafeFetch).toHaveBeenCalledOnce();
|
expect(unsafeFetch).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
const statusFetch = vi.fn(async () => failureResponse(500));
|
const statusFetch = vi.fn(async () => failureResponse(500));
|
||||||
const statusClient = createHttpClient({
|
const statusClient = testClient({
|
||||||
baseUrl: "https://api.test",
|
baseUrl: "https://api.test",
|
||||||
fetcher: statusFetch,
|
fetcher: statusFetch,
|
||||||
clock: immediateClock(),
|
clock: immediateClock(),
|
||||||
});
|
});
|
||||||
await statusClient.execute({
|
await statusClient.execute({
|
||||||
operationId: "LIST_SAMPLE_RESOURCES",
|
operationId: "LIST_ENTITIES",
|
||||||
routeId: "SAMPLE_RESOURCE_LIST",
|
routeId: "TEST_ROUTE",
|
||||||
});
|
});
|
||||||
expect(statusFetch).toHaveBeenCalledOnce();
|
expect(statusFetch).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
@@ -228,15 +236,15 @@ describe("HTTP operation execution contract", () => {
|
|||||||
successResponse([{ id: "one", name: 42 }]),
|
successResponse([{ id: "one", name: 42 }]),
|
||||||
);
|
);
|
||||||
const schemaScheduler = recordingScheduler();
|
const schemaScheduler = recordingScheduler();
|
||||||
const schemaClient = createHttpClient({
|
const schemaClient = testClient({
|
||||||
baseUrl: "https://api.test",
|
baseUrl: "https://api.test",
|
||||||
fetcher: schemaFetch,
|
fetcher: schemaFetch,
|
||||||
clock: immediateClock(),
|
clock: immediateClock(),
|
||||||
scheduler: schemaScheduler,
|
scheduler: schemaScheduler,
|
||||||
});
|
});
|
||||||
await schemaClient.execute({
|
await schemaClient.execute({
|
||||||
operationId: "LIST_SAMPLE_RESOURCES",
|
operationId: "LIST_ENTITIES",
|
||||||
routeId: "SAMPLE_RESOURCE_LIST",
|
routeId: "TEST_ROUTE",
|
||||||
});
|
});
|
||||||
expect(schemaFetch).toHaveBeenCalledOnce();
|
expect(schemaFetch).toHaveBeenCalledOnce();
|
||||||
expect(schemaScheduler.clearTimeout).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,
|
validateOperationRequest,
|
||||||
} from "../../src/adapters/http/schema-registry.js";
|
} from "../../src/adapters/http/schema-registry.js";
|
||||||
|
|
||||||
describe("HTTP runtime schema boundary", () => {
|
describe("HTTP platform schema boundary", () => {
|
||||||
it("rejects an invalid top-level envelope", () => {
|
it("rejects an invalid top-level envelope", () => {
|
||||||
expect(validateEnvelope({ success: true }).success).toBe(false);
|
expect(validateEnvelope({ success: true }).success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an invalid operation payload with safe issue metadata", () => {
|
it("accepts and clones a generic valid response envelope", () => {
|
||||||
const result = validateOperationPayload("SampleResourceListPayload", [
|
const source = {
|
||||||
{ 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({
|
|
||||||
success: true,
|
success: true,
|
||||||
data: [{ id: "resource-1", additive: "accepted" }],
|
data: [],
|
||||||
});
|
meta: { requestId: "request-1", traceId: "trace-1" },
|
||||||
if (!result.success) throw new Error("expected valid sample payload");
|
};
|
||||||
|
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);
|
expect(result.data).not.toBe(source);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("validates outbound commands before transport", () => {
|
it("fails closed without leaking input when a feature schema is absent", () => {
|
||||||
expect(
|
const result = validateOperationPayload("UnknownPayload", {
|
||||||
validateOperationRequest("CreateSampleResourceCommand", { name: "" }).success,
|
secret: "not-projected",
|
||||||
).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" }],
|
|
||||||
});
|
});
|
||||||
|
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",
|
"diagnostics",
|
||||||
"runtime",
|
"runtime",
|
||||||
"recovery",
|
"recovery",
|
||||||
|
"features",
|
||||||
]);
|
]);
|
||||||
expect(application).not.toHaveProperty("storage");
|
expect(application).not.toHaveProperty("storage");
|
||||||
expect(application).not.toHaveProperty("telemetry");
|
expect(application).not.toHaveProperty("telemetry");
|
||||||
expect(application).not.toHaveProperty("releaseInfo");
|
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({
|
await expect(application.runtime.getReleaseSummary()).resolves.toEqual({
|
||||||
buildId: "test-build",
|
buildId: "test-build",
|
||||||
releaseId: "test-release",
|
releaseId: "test-release",
|
||||||
|
|||||||
@@ -1,52 +1,11 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import {
|
import { mapOperationPayload } from "../../src/adapters/http/resource-mapper.js";
|
||||||
mapOperationPayload,
|
|
||||||
mapResourceDto,
|
|
||||||
} from "../../src/adapters/http/resource-mapper.js";
|
|
||||||
import { toResourceViewModel } from "../../src/application/view-models/resource-view-model.js";
|
|
||||||
|
|
||||||
describe("DTO to model to view-model mapping", () => {
|
describe("platform DTO mapper boundary", () => {
|
||||||
it("contains raw DTO names at the HTTP boundary", () => {
|
it("fails closed when no feature mapper was injected", () => {
|
||||||
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 },
|
|
||||||
]);
|
|
||||||
expect(() => mapOperationPayload("UNKNOWN", {})).toThrow(
|
expect(() => mapOperationPayload("UNKNOWN", {})).toThrow(
|
||||||
"No boundary mapper registered",
|
"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(
|
const result = createFailure(
|
||||||
"SERVER_FAILURE",
|
"SERVER_FAILURE",
|
||||||
"LIST_SAMPLE_RESOURCES",
|
"LIST_ENTITIES",
|
||||||
0,
|
0,
|
||||||
untrustedDetails,
|
untrustedDetails,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
MANUAL_A11Y_ROUTE_IDS,
|
MANUAL_A11Y_ROUTE_IDS,
|
||||||
validateManualA11yEvidence,
|
validateManualA11yEvidence,
|
||||||
} from "../../scripts/lib/manual-a11y-evidence.mjs";
|
} 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
|
const reviewed = `Status: reviewed
|
||||||
Route ID: APP_HOME
|
Route ID: APP_HOME
|
||||||
|
|||||||
@@ -1,126 +1,35 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
NAVIGATION_ROUTES,
|
||||||
|
ROUTE_REGISTRY,
|
||||||
|
} from "../../src/features/installed-feature-contracts.js";
|
||||||
import {
|
import {
|
||||||
createRedirectLoopGuard,
|
createRedirectLoopGuard,
|
||||||
decideRouteAccess,
|
decideRouteAccess,
|
||||||
} from "../../src/presentation/routes/navigation-policy.js";
|
} 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", () => {
|
it("derives visible navigation in explicit order", () => {
|
||||||
expect(NAVIGATION_ROUTES.map(({ routeId }) => routeId)).toEqual([
|
const expected = Object.values(ROUTE_REGISTRY)
|
||||||
"APP_HOME",
|
.filter((route) => route.navigationOrder !== null)
|
||||||
"EXAMPLES_UI",
|
.sort(
|
||||||
"EXAMPLES_STATES",
|
(left, right) =>
|
||||||
"EXAMPLES_AUTH",
|
/** @type {number} */ (left.navigationOrder) -
|
||||||
"SAMPLE_RESOURCE_LIST",
|
/** @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({
|
expect(decideRouteAccess("APP_HOME", "unauthenticated")).toEqual({
|
||||||
allowed: true,
|
allowed: true,
|
||||||
action: "none",
|
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);
|
const guard = createRedirectLoopGuard(2);
|
||||||
expect(guard.allow("/private", "/signin")).toBe(true);
|
expect(guard.allow("/private", "/signin")).toBe(true);
|
||||||
expect(guard.allow("/private", "/signin")).toBe(false);
|
expect(guard.allow("/private", "/signin")).toBe(false);
|
||||||
|
|||||||
@@ -5,18 +5,27 @@ import {
|
|||||||
createQueryCacheAdapter,
|
createQueryCacheAdapter,
|
||||||
createQueryClient,
|
createQueryClient,
|
||||||
} from "../../src/adapters/query-cache/tanstack-query-cache.js";
|
} 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", () => {
|
describe("query key registry", () => {
|
||||||
it("canonicalizes filter order into the same stable key", () => {
|
it("canonicalizes filter order into the same stable key", () => {
|
||||||
expect(queryKeys.resource.list({ page: 1, status: "open" })).toEqual(
|
expect(queryKeys.list({ page: 1, status: "open" })).toEqual(
|
||||||
queryKeys.resource.list({ status: "open", page: 1 }),
|
queryKeys.list({ status: "open", page: 1 }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("contains no raw URL or token material", () => {
|
it("contains no raw URL or token material", () => {
|
||||||
expect(JSON.stringify(queryKeys.resource.detail("resource-1"))).toBe(
|
expect(JSON.stringify(queryKeys.detail("entity-1"))).toBe(
|
||||||
'["resource",1,"detail","resource-1"]',
|
'["entity",1,"detail","entity-1"]',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -25,7 +34,7 @@ describe("TanStack QueryCachePort adapter", () => {
|
|||||||
it("reads, writes, and invalidates only the declared namespace", async () => {
|
it("reads, writes, and invalidates only the declared namespace", async () => {
|
||||||
const client = createQueryClient();
|
const client = createQueryClient();
|
||||||
const adapter = createQueryCacheAdapter(client);
|
const adapter = createQueryCacheAdapter(client);
|
||||||
const listKey = queryKeys.resource.list({ page: 1 });
|
const listKey = queryKeys.list({ page: 1 });
|
||||||
const otherKey = ["other", 1];
|
const otherKey = ["other", 1];
|
||||||
|
|
||||||
expect(adapter.write(listKey, [{ id: "resource-1" }])).toEqual({ ok: true });
|
expect(adapter.write(listKey, [{ id: "resource-1" }])).toEqual({ ok: true });
|
||||||
@@ -35,7 +44,7 @@ describe("TanStack QueryCachePort adapter", () => {
|
|||||||
value: [{ id: "resource-1" }],
|
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(listKey)?.isInvalidated).toBe(true);
|
||||||
expect(client.getQueryState(otherKey)?.isInvalidated).toBe(false);
|
expect(client.getQueryState(otherKey)?.isInvalidated).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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 {
|
import {
|
||||||
buildRouteUrl,
|
ROUTE_REGISTRY,
|
||||||
parseRouteInput,
|
ROUTE_RUNTIME_CONTRACT,
|
||||||
} from "../../src/presentation/routes/route-codecs.js";
|
} from "../../src/features/installed-feature-contracts.js";
|
||||||
import { ROUTE_RUNTIME } from "../../src/presentation/routes/route-runtime.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", () => {
|
describe("typed installed route catalog", () => {
|
||||||
it("keeps contract, runtime contribution, and executable module complete", () => {
|
it("keeps contract and executable runtime contributions complete", () => {
|
||||||
expect(Object.keys(ROUTE_RUNTIME_CONTRACT).sort()).toEqual(
|
expect(Object.keys(ROUTE_RUNTIME_CONTRACT).sort()).toEqual(
|
||||||
Object.keys(ROUTE_REGISTRY).sort(),
|
Object.keys(ROUTE_REGISTRY).sort(),
|
||||||
);
|
);
|
||||||
@@ -18,46 +17,7 @@ describe("typed route contract and runtime", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("round-trips canonical search through the registered codec", () => {
|
it("accepts the platform not-found splat owner", () => {
|
||||||
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" });
|
|
||||||
expect(
|
expect(
|
||||||
parseRouteInput(
|
parseRouteInput(
|
||||||
"NOT_FOUND",
|
"NOT_FOUND",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
createRuntimeAdapters,
|
createRuntimeAdapters,
|
||||||
createRuntimeHttpClient,
|
createRuntimeHttpClient,
|
||||||
} from "../../src/bootstrap/runtime-adapters.js";
|
} from "../../src/bootstrap/runtime-adapters.js";
|
||||||
|
import { TEST_HTTP_CONTRACT } from "../helpers/http-contract-fixture.js";
|
||||||
|
|
||||||
const runtime = {
|
const runtime = {
|
||||||
config: {
|
config: {
|
||||||
@@ -118,23 +119,26 @@ describe("runtime adapter composition", () => {
|
|||||||
release,
|
release,
|
||||||
host: {},
|
host: {},
|
||||||
})).outputPorts.session;
|
})).outputPorts.session;
|
||||||
const client = createRuntimeHttpClient({
|
const client = createRuntimeHttpClient(
|
||||||
runtime:
|
{
|
||||||
/** @type {Parameters<typeof createRuntimeHttpClient>[0]["runtime"]} */ (
|
runtime:
|
||||||
runtime
|
/** @type {Parameters<typeof createRuntimeHttpClient>[0]["runtime"]} */ (
|
||||||
),
|
runtime
|
||||||
authSession:
|
),
|
||||||
/** @type {import("../../src/application/ports/auth-session-port.js").AuthSessionPort} */ (
|
authSession:
|
||||||
authSession
|
/** @type {import("../../src/application/ports/auth-session-port.js").AuthSessionPort} */ (
|
||||||
),
|
authSession
|
||||||
fetcher,
|
),
|
||||||
clock: { now: () => 0, sleep: async () => {} },
|
fetcher,
|
||||||
scheduler,
|
clock: { now: () => 0, sleep: async () => {} },
|
||||||
});
|
scheduler,
|
||||||
|
},
|
||||||
|
TEST_HTTP_CONTRACT,
|
||||||
|
);
|
||||||
|
|
||||||
await client.execute({
|
await client.execute({
|
||||||
operationId: "LIST_SAMPLE_RESOURCES",
|
operationId: "LIST_ENTITIES",
|
||||||
routeId: "SAMPLE_RESOURCE_LIST",
|
routeId: "TEST_ROUTE",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fetcher).toHaveBeenCalledOnce();
|
expect(fetcher).toHaveBeenCalledOnce();
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const validAttributes = {
|
|||||||
error_kind: "SERVER_FAILURE",
|
error_kind: "SERVER_FAILURE",
|
||||||
http_status_group: "5xx",
|
http_status_group: "5xx",
|
||||||
attempt_count_bucket: "3",
|
attempt_count_bucket: "3",
|
||||||
route_id: "SAMPLE_RESOURCE_LIST",
|
route_id: "TEST_ROUTE",
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("telemetry registry and redaction", () => {
|
describe("telemetry registry and redaction", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user