118 lines
3.6 KiB
TypeScript
118 lines
3.6 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
createFeatureHttpBinding,
|
|
defineFeatureHttpOperation,
|
|
type InstalledHttpOperationExecutor,
|
|
} from "../../../src/adapters/http/index.ts";
|
|
import {
|
|
mappingFailure,
|
|
mappingSuccess,
|
|
} from "../../../src/contracts/boundary-mapper.ts";
|
|
|
|
type Resource = Readonly<{ id: string; title: string }>;
|
|
|
|
const OPERATIONS = Object.freeze({
|
|
LOAD_RESOURCE: defineFeatureHttpOperation<
|
|
Readonly<{ resourceId: string }>,
|
|
Resource
|
|
>({
|
|
operationId: "LOAD_RESOURCE",
|
|
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"
|
|
) {
|
|
return mappingFailure("MAPPING_INVARIANT_REJECTED");
|
|
}
|
|
const candidate = value as Readonly<{ id: string; title: string }>;
|
|
return mappingSuccess(
|
|
Object.freeze({ id: candidate.id, title: candidate.title }),
|
|
);
|
|
},
|
|
}),
|
|
} as const);
|
|
|
|
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,
|
|
);
|
|
|
|
const result = await binding.execute("LOAD_RESOURCE", {
|
|
resourceId: "resource-1",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
value: { id: "resource-1", title: "Reference" },
|
|
});
|
|
expect(execute).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
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 result = await binding.execute("LOAD_RESOURCE", {
|
|
resourceId: "resource-1",
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.error.kind).toBe("REQUEST_TIMEOUT");
|
|
expect(result.error.operationId).toBe("LOAD_RESOURCE");
|
|
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);
|
|
|
|
const result = await binding.execute("LOAD_RESOURCE", {
|
|
resourceId: "resource-1",
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.error.kind).toBe("MAPPING_CONTRACT_VIOLATION");
|
|
expect(result.error.code).toBe("MAPPING_INVARIANT_REJECTED");
|
|
});
|
|
});
|