refactor: 프론트엔드 리펙토링
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type {
|
||||
IndexedDbRepositoryPort,
|
||||
} from "../../../src/application/ports/browser-file-storage/index.ts";
|
||||
import type {
|
||||
InstalledHttpOperationExecutor,
|
||||
} from "../../../src/adapters/http/index.ts";
|
||||
import {
|
||||
composeFeatureAdapterInputs,
|
||||
defineFeatureAdapterContribution,
|
||||
type IndexedDbRepositoryProvider,
|
||||
} from "../../../src/features/feature-adapter-contribution.ts";
|
||||
import {
|
||||
LOCAL_DRAFT_FEATURE_ADAPTER_CONTRIBUTION,
|
||||
} from "../../../src/features/local-draft-feature/adapters/create-local-draft-feature-input.ts";
|
||||
import type { LocalDraft } from "../../../src/features/local-draft-feature/domain/local-draft.ts";
|
||||
|
||||
type TestHttpFeatureInput = Readonly<{
|
||||
ping(): Promise<"pong">;
|
||||
}>;
|
||||
|
||||
declare module "../../../src/application/ports/in/application-api.ts" {
|
||||
interface ApplicationFeatureInputs {
|
||||
"test-http-feature": TestHttpFeatureInput;
|
||||
}
|
||||
}
|
||||
|
||||
const TEST_HTTP_FEATURE_ADAPTER_CONTRIBUTION =
|
||||
defineFeatureAdapterContribution({
|
||||
featureId: "test-http-feature",
|
||||
needs: ["http"] as const,
|
||||
createInput() {
|
||||
return Object.freeze({
|
||||
featureId: "test-http-feature" as const,
|
||||
input: Object.freeze({
|
||||
async ping() {
|
||||
return "pong" as const;
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function localDraftRepository() {
|
||||
let stored: Readonly<{ value: LocalDraft; revision: number }> | null = null;
|
||||
const repository = Object.freeze({
|
||||
async compareAndSwap(input: Readonly<{ value: LocalDraft }>) {
|
||||
stored = Object.freeze({ value: input.value, revision: 1 });
|
||||
return Object.freeze({
|
||||
ok: true as const,
|
||||
value: Object.freeze({ revision: 1 }),
|
||||
});
|
||||
},
|
||||
async read() {
|
||||
return Object.freeze({ ok: true as const, value: stored });
|
||||
},
|
||||
async remove() {
|
||||
stored = null;
|
||||
return Object.freeze({
|
||||
ok: true as const,
|
||||
value: Object.freeze({ revision: 2 }),
|
||||
});
|
||||
},
|
||||
}) as unknown as IndexedDbRepositoryPort<LocalDraft, never>;
|
||||
return repository;
|
||||
}
|
||||
|
||||
describe("feature adapter composition", () => {
|
||||
it("composes HTTP-only and IndexedDB-only concrete contributions together", async () => {
|
||||
const repository = localDraftRepository();
|
||||
const get = ((repositoryId: string) => {
|
||||
expect(repositoryId).toBe("local-draft");
|
||||
return repository;
|
||||
}) as unknown as IndexedDbRepositoryProvider["get"];
|
||||
|
||||
const indexedDb = Object.freeze({ get }) satisfies IndexedDbRepositoryProvider;
|
||||
const http = Object.freeze({
|
||||
execute: async () => {
|
||||
throw new Error("HTTP should not execute during composition");
|
||||
},
|
||||
}) as unknown as InstalledHttpOperationExecutor;
|
||||
|
||||
const inputs = composeFeatureAdapterInputs(
|
||||
Object.freeze([
|
||||
TEST_HTTP_FEATURE_ADAPTER_CONTRIBUTION,
|
||||
LOCAL_DRAFT_FEATURE_ADAPTER_CONTRIBUTION,
|
||||
] as const),
|
||||
Object.freeze(["test-http-feature", "local-draft"]),
|
||||
Object.freeze({ http, indexedDb }),
|
||||
);
|
||||
|
||||
expect(Object.keys(inputs).sort()).toEqual([
|
||||
"local-draft",
|
||||
"test-http-feature",
|
||||
]);
|
||||
|
||||
await expect(inputs["test-http-feature"]?.ping()).resolves.toBe("pong");
|
||||
|
||||
const localDraft = inputs["local-draft"];
|
||||
if (!localDraft) throw new Error("local-draft input was not composed");
|
||||
|
||||
const draft = Object.freeze({
|
||||
draftId: "draft-1",
|
||||
title: "Local draft",
|
||||
body: "Body",
|
||||
});
|
||||
|
||||
await expect(
|
||||
localDraft.saveDraft({
|
||||
draft,
|
||||
expectedRevision: null,
|
||||
idempotencyKey: "save-draft-1",
|
||||
}),
|
||||
).resolves.toEqual({ ok: true, value: { revision: 1 } });
|
||||
|
||||
await expect(localDraft.findDraft("draft-1")).resolves.toEqual({
|
||||
ok: true,
|
||||
value: { draft, revision: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,57 +3,129 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createFeatureHttpBinding,
|
||||
defineFeatureHttpOperation,
|
||||
type HttpExecutionOutcome,
|
||||
type InstalledHttpOperationExecutor,
|
||||
} from "../../../src/adapters/http/index.ts";
|
||||
import {
|
||||
mappingFailure,
|
||||
mappingSuccess,
|
||||
} from "../../../src/contracts/boundary-mapper.ts";
|
||||
import type {
|
||||
InstalledHttpContract,
|
||||
RuntimeValidator,
|
||||
} from "../../../src/contracts/external-contract-runtime.ts";
|
||||
|
||||
type ResourceInput = Readonly<{ resourceId: string }>;
|
||||
type ResourceWire = Readonly<{ id: string; title: string }>;
|
||||
type ResourceProblem = Readonly<{ code?: string }>;
|
||||
type Resource = Readonly<{ id: string; title: string }>;
|
||||
|
||||
const OPERATIONS = Object.freeze({
|
||||
LOAD_RESOURCE: defineFeatureHttpOperation<
|
||||
Readonly<{ resourceId: string }>,
|
||||
Resource
|
||||
>({
|
||||
function validator<T>(schemaId: string): RuntimeValidator<T> {
|
||||
return Object.freeze({
|
||||
schemaId,
|
||||
safeParse(value: unknown) {
|
||||
return Object.freeze({
|
||||
success: true as const,
|
||||
data: value as T,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const LOAD_RESOURCE_CONTRACT = Object.freeze({
|
||||
contract: Object.freeze({
|
||||
operationId: "LOAD_RESOURCE",
|
||||
method: "GET" as const,
|
||||
pathTemplate: "/resources/{resourceId}",
|
||||
inputValidator: validator<ResourceInput>("ResourceInput"),
|
||||
outputValidator: validator<ResourceWire>("ResourceWire"),
|
||||
problemValidator: validator<ResourceProblem>("ResourceProblem"),
|
||||
acceptedStatuses: Object.freeze([200]),
|
||||
emptyBodyStatuses: Object.freeze([]),
|
||||
retrySemantics: "SAFE" as const,
|
||||
requestBody: "NONE" as const,
|
||||
responseBody: "REQUIRED_JSON" as const,
|
||||
commandRecovery: null,
|
||||
commandEffect: null,
|
||||
projectRequest(input: ResourceInput) {
|
||||
return Object.freeze({
|
||||
pathValues: Object.freeze({ resourceId: input.resourceId }),
|
||||
queryEntries: Object.freeze([]),
|
||||
body: null,
|
||||
});
|
||||
},
|
||||
}),
|
||||
frontend: Object.freeze({
|
||||
policyId: "TEST_LOAD_RESOURCE",
|
||||
requestByteLimit: 0,
|
||||
responseByteLimit: 8_192,
|
||||
totalDeadlineMs: 1_000,
|
||||
retryBudget: 0 as const,
|
||||
authProfileId: "TEST",
|
||||
diagnosticsOperation: "test.load-resource",
|
||||
}),
|
||||
}) satisfies InstalledHttpContract<ResourceInput, ResourceWire, ResourceProblem>;
|
||||
|
||||
const OPERATIONS = Object.freeze({
|
||||
LOAD_RESOURCE: defineFeatureHttpOperation({
|
||||
contract: LOAD_RESOURCE_CONTRACT,
|
||||
routeId: "RESOURCE_DETAIL",
|
||||
mapSuccess(value) {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== "object" ||
|
||||
typeof (value as Record<string, unknown>).id !== "string" ||
|
||||
typeof (value as Record<string, unknown>).title !== "string"
|
||||
) {
|
||||
if (value.title.length === 0) {
|
||||
return mappingFailure("MAPPING_INVARIANT_REJECTED");
|
||||
}
|
||||
const candidate = value as Readonly<{ id: string; title: string }>;
|
||||
return mappingSuccess(
|
||||
Object.freeze({ id: candidate.id, title: candidate.title }),
|
||||
Object.freeze({ id: value.id, title: value.title }),
|
||||
);
|
||||
},
|
||||
mapProblem(problem, metadata) {
|
||||
if (metadata.status !== 409) return undefined;
|
||||
return Object.freeze({
|
||||
kind: "CONFLICT" as const,
|
||||
code: problem.code ?? "RESOURCE_CONFLICT",
|
||||
});
|
||||
},
|
||||
}),
|
||||
} as const);
|
||||
|
||||
function scriptedExecutor<WireOutput, Problem>(
|
||||
outcome: HttpExecutionOutcome<WireOutput, Problem>,
|
||||
) {
|
||||
const calls: Array<
|
||||
Readonly<{
|
||||
contract: unknown;
|
||||
input: unknown;
|
||||
context: unknown;
|
||||
}>
|
||||
> = [];
|
||||
const implementation = async (
|
||||
contract: unknown,
|
||||
input: unknown,
|
||||
context: unknown,
|
||||
) => {
|
||||
calls.push(Object.freeze({ contract, input, context }));
|
||||
return outcome;
|
||||
};
|
||||
const execute = vi.fn(implementation) as unknown as
|
||||
InstalledHttpOperationExecutor["execute"];
|
||||
return {
|
||||
executor: Object.freeze({ execute }) satisfies InstalledHttpOperationExecutor,
|
||||
calls,
|
||||
execute,
|
||||
};
|
||||
}
|
||||
|
||||
describe("feature HTTP binding", () => {
|
||||
it("keeps typed feature input while platform owns route/context execution", async () => {
|
||||
const execute = vi.fn<InstalledHttpOperationExecutor["execute"]>(
|
||||
async (_operationId, input, context) => {
|
||||
expect(input).toEqual({ resourceId: "resource-1" });
|
||||
expect(context.routeId).toBe("RESOURCE_DETAIL");
|
||||
return Object.freeze({
|
||||
kind: "SUCCESS" as const,
|
||||
value: Object.freeze({ id: "resource-1", title: "Reference" }),
|
||||
metadata: Object.freeze({ status: 200 }),
|
||||
effect: "NOT_APPLICABLE" as const,
|
||||
});
|
||||
},
|
||||
);
|
||||
const binding = createFeatureHttpBinding(
|
||||
Object.freeze({ execute }),
|
||||
OPERATIONS,
|
||||
it("keeps contract-derived input while platform owns route/context execution", async () => {
|
||||
const scripted = scriptedExecutor<ResourceWire, ResourceProblem>(
|
||||
Object.freeze({
|
||||
kind: "SUCCESS" as const,
|
||||
value: Object.freeze({ id: "resource-1", title: "Reference" }),
|
||||
metadata: Object.freeze({ status: 200 }),
|
||||
effect: "NOT_APPLICABLE" as const,
|
||||
}),
|
||||
);
|
||||
const binding = createFeatureHttpBinding(scripted.executor, OPERATIONS);
|
||||
|
||||
const result = await binding.execute("LOAD_RESOURCE", {
|
||||
resourceId: "resource-1",
|
||||
@@ -63,23 +135,27 @@ describe("feature HTTP binding", () => {
|
||||
ok: true,
|
||||
value: { id: "resource-1", title: "Reference" },
|
||||
});
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
expect(scripted.calls).toEqual([
|
||||
{
|
||||
contract: LOAD_RESOURCE_CONTRACT,
|
||||
input: { resourceId: "resource-1" },
|
||||
context: { routeId: "RESOURCE_DETAIL" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes transport failure before it crosses the feature gateway", async () => {
|
||||
const executor: InstalledHttpOperationExecutor = Object.freeze({
|
||||
async execute() {
|
||||
return Object.freeze({
|
||||
kind: "TRANSPORT_FAILURE" as const,
|
||||
failure: Object.freeze({
|
||||
kind: "TIMEOUT" as const,
|
||||
retryable: true,
|
||||
}),
|
||||
effect: "NOT_STARTED" as const,
|
||||
});
|
||||
},
|
||||
});
|
||||
const binding = createFeatureHttpBinding(executor, OPERATIONS);
|
||||
const scripted = scriptedExecutor<ResourceWire, ResourceProblem>(
|
||||
Object.freeze({
|
||||
kind: "TRANSPORT_FAILURE" as const,
|
||||
failure: Object.freeze({
|
||||
kind: "TIMEOUT" as const,
|
||||
retryable: true,
|
||||
}),
|
||||
effect: "NOT_STARTED" as const,
|
||||
}),
|
||||
);
|
||||
const binding = createFeatureHttpBinding(scripted.executor, OPERATIONS);
|
||||
|
||||
const result = await binding.execute("LOAD_RESOURCE", {
|
||||
resourceId: "resource-1",
|
||||
@@ -92,18 +168,16 @@ describe("feature HTTP binding", () => {
|
||||
expect(result.error.effect).toBe("NOT_STARTED");
|
||||
});
|
||||
|
||||
it("turns feature mapper rejection into the shared mapping failure", async () => {
|
||||
const executor: InstalledHttpOperationExecutor = Object.freeze({
|
||||
async execute() {
|
||||
return Object.freeze({
|
||||
kind: "SUCCESS" as const,
|
||||
value: Object.freeze({ unexpected: true }),
|
||||
metadata: Object.freeze({ status: 200 }),
|
||||
effect: "NOT_APPLICABLE" as const,
|
||||
});
|
||||
},
|
||||
});
|
||||
const binding = createFeatureHttpBinding(executor, OPERATIONS);
|
||||
it("turns a domain mapper rejection into the shared mapping failure", async () => {
|
||||
const scripted = scriptedExecutor<ResourceWire, ResourceProblem>(
|
||||
Object.freeze({
|
||||
kind: "SUCCESS" as const,
|
||||
value: Object.freeze({ id: "resource-1", title: "" }),
|
||||
metadata: Object.freeze({ status: 200 }),
|
||||
effect: "NOT_APPLICABLE" as const,
|
||||
}),
|
||||
);
|
||||
const binding = createFeatureHttpBinding(scripted.executor, OPERATIONS);
|
||||
|
||||
const result = await binding.execute("LOAD_RESOURCE", {
|
||||
resourceId: "resource-1",
|
||||
@@ -114,4 +188,29 @@ describe("feature HTTP binding", () => {
|
||||
expect(result.error.kind).toBe("MAPPING_CONTRACT_VIOLATION");
|
||||
expect(result.error.code).toBe("MAPPING_INVARIANT_REJECTED");
|
||||
});
|
||||
|
||||
it("lets the feature interpret a typed business problem", async () => {
|
||||
const scripted = scriptedExecutor<ResourceWire, ResourceProblem>(
|
||||
Object.freeze({
|
||||
kind: "PROBLEM" as const,
|
||||
problem: Object.freeze({ code: "RESOURCE_NAME_EXISTS" }),
|
||||
metadata: Object.freeze({ status: 409 }),
|
||||
effect: "NOT_APPLIED" as const,
|
||||
}),
|
||||
);
|
||||
const binding = createFeatureHttpBinding(scripted.executor, OPERATIONS);
|
||||
|
||||
const result = await binding.execute("LOAD_RESOURCE", {
|
||||
resourceId: "resource-1",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "CONFLICT",
|
||||
code: "RESOURCE_NAME_EXISTS",
|
||||
httpStatus: 409,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user