feat: add the TechLog asset multipart upload transport
Wires the whole Asset capability into the running application: the multipart upload transport (the contract runtime can only express JSON bodies), a single composition-root-owned CSRF provider shared between the platform's credential collaborator (18 JSON operations) and the upload transport (1 multipart operation), and Studio/StudioShell exposure of the Asset gateway alongside the existing document gateway. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d9c2d8bc5e
commit
c9c832c365
@@ -0,0 +1,76 @@
|
|||||||
|
# Adapter Review — TechLog Asset Multipart Upload
|
||||||
|
|
||||||
|
> 검토 기준: `feature/techlog-backend-alignment` (2026-08-18, Task 7)
|
||||||
|
>
|
||||||
|
> 범위: `src/features/tech-log/adapters/http/asset-upload-transport.ts` 1개 파일과 그 wiring — `create-tech-log-feature-input.ts`, `installed-feature-adapters.ts`, `bootstrap/runtime-adapters.ts`의 `attachCredentials`/`techLogCsrf`. `src/adapters/**` 전수 리뷰([INVENTORY](./INVENTORY.md))와는 별도 트랙이다: 이 파일은 `src/features/tech-log/adapters/**` 아래에 있고, TechLog는 자체 canonical HTTP 계약을 갖는 product feature이지 템플릿의 범용 adapter 계층이 아니다.
|
||||||
|
|
||||||
|
## 결론
|
||||||
|
|
||||||
|
TechLog Studio는 canonical 계약상 19개 operation을 갖는다. 그중 18개는 `external-contract-runtime.ts`가 표현할 수 있는 `requestBody: "NONE" | "JSON"` 범위 안에 있고, 플랫폼의 V3 실행기(`http-execution-v3.ts`) · 저수준 client(`client.ts`) · `attachCredentials` credential seam을 그대로 통과한다. 나머지 1개, `uploadStudioAsset`(`POST /api/v1/studio/assets`)만 `multipart/form-data`를 요구한다. 이 요구를 플랫폼이 표현할 수 없으므로, 이 operation 하나만 별도의 좁은 transport(`asset-upload-transport.ts`)로 분리했다.
|
||||||
|
|
||||||
|
이 seam은 플랫폼을 대체하지 않는다. CSRF, `Idempotency-Key`, canonical 오류 코드 매핑, timeout, credentials는 동일한 provider·동일한 오류 taxonomy로 다시 구현해 대칭을 유지한다. 포기하는 것은 플랫폼이 대신 강제해 주던 부분 — 계약 실행기의 byte 상한, retry policy, V3 진단 계측 — 뿐이며 이는 아래에 명시적으로 기록한다. 조립 지점은 `createTechLogFeatureInstalledInput`이 무조건(HTTP/MOCK 무관) `createHttpStudioAssetGateway`를 구성하고, 그 안에 이 transport를 주입하는 한 곳뿐이다.
|
||||||
|
|
||||||
|
## 우회 대상과 이유
|
||||||
|
|
||||||
|
- `src/contracts/external-contract-runtime.ts`의 `requestBody` union은 `"NONE" | "JSON"` 두 값만 갖는다. `multipart/form-data`를 표현할 세 번째 값이 없다.
|
||||||
|
- `src/adapters/http/client.ts:719` 부근의 저수준 client는 본문이 있는 모든 request를 `JSON.stringify(input)`으로 직렬화해 고정 `content-type: application/json`으로 보낸다. `File`을 이 경로에 태우면 파일 바이트 대신 그 JSON 표현(빈 객체거나 오류)이 전송된다.
|
||||||
|
- 두 제약 모두 이번 task의 global constraint로 수정 금지 대상이다(`external-contract-runtime.ts`, `client.ts`, `http-execution-v3.ts`, `mutation-intent.ts`, 생성된 계약 산출물, `studio-gateway.ts` 포트). 계약 실행기 자체를 바꾸는 대신, `uploadStudioAsset` 한 operation만 포트 경계 뒤에서 다른 구현으로 우회한다.
|
||||||
|
|
||||||
|
## 우회 범위
|
||||||
|
|
||||||
|
`tech-log-studio-contract-contribution.ts`가 등록하는 canonical operation은 19개다.
|
||||||
|
|
||||||
|
| 분류 | operation | 경로 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| JSON, 계약 실행기 경유 | `getStudioSession`, `getStudioDashboard`, `listStudioDocuments`, `createStudioDocument`, `getStudioDocument`, `saveStudioDocument`, `validateStudioDocument`, `getCurrentStudioPreview`, `createStudioPreview`, `publishStudioDocument`, `listStudioPublications`, `unpublishStudioPublication`, `getStudioPublicationSnapshot`, `listStudioCatalog`, `listStudioAssets`, `getStudioAsset`, `updateStudioAsset`, `deleteStudioAsset` (18개) | `contractHttp.execute()` → `client.ts` → `attachCredentials` |
|
||||||
|
| multipart, 플랫폼 우회 | `uploadStudioAsset` (1개) | `asset-upload-transport.ts`의 직접 `fetch()` |
|
||||||
|
|
||||||
|
18개는 `contractOperations.execute(operationId, input, { routeId, intent? })`를 통해 나가며 `attachCredentials`가 매 요청에 `x-csrf-token`을 싣는다. 1개(`uploadStudioAsset`)만 이 경로를 완전히 벗어나 `createAssetUploadTransport`가 직접 `fetch()`한다. `StudioAssetGateway.uploadAsset()`이 이 transport를 호출하는 유일한 지점이며, 포트 시그니처(`Promise<Asset>`)는 나머지 4개 asset operation과 동일해 호출자는 어느 경로인지 알 필요가 없다.
|
||||||
|
|
||||||
|
## 유지되는 보증
|
||||||
|
|
||||||
|
플랫폼이 18개 JSON operation에 자동으로 제공하는 것을, 이 transport는 같은 provider·같은 값으로 손으로 다시 만든다.
|
||||||
|
|
||||||
|
| 보증 | JSON 경로 | multipart 경로 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| CSRF | `attachCredentials`가 `techLogCsrf.token()`으로 얻은 값을 `x-csrf-token`에 싣는다 | `StudioAssetGateway.uploadAsset()`이 **같은** `techLogCsrf` provider에서 `token()`/`headerName()`을 읽어 transport에 넘긴다 — provider가 composition root에 하나뿐이므로 세션당 토큰도 하나다 |
|
||||||
|
| Idempotency-Key | 실행 intent(`mutationIntent()`)에서 나와 client가 헤더로 싣는다 | 호출자(`uploadAsset` options)의 `idempotencyKey`를 gateway가 그대로 헤더로 전달한다 |
|
||||||
|
| canonical 오류 코드 매핑 | `toStudioGatewayError()`가 `STUDIO_ERROR_CODES`에 있는 `problem.code`만 `StudioGatewayError`로 승격하고, 계약 밖 코드는 `STUDIO_UNAVAILABLE`로 접는다 | transport가 동일한 `STUDIO_ERROR_CODES` 집합을 재사용해 같은 규칙으로 매핑한다. 서버가 `PAYLOAD_TOO_LARGE`/`UNSUPPORTED_MEDIA_TYPE`처럼 이 목록에 있는 코드를 보내면 그대로 `StudioGatewayError`가 되고, 계약에 없는 코드나 파싱 불가능한 본문은 도메인 코드를 지어내지 않고 `STUDIO_UNAVAILABLE`로 접는다 |
|
||||||
|
| timeout | 계약의 `requestDeadlineCeilingMs` | `AbortSignal.timeout(deps.timeoutMs)`를 호출자 signal과 `AbortSignal.any()`로 합성한다 |
|
||||||
|
| credentials | `TECH_LOG_STUDIO_SESSION` 프로필의 `credentials: "include"` | `fetch()` 호출에 동일하게 `credentials: "include"`를 명시한다 |
|
||||||
|
|
||||||
|
## 포기하는 보증
|
||||||
|
|
||||||
|
- **byte 상한**: 계약 실행기의 bounded body reader/writer가 응답 크기를 강제하는 것과 달리, 이 transport의 요청 body(`FormData`)와 응답 JSON 파싱에는 별도 상한이 없다. 서버가 `413 PAYLOAD_TOO_LARGE`로 거절하는 것에 의존한다.
|
||||||
|
- **retry policy**: 계약 실행기의 `retry-policy.ts`는 이 operation에 적용되지 않는다. `uploadStudioAsset`은 애초에 계약에서 `retrySemantics`를 선언할 수 없는 경로 밖에 있으므로, 재시도는 호출자(향후 Task 11의 Asset Library UI)가 명시적으로 다시 `uploadAsset()`을 호출하는 형태로만 존재한다.
|
||||||
|
- **V3 진단 계측**: `createHttpObservationProjector`가 만드는 `api.request.*` diagnostics/telemetry 이벤트는 `contractHttp.execute()` 내부에서만 발생한다. 이 transport는 그 관찰 경계 밖에서 직접 `fetch()`하므로 업로드 성공/실패는 diagnostics 스트림에 나타나지 않는다. `routeId: "TECH_LOG_STUDIO_ASSETS"`는 나머지 4개 asset JSON operation에는 여전히 붙지만, `uploadStudioAsset` 자체에는 대응하는 diagnostics 레코드가 없다.
|
||||||
|
|
||||||
|
이 세 항목 모두 이번 task 범위에서 새로 만들지 않는다 — 다시 만들려면 플랫폼과 동일한 bounded reader/retry/observation을 복제해야 하고, 그것은 계약 실행기를 다시 짓는 것과 다르지 않다. 대신 아래 교체 계획으로 닫는다.
|
||||||
|
|
||||||
|
## `ROUTE_ID` 검토 (Task 6 리뷰 인계 항목)
|
||||||
|
|
||||||
|
`http-studio-asset-gateway.ts`의 `const ROUTE_ID = "TECH_LOG_STUDIO_ASSETS"`는 diagnostics/telemetry 버킷을 나누는 low-cardinality routing 메타데이터이지, 등록된 route 경로가 아니다. 이제 gateway가 실제로 배선되어 나머지 4개 asset JSON operation(`listAssets`, `getAsset`, `updateAssetMetadata`, `deleteAsset`)이 이 값으로 나가는 시점에서 다시 확인한 결과, **그대로 유지한다.** 문서/편집 operation의 `TECH_LOG_STUDIO`와 별개 값을 쓰는 것은 두 가지를 갖는다.
|
||||||
|
|
||||||
|
1. Asset 수명주기(업로드·목록·삭제)는 문서 편집과 실패 특성이 다르다 — 예를 들어 `PAYLOAD_TOO_LARGE`/`UNSUPPORTED_MEDIA_TYPE`/`ASSET_QUARANTINED`는 asset 쪽에만 있다. 별도 routeId는 이 실패를 diagnostics에서 문서 편집 트래픽과 섞지 않는다.
|
||||||
|
2. `uploadStudioAsset` 자체는 계약 실행기를 우회해 이 routeId를 진단에 보고하지 않지만, 같은 이름을 4개 JSON operation에 유지해 두면 향후 업로드가 presigned/resumable로 옮겨가거나 플랫폼에 MULTIPART 모드가 생겨 계약 경로로 복귀할 때, 같은 routeId 아래 asset 트래픽 전체가 이미 일관되게 모여 있다.
|
||||||
|
|
||||||
|
값을 바꿀 이유(예: 기존 registry 충돌, 명명 규칙 위반)는 없었다.
|
||||||
|
|
||||||
|
## 교체 계획
|
||||||
|
|
||||||
|
1. **presigned/resumable 업로드로 이전.** `src/adapters/browser-transfer/`에 이미 presigned capability와 resumable checkpoint 인프라가 있다(별도 리뷰: [04 — Browser transfer](./04-browser-transfer.md)). Studio asset 업로드가 그쪽으로 옮겨가면, 이 transport는 presigned URL 발급을 위한 작은 JSON operation(계약 실행기 경유 가능)과 실제 바이트 전송을 위한 presigned executor 호출로 나뉜다. `POST /api/v1/studio/assets`의 multipart 자체가 없어진다.
|
||||||
|
2. **플랫폼에 `requestBody: "MULTIPART"` 모드가 생기는 경우.** `external-contract-runtime.ts`와 `client.ts`가 `FormData` 본문을 표현할 수 있게 확장되면, `uploadStudioAsset`을 다른 17개 operation과 함께 `tech-log-studio-contract-contribution.ts`에 등록하고 `createHttpStudioAssetGateway`의 `upload` 의존성을 제거한다. `StudioAssetGateway` 포트 시그니처(`uploadAsset(form, options): Promise<Asset>`)는 바뀌지 않는다 — 교체는 이 파일과 `create-tech-log-feature-input.ts`의 배선 한 줄에서 끝난다.
|
||||||
|
|
||||||
|
두 경로 모두 `StudioAssetUploadTransport`/`StudioAssetGateway` 포트 경계 뒤에서 일어나므로, presentation 계층(Task 11의 Asset Library UI)은 재작성하지 않는다.
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
```
|
||||||
|
corepack pnpm exec vitest run tests/features/tech-log/asset-upload-transport.test.ts
|
||||||
|
corepack pnpm exec vitest run tests/features/tech-log/studio-asset-gateway.test.ts
|
||||||
|
corepack pnpm exec vitest run tests/features/tech-log/runtime-composition.test.ts
|
||||||
|
corepack pnpm check:types
|
||||||
|
corepack pnpm test:tech-log
|
||||||
|
```
|
||||||
|
|
||||||
|
정확한 실행 결과는 `.superpowers/sdd/2026-08-17-techlog-backend-alignment/task-7-report.md`에 있다.
|
||||||
@@ -131,3 +131,9 @@
|
|||||||
|
|
||||||
합계: **120/120**. 새 adapter 파일이 추가되면 이 ledger와 해당 상세 리뷰 inventory를 같은 변경에서 갱신한다.
|
합계: **120/120**. 새 adapter 파일이 추가되면 이 ledger와 해당 상세 리뷰 inventory를 같은 변경에서 갱신한다.
|
||||||
|
|
||||||
|
## Feature-scoped adapter 리뷰 (`src/adapters/**` 밖)
|
||||||
|
|
||||||
|
이 표는 `corepack pnpm check:adapter-inventory`가 `git ls-files src/adapters`와 대조하는 목록이라 `src/features/**/adapters/**` 파일은 포함하지 않는다. TechLog는 자체 canonical HTTP 계약을 갖는 product feature이며 그 adapter는 별도 트랙으로 검토한다.
|
||||||
|
|
||||||
|
- `src/features/tech-log/adapters/http/asset-upload-transport.ts` — [06 — TechLog asset multipart upload](./06-tech-log-asset-upload.md)
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import { createConditionalValidatorStore } from "../adapters/query-cache/conditi
|
|||||||
import { createBrowserStorageAdapter } from "../adapters/storage/browser-storage-adapter.ts";
|
import { createBrowserStorageAdapter } from "../adapters/storage/browser-storage-adapter.ts";
|
||||||
import { createBrowserMutationIntentFactory } from "../adapters/platform/browser-mutation-intent-factory.ts";
|
import { createBrowserMutationIntentFactory } from "../adapters/platform/browser-mutation-intent-factory.ts";
|
||||||
import { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.ts";
|
import { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.ts";
|
||||||
|
import { createCsrfTokenProvider } from "../features/tech-log/adapters/http/studio-session-csrf.ts";
|
||||||
|
import type { StudioOperationExecutor as TechLogStudioOperationExecutor } from "../features/tech-log/adapters/http/http-studio-gateway.ts";
|
||||||
import type { AuthSessionPort } from "../application/ports/auth-session-port.ts";
|
import type { AuthSessionPort } from "../application/ports/auth-session-port.ts";
|
||||||
import type { ReleaseInfo } from "../application/ports/release-info-port.ts";
|
import type { ReleaseInfo } from "../application/ports/release-info-port.ts";
|
||||||
import {
|
import {
|
||||||
@@ -420,6 +422,43 @@ export async function createRuntimeAdapters(
|
|||||||
location.reload();
|
location.reload();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
/**
|
||||||
|
* §7.7 / Task 7. There is exactly one CSRF provider per Studio session, and
|
||||||
|
* it is owned by the composition root — not by the TechLog feature input —
|
||||||
|
* because two collaborators share it: `attachCredentials` below (the only
|
||||||
|
* path by which `x-csrf-token` reaches the 18 JSON operations) and the
|
||||||
|
* multipart upload transport, which bypasses the platform executor
|
||||||
|
* entirely and must set the header itself. If each built its own provider,
|
||||||
|
* one Studio session would hold two different tokens.
|
||||||
|
*
|
||||||
|
* `execute` calls `getStudioSession` through `contractOperations`, which is
|
||||||
|
* declared further below — a real ordering hazard, since `attachCredentials`
|
||||||
|
* (needed to build `contractHttp`, needed to build `contractOperations`)
|
||||||
|
* needs this provider first. `getStudioSession` is a SAFE operation and
|
||||||
|
* needs no CSRF itself, so there is no true cycle: the callback below only
|
||||||
|
* *runs* once the whole runtime is composed and a Studio request is made,
|
||||||
|
* by which point `contractOperations` is assigned. `let` plus a forward
|
||||||
|
* reference inside this closure defers the read to call time instead of
|
||||||
|
* declaration time.
|
||||||
|
*/
|
||||||
|
let contractOperations!: TechLogStudioOperationExecutor;
|
||||||
|
const techLogCsrf = createCsrfTokenProvider({
|
||||||
|
async execute(options) {
|
||||||
|
const outcome = await contractOperations.execute(
|
||||||
|
"getStudioSession",
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
routeId: "TECH_LOG_STUDIO",
|
||||||
|
...(options?.signal ? { signal: options.signal } : {}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (outcome.kind !== "SUCCESS") {
|
||||||
|
throw new Error("studio session is unavailable");
|
||||||
|
}
|
||||||
|
const value = outcome.value as { csrfToken: string; csrfHeaderName: string };
|
||||||
|
return { csrfToken: value.csrfToken, csrfHeaderName: value.csrfHeaderName };
|
||||||
|
},
|
||||||
|
});
|
||||||
const contractHttp = createContractHttpExecutor({
|
const contractHttp = createContractHttpExecutor({
|
||||||
baseUrl: config.API_BASE_URL,
|
baseUrl: config.API_BASE_URL,
|
||||||
maxRetryAttempts: config.MAX_RETRY_ATTEMPTS,
|
maxRetryAttempts: config.MAX_RETRY_ATTEMPTS,
|
||||||
@@ -436,6 +475,25 @@ export async function createRuntimeAdapters(
|
|||||||
if (serverStateScope.getPhase() !== "READY") {
|
if (serverStateScope.getPhase() !== "READY") {
|
||||||
return Object.freeze({ kind: "SCOPE_FENCED" as const });
|
return Object.freeze({ kind: "SCOPE_FENCED" as const });
|
||||||
}
|
}
|
||||||
|
if (operation.authProfileId === "TECH_LOG_STUDIO_SESSION") {
|
||||||
|
// Studio authenticates with a session cookie and carries only the
|
||||||
|
// CSRF token as a proof header. Read operations use this profile too
|
||||||
|
// — the server does not require the header for them — so a failure
|
||||||
|
// to fetch the token fails only this one request (`UNAVAILABLE`) and
|
||||||
|
// is not promoted to a session-level `UNAUTHENTICATED`, which would
|
||||||
|
// trigger a global re-authentication flow the session itself did not
|
||||||
|
// warrant.
|
||||||
|
try {
|
||||||
|
return Object.freeze({
|
||||||
|
kind: "READY" as const,
|
||||||
|
headers: Object.freeze({
|
||||||
|
"x-csrf-token": await techLogCsrf.token({ signal: authContext.signal }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return Object.freeze({ kind: "UNAVAILABLE" as const });
|
||||||
|
}
|
||||||
|
}
|
||||||
const state = authSession.getState();
|
const state = authSession.getState();
|
||||||
if (state === "integration-failed") {
|
if (state === "integration-failed") {
|
||||||
return Object.freeze({ kind: "UNAVAILABLE" as const });
|
return Object.freeze({ kind: "UNAVAILABLE" as const });
|
||||||
@@ -465,7 +523,7 @@ export async function createRuntimeAdapters(
|
|||||||
},
|
},
|
||||||
observe: createHttpObservationProjector({ diagnostics, telemetry }),
|
observe: createHttpObservationProjector({ diagnostics, telemetry }),
|
||||||
});
|
});
|
||||||
const contractOperations = Object.freeze({
|
contractOperations = Object.freeze({
|
||||||
async execute(
|
async execute(
|
||||||
operationId: string,
|
operationId: string,
|
||||||
input: unknown,
|
input: unknown,
|
||||||
@@ -499,6 +557,11 @@ export async function createRuntimeAdapters(
|
|||||||
});
|
});
|
||||||
if (outcome.kind === "UNAUTHENTICATED") {
|
if (outcome.kind === "UNAUTHENTICATED") {
|
||||||
authSession.onUnauthenticated();
|
authSession.onUnauthenticated();
|
||||||
|
// The Studio session (and the CSRF token it issued) expired. The
|
||||||
|
// cache owner discards it here, not the gateway — the gateway has no
|
||||||
|
// way to know a 401 on one operation invalidates a token shared by
|
||||||
|
// every other in-flight and future Studio request.
|
||||||
|
techLogCsrf.invalidate();
|
||||||
}
|
}
|
||||||
return outcome;
|
return outcome;
|
||||||
},
|
},
|
||||||
@@ -506,6 +569,9 @@ export async function createRuntimeAdapters(
|
|||||||
const featureInputs = createInstalledFeatureInputs({
|
const featureInputs = createInstalledFeatureInputs({
|
||||||
contractOperations,
|
contractOperations,
|
||||||
studioSource: config.TECH_LOG_STUDIO_SOURCE,
|
studioSource: config.TECH_LOG_STUDIO_SOURCE,
|
||||||
|
apiBaseUrl: config.API_BASE_URL,
|
||||||
|
requestTimeoutMs: config.REQUEST_TIMEOUT_MS,
|
||||||
|
csrf: techLogCsrf,
|
||||||
});
|
});
|
||||||
|
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { ApplicationFeatureInputs } from "../application/ports/in/applicati
|
|||||||
import { createReferenceFeatureInstalledInput } from "./reference-feature/adapters/create-reference-feature-input.ts";
|
import { createReferenceFeatureInstalledInput } from "./reference-feature/adapters/create-reference-feature-input.ts";
|
||||||
import { REFERENCE_FEATURE_ID } from "./reference-feature/contracts/reference-feature-contract.ts";
|
import { REFERENCE_FEATURE_ID } from "./reference-feature/contracts/reference-feature-contract.ts";
|
||||||
import { createTechLogFeatureInstalledInput } from "./tech-log/adapters/create-tech-log-feature-input.ts";
|
import { createTechLogFeatureInstalledInput } from "./tech-log/adapters/create-tech-log-feature-input.ts";
|
||||||
|
import type { CsrfTokenProvider } from "./tech-log/adapters/http/studio-session-csrf.ts";
|
||||||
import { TECH_LOG_FEATURE_ID } from "./tech-log/application/tech-log-feature-input.ts";
|
import { TECH_LOG_FEATURE_ID } from "./tech-log/application/tech-log-feature-input.ts";
|
||||||
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
|
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
|
||||||
|
|
||||||
@@ -20,7 +21,12 @@ type InstalledFeatureInputs = Readonly<
|
|||||||
|
|
||||||
export function createInstalledFeatureInputs(
|
export function createInstalledFeatureInputs(
|
||||||
context: Parameters<typeof createReferenceFeatureInstalledInput>[0] &
|
context: Parameters<typeof createReferenceFeatureInstalledInput>[0] &
|
||||||
Readonly<{ studioSource: "MOCK" | "HTTP" }>,
|
Readonly<{
|
||||||
|
studioSource: "MOCK" | "HTTP";
|
||||||
|
apiBaseUrl: string;
|
||||||
|
requestTimeoutMs: number;
|
||||||
|
csrf: CsrfTokenProvider;
|
||||||
|
}>,
|
||||||
): InstalledFeatureInputs {
|
): InstalledFeatureInputs {
|
||||||
const techLogFeature = createTechLogFeatureInstalledInput(context);
|
const techLogFeature = createTechLogFeatureInstalledInput(context);
|
||||||
if (!INSTALLED_PRODUCT_FEATURE_IDS.includes(REFERENCE_FEATURE_ID)) {
|
if (!INSTALLED_PRODUCT_FEATURE_IDS.includes(REFERENCE_FEATURE_ID)) {
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ import {
|
|||||||
TECH_LOG_FEATURE_ID,
|
TECH_LOG_FEATURE_ID,
|
||||||
type TechLogFeatureInput,
|
type TechLogFeatureInput,
|
||||||
} from "../application/tech-log-feature-input.ts";
|
} from "../application/tech-log-feature-input.ts";
|
||||||
|
import { createAssetUploadTransport } from "./http/asset-upload-transport.ts";
|
||||||
|
import { createHttpStudioAssetGateway } from "./http/http-studio-asset-gateway.ts";
|
||||||
import {
|
import {
|
||||||
createHttpStudioGateway,
|
createHttpStudioGateway,
|
||||||
type StudioOperationExecutor,
|
type StudioOperationExecutor,
|
||||||
} from "./http/http-studio-gateway.ts";
|
} from "./http/http-studio-gateway.ts";
|
||||||
|
import type { CsrfTokenProvider } from "./http/studio-session-csrf.ts";
|
||||||
import { createMockStudioGateway } from "./mock/mock-studio-gateway.ts";
|
import { createMockStudioGateway } from "./mock/mock-studio-gateway.ts";
|
||||||
import { publicContentQueries } from "./static/public-query.ts";
|
import { publicContentQueries } from "./static/public-query.ts";
|
||||||
|
|
||||||
@@ -15,15 +18,35 @@ import { publicContentQueries } from "./static/public-query.ts";
|
|||||||
* platform's `attachCredentials` collaborator at the composition root. This
|
* platform's `attachCredentials` collaborator at the composition root. This
|
||||||
* context only has to say which adapter to construct and hand it the
|
* context only has to say which adapter to construct and hand it the
|
||||||
* composed contract executor.
|
* composed contract executor.
|
||||||
|
*
|
||||||
|
* CSRF has exactly one provider per Studio session, owned by the composition
|
||||||
|
* root — it is shared with the platform's credential collaborator, so it is
|
||||||
|
* threaded in here rather than constructed locally.
|
||||||
*/
|
*/
|
||||||
export type TechLogInstallContext = Readonly<{
|
export type TechLogInstallContext = Readonly<{
|
||||||
studioSource: "MOCK" | "HTTP";
|
studioSource: "MOCK" | "HTTP";
|
||||||
contractOperations: StudioOperationExecutor;
|
contractOperations: StudioOperationExecutor;
|
||||||
|
apiBaseUrl: string;
|
||||||
|
requestTimeoutMs: number;
|
||||||
|
csrf: CsrfTokenProvider;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export function createTechLogFeatureInstalledInput(
|
export function createTechLogFeatureInstalledInput(
|
||||||
context: TechLogInstallContext,
|
context: TechLogInstallContext,
|
||||||
) {
|
) {
|
||||||
|
// The Asset gateway is needed even on MOCK: with no backend behind it, the
|
||||||
|
// list comes back empty and uploads fail with a transport error — the UI
|
||||||
|
// surfacing that state is the correct behavior, not a bug to route around.
|
||||||
|
const createStudioAssetGateway = () =>
|
||||||
|
createHttpStudioAssetGateway({
|
||||||
|
operations: context.contractOperations,
|
||||||
|
csrf: context.csrf,
|
||||||
|
upload: createAssetUploadTransport({
|
||||||
|
baseUrl: context.apiBaseUrl,
|
||||||
|
timeoutMs: context.requestTimeoutMs,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
const createStudioGateway = () =>
|
const createStudioGateway = () =>
|
||||||
context.studioSource === "MOCK"
|
context.studioSource === "MOCK"
|
||||||
? createMockStudioGateway()
|
? createMockStudioGateway()
|
||||||
@@ -32,6 +55,7 @@ export function createTechLogFeatureInstalledInput(
|
|||||||
const input: TechLogFeatureInput = Object.freeze({
|
const input: TechLogFeatureInput = Object.freeze({
|
||||||
publicContent: publicContentQueries,
|
publicContent: publicContentQueries,
|
||||||
createStudioGateway,
|
createStudioGateway,
|
||||||
|
createStudioAssetGateway,
|
||||||
});
|
});
|
||||||
|
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { StudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
|
||||||
|
import type { UploadAssetForm } from "../../application/ports/studio-asset-gateway.ts";
|
||||||
|
import type { Asset, ProblemDetails } from "../../contracts/studio/contract.ts";
|
||||||
|
import type { StudioAssetUploadTransport } from "./http-studio-asset-gateway.ts";
|
||||||
|
import { STUDIO_ERROR_CODES } from "./studio-error-mapping.ts";
|
||||||
|
|
||||||
|
const CODES = new Set<string>(STUDIO_ERROR_CODES);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The contract runtime can only express `requestBody: "NONE" | "JSON"` and the
|
||||||
|
* low-level client always serializes the body as JSON (see
|
||||||
|
* `external-contract-runtime.ts` and `client.ts:719`). The canonical
|
||||||
|
* `POST /assets` is multipart, so this one operation is split into a narrow
|
||||||
|
* seam instead. If upload moves to presigned/resumable transfer, or the
|
||||||
|
* platform grows a MULTIPART mode, only this file is replaced.
|
||||||
|
*/
|
||||||
|
export function createAssetUploadTransport(
|
||||||
|
deps: Readonly<{
|
||||||
|
baseUrl: string;
|
||||||
|
timeoutMs: number;
|
||||||
|
fetch?: typeof globalThis.fetch;
|
||||||
|
}>,
|
||||||
|
): StudioAssetUploadTransport {
|
||||||
|
const doFetch = deps.fetch ?? globalThis.fetch.bind(globalThis);
|
||||||
|
const endpoint = new URL("api/v1/studio/assets", deps.baseUrl).href;
|
||||||
|
|
||||||
|
function unavailable(detail: string): StudioGatewayError {
|
||||||
|
return new StudioGatewayError({
|
||||||
|
type: "https://techlog.local/problems/studio-unavailable",
|
||||||
|
title: "STUDIO_UNAVAILABLE",
|
||||||
|
status: 503,
|
||||||
|
detail,
|
||||||
|
code: "STUDIO_UNAVAILABLE",
|
||||||
|
retryable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
async upload(form: UploadAssetForm, headers, options) {
|
||||||
|
const body = new FormData();
|
||||||
|
body.append("file", form.file, form.file.name);
|
||||||
|
body.append("kind", form.kind);
|
||||||
|
if (form.altText !== undefined) body.append("altText", form.altText);
|
||||||
|
if (form.decorative !== undefined) body.append("decorative", String(form.decorative));
|
||||||
|
|
||||||
|
const timeout = AbortSignal.timeout(deps.timeoutMs);
|
||||||
|
const signal = options?.signal
|
||||||
|
? AbortSignal.any([options.signal, timeout])
|
||||||
|
: timeout;
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
// content-type is never set by hand here. fetch generates the
|
||||||
|
// multipart boundary; setting it manually produces a body the server
|
||||||
|
// cannot parse.
|
||||||
|
response = await doFetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...headers },
|
||||||
|
body,
|
||||||
|
signal,
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw unavailable(
|
||||||
|
error instanceof Error ? `Upload transport failed: ${error.message}` : "Upload transport failed.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 201 || response.status === 200) {
|
||||||
|
return (await response.json()) as Asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
let problem: ProblemDetails | null;
|
||||||
|
try {
|
||||||
|
problem = (await response.json()) as ProblemDetails;
|
||||||
|
} catch {
|
||||||
|
problem = null;
|
||||||
|
}
|
||||||
|
if (problem && typeof problem.code === "string" && CODES.has(problem.code)) {
|
||||||
|
throw new StudioGatewayError(problem);
|
||||||
|
}
|
||||||
|
throw unavailable(`Upload returned an uncontracted status ${response.status}.`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { PublicContentQueries } from "./ports/public-content-queries.ts";
|
import type { PublicContentQueries } from "./ports/public-content-queries.ts";
|
||||||
|
import type { StudioAssetGateway } from "./ports/studio-asset-gateway.ts";
|
||||||
import type { StudioGateway } from "./ports/studio-gateway.ts";
|
import type { StudioGateway } from "./ports/studio-gateway.ts";
|
||||||
|
|
||||||
export const TECH_LOG_FEATURE_ID = "tech-log" as const;
|
export const TECH_LOG_FEATURE_ID = "tech-log" as const;
|
||||||
@@ -6,6 +7,7 @@ export const TECH_LOG_FEATURE_ID = "tech-log" as const;
|
|||||||
export type TechLogFeatureInput = Readonly<{
|
export type TechLogFeatureInput = Readonly<{
|
||||||
publicContent: PublicContentQueries;
|
publicContent: PublicContentQueries;
|
||||||
createStudioGateway(): StudioGateway;
|
createStudioGateway(): StudioGateway;
|
||||||
|
createStudioAssetGateway(): StudioAssetGateway;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
declare module "../../../application/ports/in/application-api.ts" {
|
declare module "../../../application/ports/in/application-api.ts" {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import { isStudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
|
import { isStudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
|
||||||
|
import type { StudioAssetGateway } from "../../application/ports/studio-asset-gateway.ts";
|
||||||
import type { StudioGateway } from "../../application/ports/studio-gateway.ts";
|
import type { StudioGateway } from "../../application/ports/studio-gateway.ts";
|
||||||
import type { ResolvePublishedLabel } from "../../domain/public-render-content.ts";
|
import type { ResolvePublishedLabel } from "../../domain/public-render-content.ts";
|
||||||
import type {
|
import type {
|
||||||
@@ -25,6 +26,9 @@ import { useBeforeUnload } from "./components/use-before-unload.ts";
|
|||||||
type StudioProviderProps = Readonly<{
|
type StudioProviderProps = Readonly<{
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
createGateway: () => StudioGateway;
|
createGateway: () => StudioGateway;
|
||||||
|
// Optional so test harnesses that only exercise the document gateway keep
|
||||||
|
// working unchanged. `StudioShell` always supplies one in the running app.
|
||||||
|
createAssetGateway?: () => StudioAssetGateway;
|
||||||
resolvePublishedLabel?: ResolvePublishedLabel;
|
resolvePublishedLabel?: ResolvePublishedLabel;
|
||||||
now?: () => Date;
|
now?: () => Date;
|
||||||
navigate?: (href: string) => void;
|
navigate?: (href: string) => void;
|
||||||
@@ -48,11 +52,18 @@ const missingPublishedLabel: ResolvePublishedLabel = () => undefined;
|
|||||||
export function StudioProvider({
|
export function StudioProvider({
|
||||||
children,
|
children,
|
||||||
createGateway,
|
createGateway,
|
||||||
|
createAssetGateway,
|
||||||
resolvePublishedLabel = missingPublishedLabel,
|
resolvePublishedLabel = missingPublishedLabel,
|
||||||
now = () => new Date("2026-08-14T01:00:00.000Z"),
|
now = () => new Date("2026-08-14T01:00:00.000Z"),
|
||||||
navigate = defaultNavigate,
|
navigate = defaultNavigate,
|
||||||
}: StudioProviderProps) {
|
}: StudioProviderProps) {
|
||||||
const [gateway] = useState<StudioGateway>(() => createGateway());
|
const [gateway] = useState<StudioGateway>(() => createGateway());
|
||||||
|
// Same lazy, called-once-per-mount pattern as `gateway`. Both are keyed to
|
||||||
|
// the same provider generation in `StudioShell`, so a persisted `pageshow`
|
||||||
|
// remount recreates them together — never one without the other.
|
||||||
|
const [assetGateway] = useState<StudioAssetGateway | null>(
|
||||||
|
() => createAssetGateway?.() ?? null,
|
||||||
|
);
|
||||||
const [editor, setEditor] = useState<StudioEditorState | null>(null);
|
const [editor, setEditor] = useState<StudioEditorState | null>(null);
|
||||||
const [pendingHref, setPendingHref] = useState<string | null>(null);
|
const [pendingHref, setPendingHref] = useState<string | null>(null);
|
||||||
const [requestAnnouncement, setRequestAnnouncement] = useState("");
|
const [requestAnnouncement, setRequestAnnouncement] = useState("");
|
||||||
@@ -148,6 +159,7 @@ export function StudioProvider({
|
|||||||
const value = useMemo<StudioContextValue>(
|
const value = useMemo<StudioContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
gateway,
|
gateway,
|
||||||
|
assetGateway,
|
||||||
resolvePublishedLabel,
|
resolvePublishedLabel,
|
||||||
now,
|
now,
|
||||||
editor,
|
editor,
|
||||||
@@ -160,6 +172,7 @@ export function StudioProvider({
|
|||||||
clearEditor,
|
clearEditor,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
|
assetGateway,
|
||||||
beginEditor,
|
beginEditor,
|
||||||
clearEditor,
|
clearEditor,
|
||||||
editor,
|
editor,
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ export function StudioShell({ children }: StudioShellProps) {
|
|||||||
() => application.features.get(TECH_LOG_FEATURE_ID).createStudioGateway(),
|
() => application.features.get(TECH_LOG_FEATURE_ID).createStudioGateway(),
|
||||||
[application],
|
[application],
|
||||||
);
|
);
|
||||||
|
const createAssetGateway = useCallback(
|
||||||
|
() => application.features.get(TECH_LOG_FEATURE_ID).createStudioAssetGateway(),
|
||||||
|
[application],
|
||||||
|
);
|
||||||
const resolvePublishedLabel = useCallback(
|
const resolvePublishedLabel = useCallback(
|
||||||
(path: string) =>
|
(path: string) =>
|
||||||
application.features
|
application.features
|
||||||
@@ -69,6 +73,7 @@ export function StudioShell({ children }: StudioShellProps) {
|
|||||||
<StudioProvider
|
<StudioProvider
|
||||||
key={generation}
|
key={generation}
|
||||||
createGateway={createGateway}
|
createGateway={createGateway}
|
||||||
|
createAssetGateway={createAssetGateway}
|
||||||
resolvePublishedLabel={resolvePublishedLabel}
|
resolvePublishedLabel={resolvePublishedLabel}
|
||||||
navigate={navigateInternal}
|
navigate={navigateInternal}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createContext, useContext, useMemo } from "react";
|
import { createContext, useContext, useMemo } from "react";
|
||||||
|
|
||||||
|
import type { StudioAssetGateway } from "../../application/ports/studio-asset-gateway.ts";
|
||||||
import type { StudioGateway } from "../../application/ports/studio-gateway.ts";
|
import type { StudioGateway } from "../../application/ports/studio-gateway.ts";
|
||||||
import type { ResolvePublishedLabel } from "../../domain/public-render-content.ts";
|
import type { ResolvePublishedLabel } from "../../domain/public-render-content.ts";
|
||||||
import type {
|
import type {
|
||||||
@@ -18,6 +19,10 @@ export type StudioEditorState = Readonly<{
|
|||||||
|
|
||||||
export type StudioContextValue = Readonly<{
|
export type StudioContextValue = Readonly<{
|
||||||
gateway: StudioGateway;
|
gateway: StudioGateway;
|
||||||
|
// `null` only in test harnesses that render `StudioProvider` without an
|
||||||
|
// `createAssetGateway` prop. `StudioShell` — the real app path — always
|
||||||
|
// supplies one, so production code sees this populated.
|
||||||
|
assetGateway: StudioAssetGateway | null;
|
||||||
resolvePublishedLabel: ResolvePublishedLabel;
|
resolvePublishedLabel: ResolvePublishedLabel;
|
||||||
now(): Date;
|
now(): Date;
|
||||||
editor: StudioEditorState | null;
|
editor: StudioEditorState | null;
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { afterAll, afterEach, beforeAll, test } from "vitest";
|
||||||
|
import { http, HttpResponse } from "msw";
|
||||||
|
import { setupServer } from "msw/node";
|
||||||
|
|
||||||
|
import { createAssetUploadTransport } from "../../../src/features/tech-log/adapters/http/asset-upload-transport.ts";
|
||||||
|
import { isStudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts";
|
||||||
|
|
||||||
|
const BASE = "http://api.test";
|
||||||
|
const server = setupServer();
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
||||||
|
afterEach(() => server.resetHandlers());
|
||||||
|
afterAll(() => server.close());
|
||||||
|
|
||||||
|
const transport = () =>
|
||||||
|
createAssetUploadTransport({ baseUrl: `${BASE}/`, timeoutMs: 10_000 });
|
||||||
|
|
||||||
|
const svg = () => new File(["<svg/>"], "b.svg", { type: "image/svg+xml" });
|
||||||
|
|
||||||
|
test("posts multipart form data with the supplied headers", async () => {
|
||||||
|
let seen: { kind: unknown; alt: unknown; csrf: string | null; key: string | null } | null = null;
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.post(`${BASE}/api/v1/studio/assets`, async ({ request }) => {
|
||||||
|
const form = await request.formData();
|
||||||
|
seen = {
|
||||||
|
kind: form.get("kind"),
|
||||||
|
alt: form.get("altText"),
|
||||||
|
csrf: request.headers.get("X-CSRF-TOKEN"),
|
||||||
|
key: request.headers.get("Idempotency-Key"),
|
||||||
|
};
|
||||||
|
return HttpResponse.json({ id: "a", managementStatus: "READY" }, { status: 201 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const asset = await transport().upload(
|
||||||
|
{ file: svg(), kind: "DIAGRAM", altText: "경계 다이어그램", decorative: false },
|
||||||
|
{ "X-CSRF-TOKEN": "csrf", "Idempotency-Key": "up-1" },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal((asset as { id: string }).id, "a");
|
||||||
|
assert.equal(seen!.kind, "DIAGRAM");
|
||||||
|
assert.equal(seen!.alt, "경계 다이어그램");
|
||||||
|
assert.equal(seen!.csrf, "csrf");
|
||||||
|
assert.equal(seen!.key, "up-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not set content-type itself so the boundary survives", async () => {
|
||||||
|
let contentType: string | null = "unset";
|
||||||
|
server.use(
|
||||||
|
http.post(`${BASE}/api/v1/studio/assets`, ({ request }) => {
|
||||||
|
contentType = request.headers.get("content-type");
|
||||||
|
return HttpResponse.json({ id: "a" }, { status: 201 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await transport().upload({ file: svg(), kind: "IMAGE" }, {});
|
||||||
|
|
||||||
|
assert.ok(contentType?.startsWith("multipart/form-data; boundary="));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maps 413 onto PAYLOAD_TOO_LARGE", async () => {
|
||||||
|
server.use(
|
||||||
|
http.post(`${BASE}/api/v1/studio/assets`, () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
{
|
||||||
|
type: "https://techlog.local/problems/payload-too-large",
|
||||||
|
title: "PAYLOAD_TOO_LARGE",
|
||||||
|
status: 413,
|
||||||
|
detail: "파일이 너무 큽니다.",
|
||||||
|
code: "PAYLOAD_TOO_LARGE",
|
||||||
|
},
|
||||||
|
{ status: 413, headers: { "content-type": "application/problem+json" } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
transport().upload({ file: svg(), kind: "IMAGE" }, {}),
|
||||||
|
(error: unknown) => {
|
||||||
|
assert.ok(isStudioGatewayError(error));
|
||||||
|
assert.equal(error.code, "PAYLOAD_TOO_LARGE");
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maps 415 onto UNSUPPORTED_MEDIA_TYPE", async () => {
|
||||||
|
server.use(
|
||||||
|
http.post(`${BASE}/api/v1/studio/assets`, () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
{
|
||||||
|
type: "https://techlog.local/problems/unsupported-media-type",
|
||||||
|
title: "UNSUPPORTED_MEDIA_TYPE",
|
||||||
|
status: 415,
|
||||||
|
detail: "지원하지 않는 형식입니다.",
|
||||||
|
code: "UNSUPPORTED_MEDIA_TYPE",
|
||||||
|
},
|
||||||
|
{ status: 415, headers: { "content-type": "application/problem+json" } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(transport().upload({ file: svg(), kind: "IMAGE" }, {}), (error: unknown) => {
|
||||||
|
assert.ok(isStudioGatewayError(error));
|
||||||
|
assert.equal(error.code, "UNSUPPORTED_MEDIA_TYPE");
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maps a network failure onto STUDIO_UNAVAILABLE", async () => {
|
||||||
|
server.use(http.post(`${BASE}/api/v1/studio/assets`, () => HttpResponse.error()));
|
||||||
|
|
||||||
|
await assert.rejects(transport().upload({ file: svg(), kind: "IMAGE" }, {}), (error: unknown) => {
|
||||||
|
assert.ok(isStudioGatewayError(error));
|
||||||
|
assert.equal(error.code, "STUDIO_UNAVAILABLE");
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,7 +14,10 @@ type Equal<Left, Right> =
|
|||||||
type Assert<Condition extends true> = Condition;
|
type Assert<Condition extends true> = Condition;
|
||||||
|
|
||||||
type TechLogFeatureInputExposesNoMissingOrAdditionalKeys = Assert<
|
type TechLogFeatureInputExposesNoMissingOrAdditionalKeys = Assert<
|
||||||
Equal<keyof ApplicationFeatureInputs["tech-log"], "publicContent" | "createStudioGateway">
|
Equal<
|
||||||
|
keyof ApplicationFeatureInputs["tech-log"],
|
||||||
|
"publicContent" | "createStudioGateway" | "createStudioAssetGateway"
|
||||||
|
>
|
||||||
>;
|
>;
|
||||||
type TechLogFeatureInputRegistryValueMatchesFeatureContract = Assert<
|
type TechLogFeatureInputRegistryValueMatchesFeatureContract = Assert<
|
||||||
Equal<ApplicationFeatureInputs["tech-log"], TechLogFeatureInput>
|
Equal<ApplicationFeatureInputs["tech-log"], TechLogFeatureInput>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { test } from "vitest";
|
|||||||
import type { ApplicationFeatureInputs } from "../../../src/application/ports/in/application-api.ts";
|
import type { ApplicationFeatureInputs } from "../../../src/application/ports/in/application-api.ts";
|
||||||
import { createInstalledFeatureInputs } from "../../../src/features/installed-feature-adapters.ts";
|
import { createInstalledFeatureInputs } from "../../../src/features/installed-feature-adapters.ts";
|
||||||
import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts";
|
import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts";
|
||||||
|
import { createCsrfTokenProvider } from "../../../src/features/tech-log/adapters/http/studio-session-csrf.ts";
|
||||||
import type { TechLogFeatureInput } from "../../../src/features/tech-log/application/tech-log-feature-input.ts";
|
import type { TechLogFeatureInput } from "../../../src/features/tech-log/application/tech-log-feature-input.ts";
|
||||||
import type { WorkingCopy } from "../../../src/features/tech-log/contracts/studio/contract.ts";
|
import type { WorkingCopy } from "../../../src/features/tech-log/contracts/studio/contract.ts";
|
||||||
|
|
||||||
@@ -42,6 +43,13 @@ function installedInputs(
|
|||||||
throw new Error("reference executor is not used by composition tests");
|
throw new Error("reference executor is not used by composition tests");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
apiBaseUrl: "http://composition.test/",
|
||||||
|
requestTimeoutMs: 10_000,
|
||||||
|
csrf: createCsrfTokenProvider({
|
||||||
|
async execute() {
|
||||||
|
throw new Error("CSRF provider is not used by composition tests");
|
||||||
|
},
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +58,7 @@ test("installs TechLog beside the retained reference feature through application
|
|||||||
|
|
||||||
assert.deepEqual(Object.keys(installed), ["reference-feature", "tech-log"]);
|
assert.deepEqual(Object.keys(installed), ["reference-feature", "tech-log"]);
|
||||||
assert.deepEqual(Object.keys(installed["tech-log"]).sort(), [
|
assert.deepEqual(Object.keys(installed["tech-log"]).sort(), [
|
||||||
|
"createStudioAssetGateway",
|
||||||
"createStudioGateway",
|
"createStudioGateway",
|
||||||
"publicContent",
|
"publicContent",
|
||||||
]);
|
]);
|
||||||
@@ -107,3 +116,11 @@ test("each createStudioGateway call owns an isolated mutable Studio session", as
|
|||||||
"컬렉션 Fetch Join과 페이징은 왜 충돌하는가",
|
"컬렉션 Fetch Join과 페이징은 왜 충돌하는가",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("each Studio session gets its own asset gateway instance", () => {
|
||||||
|
const installed = installedInputs("HTTP");
|
||||||
|
assert.notEqual(
|
||||||
|
installed["tech-log"].createStudioAssetGateway(),
|
||||||
|
installed["tech-log"].createStudioAssetGateway(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -67,6 +67,33 @@ test("lists assets through the canonical operation", async () => {
|
|||||||
assert.equal(calls[0]!.operationId, "listStudioAssets");
|
assert.equal(calls[0]!.operationId, "listStudioAssets");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("gets a single asset detail through the canonical operation", async () => {
|
||||||
|
const detail = {
|
||||||
|
asset: READY_ASSET,
|
||||||
|
usages: [
|
||||||
|
{
|
||||||
|
documentId: "22222222-2222-4222-8222-222222222222",
|
||||||
|
documentTitle: "Fetch Join과 Batch Fetch 비교",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
hasPublicationHistory: true,
|
||||||
|
};
|
||||||
|
const { calls, dependencies } = deps({
|
||||||
|
getStudioAsset: {
|
||||||
|
kind: "SUCCESS",
|
||||||
|
value: detail,
|
||||||
|
effect: "APPLIED_CONFIRMED",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const gateway = createHttpStudioAssetGateway(dependencies as never);
|
||||||
|
|
||||||
|
const result = await gateway.getAsset(READY_ASSET.id);
|
||||||
|
|
||||||
|
assert.equal(calls[0]!.operationId, "getStudioAsset");
|
||||||
|
assert.deepEqual(calls[0]!.input, { assetId: READY_ASSET.id });
|
||||||
|
assert.deepEqual(result, detail);
|
||||||
|
});
|
||||||
|
|
||||||
test("delegates upload to the transport with CSRF and idempotency headers", async () => {
|
test("delegates upload to the transport with CSRF and idempotency headers", async () => {
|
||||||
let received: Record<string, string> = {};
|
let received: Record<string, string> = {};
|
||||||
const { dependencies } = deps({});
|
const { dependencies } = deps({});
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
|
import { createCsrfTokenProvider } from "../../src/features/tech-log/adapters/http/studio-session-csrf.ts";
|
||||||
import type { TechLogInstallContext } from "../../src/features/tech-log/adapters/create-tech-log-feature-input.ts";
|
import type { TechLogInstallContext } from "../../src/features/tech-log/adapters/create-tech-log-feature-input.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The install context the existing Studio test suite composes against. It
|
* The install context the existing Studio test suite composes against. It
|
||||||
* always selects the mock adapter, so `contractOperations` is a throwing stub
|
* always selects the mock adapter, so `contractOperations` is a throwing stub
|
||||||
* — `createTechLogFeatureInstalledInput` never reads it on the MOCK branch.
|
* — `createTechLogFeatureInstalledInput` never reads it on the MOCK branch.
|
||||||
|
*
|
||||||
|
* `createStudioAssetGateway` is unconditional (Task 7), so `apiBaseUrl`,
|
||||||
|
* `requestTimeoutMs` and `csrf` must still be well-formed even here: building
|
||||||
|
* the gateway constructs the upload transport eagerly. No existing test
|
||||||
|
* exercises the asset gateway's operations, so `contractOperations` and
|
||||||
|
* `csrf` stay throwing stubs — the same "never actually used" contract as
|
||||||
|
* before.
|
||||||
*/
|
*/
|
||||||
export const MOCK_STUDIO_INSTALL_CONTEXT: TechLogInstallContext = Object.freeze({
|
export const MOCK_STUDIO_INSTALL_CONTEXT: TechLogInstallContext = Object.freeze({
|
||||||
studioSource: "MOCK",
|
studioSource: "MOCK",
|
||||||
@@ -12,4 +20,11 @@ export const MOCK_STUDIO_INSTALL_CONTEXT: TechLogInstallContext = Object.freeze(
|
|||||||
throw new Error("contract executor is not used by the mock Studio gateway");
|
throw new Error("contract executor is not used by the mock Studio gateway");
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
apiBaseUrl: "http://mock-studio.test/",
|
||||||
|
requestTimeoutMs: 10_000,
|
||||||
|
csrf: createCsrfTokenProvider({
|
||||||
|
async execute() {
|
||||||
|
throw new Error("CSRF provider is not used by the mock Studio gateway");
|
||||||
|
},
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user