feat: add removable reference feature vertical slice

This commit is contained in:
donghyeon-ka
2026-07-26 14:56:34 +09:00
parent 980981bc86
commit c11be43f20
87 changed files with 1881 additions and 1114 deletions
+8 -3
View File
@@ -3,12 +3,17 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DesignTokenShowcase } from "../../src/sample/contract-fixture/design-token-showcase.jsx";
import { Button } from "../../src/presentation/components/ui/button.jsx";
import { Card } from "../../src/presentation/components/ui/card.jsx";
describe("design-token fixture", () => {
it("uses static semantic primitive classes", () => {
render(<DesignTokenShowcase />);
expect(screen.getByRole("region")).toHaveClass("ui-panel");
render(
<Card title="Design token fixture">
<Button>Token action</Button>
</Card>,
);
expect(screen.getByRole("article")).toHaveClass("ui-card");
expect(screen.getByRole("button")).toHaveClass("ui-button");
});
});
+11 -74
View File
@@ -2,34 +2,29 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import {
createAnonymousSessionAdapter,
createDemoSessionAdapter,
} from "../../src/adapters/auth/external-session-adapter.js";
import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.js";
import { ApplicationProvider } from "../../src/presentation/providers/application-provider.js";
import { AppRouter } from "../../src/presentation/routes/app-router.jsx";
import { createTestApplication } from "../helpers/create-test-application.js";
/**
* @param {import("../../src/application/ports/auth-session-port.js").AuthSessionPort} session
* @param {Parameters<typeof createTestApplication>[0]} [overrides]
*/
function renderRouter(session, overrides = {}) {
function renderRouter() {
return render(
<ApplicationProvider
application={createTestApplication({ ...overrides, session })}
application={createTestApplication({
session: createAnonymousSessionAdapter(),
})}
>
<AppRouter />
</ApplicationProvider>,
);
}
describe("application router", () => {
it("renders the app shell and not-found route without an API request", async () => {
describe("generic application router", () => {
it("renders the app shell and not-found route without a feature input", async () => {
window.history.pushState({}, "", "/missing");
renderRouter(createAnonymousSessionAdapter());
renderRouter();
expect(
await screen.findByRole("heading", {
@@ -40,10 +35,10 @@ describe("application router", () => {
expect(screen.getByRole("main")).toBeVisible();
});
it("navigates between registry-backed example routes", async () => {
it("navigates between registry-backed platform routes", async () => {
const user = userEvent.setup();
window.history.pushState({}, "", "/");
renderRouter(createAnonymousSessionAdapter());
renderRouter();
await user.click(
await screen.findByRole("link", { name: "UI 구성요소" }),
@@ -58,62 +53,4 @@ describe("application router", () => {
screen.getByRole("heading", { name: "UI 구성요소", level: 1 }),
).toHaveFocus();
});
it("reacts to demo sign-in and opens the protected integration route", async () => {
const user = userEvent.setup();
const authSession = createDemoSessionAdapter();
window.history.pushState({}, "", "/sample/resources");
renderRouter(authSession);
expect(
await screen.findByRole("heading", { name: "세션이 필요합니다." }),
).toBeVisible();
await user.click(screen.getByRole("button", { name: "로그인 시작" }));
expect(
await screen.findByRole("heading", { name: "보호된 연동 지점" }),
).toBeVisible();
expect(screen.getByText("인증됨")).toBeVisible();
});
it("fails closed when the auth integration does not change state", async () => {
const user = userEvent.setup();
window.history.pushState({}, "", "/sample/resources");
renderRouter(createAnonymousSessionAdapter());
await user.click(
await screen.findByRole("button", { name: "로그인 시작" }),
);
expect(
screen.getByRole("heading", { name: "세션이 필요합니다." }),
).toBeVisible();
});
it("rejects invalid route search before any application query runs", async () => {
const getCurrent = vi.fn(async () => ({
buildId: "test-build",
releaseId: "test-release",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "test-hash",
routeChunks: { "route-sample-resources": "assets/sample.js" },
}));
window.history.pushState({}, "", "/sample/resources?limit=invalid");
renderRouter(createDemoSessionAdapter("authenticated"), {
releaseInfo: { getCurrent, refresh: getCurrent },
});
expect(
await screen.findByRole("heading", {
name: "올바르지 않은 주소입니다.",
}),
).toBeVisible();
expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1);
expect(screen.getByText("안전한 탐색 링크를 사용해 주세요.")).toHaveAttribute(
"data-route-error",
"ROUTE_SEARCH_INVALID",
);
expect(getCurrent).not.toHaveBeenCalled();
});
});
@@ -35,7 +35,6 @@ const releaseManifest = {
"route-examples-ui": "assets/ui.js",
"route-examples-states": "assets/states.js",
"route-examples-auth": "assets/auth.js",
"route-sample-resources": "assets/sample.js",
"route-not-found": "assets/not-found.js",
},
};
@@ -1,70 +0,0 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { SampleResourcePage } from "../../src/sample/contract-fixture/sample-resource-page.jsx";
describe("removable sample feature page", () => {
it("renders the API-to-view-model result through AsyncSurface", async () => {
/** @type {Parameters<typeof SampleResourcePage>[0]["facade"]} */
const facade = {
listResources: async () => ({
ok: /** @type {const} */ (true),
value: [
{
resourceId: "resource-1",
title: "Example",
createdAtLabel: null,
},
],
}),
createResource: async () => ({
ok: /** @type {const} */ (true),
value: {
resourceId: "resource-created",
title: "Created",
createdAtLabel: null,
},
}),
};
render(<SampleResourcePage facade={facade} />);
expect(screen.getByLabelText("불러오는 중")).toBeVisible();
expect(await screen.findByText("Example")).toBeVisible();
});
it("renders normalized terminal errors without raw DTO fields", async () => {
/** @type {Parameters<typeof SampleResourcePage>[0]["facade"]} */
const facade = {
listResources: async () => ({
ok: /** @type {const} */ (false),
error: {
kind: "SERVER_FAILURE",
code: "SERVER_FAILURE",
retryable: true,
operationId: "LIST_SAMPLE_RESOURCES",
attemptCount: 1,
userMessageKey: "error.server_failure",
action: "retry",
},
}),
createResource: async () => ({
ok: /** @type {const} */ (true),
value: {
resourceId: "resource-created",
title: "Created",
createdAtLabel: null,
},
}),
};
render(<SampleResourcePage facade={facade} />);
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("요청을 완료하지 못했습니다.");
expect(alert).toHaveAttribute(
"data-message-key",
"error.server_failure",
);
});
});
+4 -8
View File
@@ -1,14 +1,10 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.js";
for (const route of [
"/",
"/examples/ui",
"/examples/states",
"/examples/auth",
"/sample/resources",
"/not-found",
]) {
for (const route of Object.values(ROUTE_REGISTRY).map((definition) =>
definition.path === "*" ? "/not-found" : definition.path,
)) {
test(`@a11y ${route} has no critical or serious axe violations`, async ({
page,
}) => {
+7 -2
View File
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test";
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.js";
test("boots the public app shell", async ({ page }) => {
await page.goto("/");
@@ -27,7 +28,11 @@ test("navigates to a registry-backed example without a page reload", async ({
test("opens the protected integration route through the local demo seam", async ({
page,
}) => {
await page.goto("/sample/resources");
const protectedRoute = Object.values(ROUTE_REGISTRY).find(
(definition) => definition.access === "integration-defined",
);
if (!protectedRoute) throw new Error("An integration route is required");
await page.goto(protectedRoute.path);
await expect(
page.getByRole("heading", { name: "세션이 필요합니다." }),
).toBeVisible();
@@ -35,7 +40,7 @@ test("opens the protected integration route through the local demo seam", async
await page.getByRole("button", { name: "로그인 시작" }).click();
await expect(
page.getByRole("heading", { name: "보호된 연동 지점" }),
page.getByRole("heading", { name: protectedRoute.title }),
).toBeVisible();
await expect(page.getByText("인증됨")).toBeVisible();
});
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import {
buildRouteUrl,
parseRouteInput,
} from "../../../src/presentation/routes/route-codecs.js";
import {
mapReferenceOperation,
toReferenceView,
} from "../../../src/features/reference-feature/contracts/reference-mapper.js";
import {
validateReferencePayload,
validateReferenceRequest,
} from "../../../src/features/reference-feature/contracts/reference-schemas.js";
import {
REFERENCE_FEATURE_CONTRACT,
referenceQueryKeys,
} from "../../../src/features/reference-feature/contracts/reference-feature-contract.js";
describe("reference feature boundary contracts", () => {
it("round-trips one canonical filter through URL and query identity", () => {
const filters = {
tags: ["open", "new"],
cursor: "a/b",
limit: 5,
};
const url = buildRouteUrl("REFERENCE_RESOURCE_LIST", { search: filters });
expect(url).toBe(
"/examples/reference-resources?cursor=a%2Fb&limit=5&tags=open&tags=new",
);
const parsed = parseRouteInput(
"REFERENCE_RESOURCE_LIST",
{},
new URL(url, "https://app.test").searchParams,
);
expect(parsed).toMatchObject({
success: true,
data: { search: filters },
});
expect(referenceQueryKeys.list(filters).at(-1)).toEqual(filters);
});
it("rejects unknown search and malformed DTO before mapping", () => {
expect(
parseRouteInput(
"REFERENCE_RESOURCE_LIST",
{},
new URLSearchParams("unknown=value"),
),
).toEqual({ success: false, code: "ROUTE_SEARCH_INVALID" });
expect(
validateReferencePayload("ReferenceResourceListPayload", [
{ id: "unsafe", name: 42 },
]),
).toMatchObject({ success: false });
expect(() =>
mapReferenceOperation("LIST_REFERENCE_RESOURCES", [
{ id: "unsafe", name: 42 },
]),
).toThrow();
});
it("normalizes request input and maps only owned domain fields", () => {
expect(
validateReferenceRequest("CreateReferenceResourceCommand", {
name: " Example ",
}),
).toMatchObject({ success: true, data: { name: "Example" } });
const model = mapReferenceOperation("CREATE_REFERENCE_RESOURCE", {
id: "reference-1",
name: "Example",
createdAt: "2026-07-26T00:00:00.000Z",
});
if (!("id" in model)) throw new Error("expected one model");
expect(toReferenceView(model, () => "formatted")).toEqual({
resourceId: "reference-1",
title: "Example",
createdAtLabel: "formatted",
});
});
it("owns route, operation and query contributions in one removable contract", () => {
expect(Object.keys(REFERENCE_FEATURE_CONTRACT.routes)).toEqual([
"REFERENCE_RESOURCE_LIST",
]);
expect(Object.keys(REFERENCE_FEATURE_CONTRACT.apiOperations)).toEqual([
"LIST_REFERENCE_RESOURCES",
"CREATE_REFERENCE_RESOURCE",
]);
expect(Object.keys(REFERENCE_FEATURE_CONTRACT.queryRegistry)).toEqual([
"REFERENCE_RESOURCE",
]);
});
});
@@ -0,0 +1,234 @@
// @vitest-environment jsdom
import {
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { createDemoSessionAdapter } from "../../../src/adapters/auth/external-session-adapter.js";
import type {
ReferenceFeatureInput,
ReferenceResult,
} from "../../../src/features/reference-feature/application/reference-feature-api.js";
import { REFERENCE_FEATURE_ID } from "../../../src/features/reference-feature/contracts/reference-feature-contract.js";
import type { ReferenceResourceView } from "../../../src/features/reference-feature/contracts/reference-mapper.js";
import { createFailure } from "../../../src/contracts/errors.js";
import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.js";
import { AppRouter } from "../../../src/presentation/routes/app-router.js";
import { createTestApplication } from "../../helpers/create-test-application.js";
function renderReference(
input: ReferenceFeatureInput,
url = "/examples/reference-resources?limit=5",
) {
window.history.pushState({}, "", url);
const client = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: Infinity },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={client}>
<ApplicationProvider
application={createTestApplication({
session: createDemoSessionAdapter("authenticated"),
featureInputs: { [REFERENCE_FEATURE_ID]: input },
})}
>
<AppRouter />
</ApplicationProvider>
</QueryClientProvider>,
);
}
function inputWith(
overrides: Partial<ReferenceFeatureInput> = {},
): ReferenceFeatureInput {
return {
listResources: async () => ({ ok: true, value: [] }),
createResource: async ({ name }) => ({
ok: true,
value: {
resourceId: "created",
title: name,
createdAtLabel: null,
},
}),
...overrides,
};
}
describe("reference feature page states", () => {
it("renders loading, success and empty states through the installed route", async () => {
let resolveList:
| ((result: ReferenceResult<readonly ReferenceResourceView[]>) => void)
| undefined;
const pending = new Promise<
ReferenceResult<readonly ReferenceResourceView[]>
>((resolve) => {
resolveList = resolve;
});
const loaded = renderReference(
inputWith({ listResources: async () => pending }),
);
expect(await screen.findByLabelText("불러오는 중")).toBeVisible();
resolveList?.({
ok: true,
value: [
{
resourceId: "reference-1",
title: "Loaded",
createdAtLabel: null,
},
],
});
expect(await screen.findByText("Loaded")).toBeVisible();
loaded.unmount();
renderReference(inputWith(), "/examples/reference-resources?limit=10");
expect(
await screen.findByRole("heading", {
name: "표시할 항목이 없습니다.",
}),
).toBeVisible();
});
it("rejects invalid URL input before the feature application input", async () => {
const listResources = vi.fn();
renderReference(
inputWith({ listResources }),
"/examples/reference-resources?limit=invalid",
);
expect(
await screen.findByRole("heading", {
name: "올바르지 않은 주소입니다.",
}),
).toBeVisible();
expect(listResources).not.toHaveBeenCalled();
});
it("renders backend forbidden even when the client access hint allowed entry", async () => {
renderReference(
inputWith({
listResources: async () => ({
ok: false,
error: createFailure(
"FORBIDDEN",
"LIST_REFERENCE_RESOURCES",
0,
),
}),
}),
);
expect(await screen.findByRole("alert")).toHaveTextContent(
"이 작업을 수행할 권한이 없습니다.",
);
});
it("deduplicates optimistic create and rolls back a conflict", async () => {
const user = userEvent.setup();
let finish:
| ((result: ReferenceResult<ReferenceResourceView>) => void)
| undefined;
const createResource = vi.fn(
() =>
new Promise<ReferenceResult<ReferenceResourceView>>((resolve) => {
finish = resolve;
}),
);
renderReference(
inputWith({
listResources: async () => ({
ok: true,
value: [
{
resourceId: "existing",
title: "Existing",
createdAtLabel: null,
},
],
}),
createResource,
}),
);
await screen.findByText("Existing");
await user.type(screen.getByLabelText("새 항목 이름"), "Conflicting");
const submit = screen.getByRole("button", { name: "추가" });
await user.dblClick(submit);
await waitFor(() => expect(createResource).toHaveBeenCalledOnce());
expect(await screen.findByText("Conflicting")).toHaveAttribute(
"data-optimistic",
"true",
);
expect(screen.getByText("mutation-pending")).toBeVisible();
finish?.({
ok: false,
error: createFailure(
"CONFLICT",
"CREATE_REFERENCE_RESOURCE",
0,
),
});
expect(
await screen.findByRole("button", { name: "충돌 해결" }),
).toBeVisible();
expect(screen.queryByText("Conflicting")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "충돌 해결" }));
await waitFor(() =>
expect(screen.queryByText("mutation-conflict")).not.toBeInTheDocument(),
);
});
it("keeps stale data visible during refresh failure and recovers on retry", async () => {
const user = userEvent.setup();
const listResources = vi
.fn<ReferenceFeatureInput["listResources"]>()
.mockResolvedValueOnce({
ok: true,
value: [
{
resourceId: "existing",
title: "Existing",
createdAtLabel: null,
},
],
})
.mockResolvedValueOnce({
ok: false,
error: createFailure(
"SERVER_FAILURE",
"LIST_REFERENCE_RESOURCES",
0,
),
})
.mockResolvedValue({
ok: true,
value: [
{
resourceId: "recovered",
title: "Recovered",
createdAtLabel: null,
},
],
});
renderReference(inputWith({ listResources }));
await screen.findByText("Existing");
await user.type(screen.getByLabelText("새 항목 이름"), "Created");
await user.click(screen.getByRole("button", { name: "추가" }));
expect(await screen.findByText("stale-degraded")).toBeVisible();
expect(screen.getByText("Existing")).toBeVisible();
await user.click(screen.getByRole("button", { name: "다시 시도" }));
expect(await screen.findByText("Recovered")).toBeVisible();
await waitFor(() =>
expect(screen.queryByText("stale-degraded")).not.toBeInTheDocument(),
);
expect(listResources).toHaveBeenCalledTimes(3);
});
});
@@ -0,0 +1,129 @@
// @vitest-environment jsdom
import { HttpResponse, http } from "msw";
import { setupServer } from "msw/node";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
it,
vi,
} from "vitest";
import { createRuntimeComposition } from "../../../src/bootstrap/create-runtime-composition.js";
import { RuntimeApplication } from "../../../src/bootstrap/runtime-application.jsx";
const runtimeConfig = {
APP_ENV: "local",
API_BASE_URL: "https://api.test",
REQUEST_TIMEOUT_MS: 10_000,
MAX_RETRY_ATTEMPTS: 0,
TELEMETRY_ENABLED: false,
AUTH_MODE: "demo",
CONFIG_SCHEMA_VERSION: "1",
API_CONTRACT_VERSION: "1",
RELEASE_MANIFEST_URL: "/release-manifest.json",
BUILD_ID: "local-build",
RELEASE_ID: "local-release",
};
const releaseManifest = {
schemaVersion: 1,
appVersion: "0.1.0",
buildId: "local-build",
commitSha: "local",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "test-hash",
releaseId: "local-release",
builtAt: "2026-07-26T00:00:00.000Z",
routeChunks: {
"route-home": "assets/home.js",
"route-examples-ui": "assets/ui.js",
"route-examples-states": "assets/states.js",
"route-examples-auth": "assets/auth.js",
"route-reference-resources": "assets/reference.js",
"route-not-found": "assets/not-found.js",
},
};
const listRequests = vi.fn();
const createRequests = vi.fn();
const resources = [{ id: "reference-1", name: "Existing" }];
const server = setupServer(
http.get("http://app.test/config.json", () =>
HttpResponse.json(runtimeConfig),
),
http.get("http://app.test/release-manifest.json", () =>
HttpResponse.json(releaseManifest),
),
http.get("https://api.test/api/reference-resources", ({ request }) => {
listRequests(new URL(request.url).search);
return HttpResponse.json({
success: true,
data: resources,
meta: { requestId: "request-list", traceId: "trace-list" },
});
}),
http.post("https://api.test/api/reference-resources", async ({ request }) => {
const body = (await request.json()) as { name: string };
createRequests(body);
const created = { id: "reference-created", name: body.name };
resources.push(created);
return HttpResponse.json({
success: true,
data: created,
meta: { requestId: "request-create", traceId: "trace-create" },
});
}),
);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
listRequests.mockClear();
createRequests.mockClear();
resources.splice(1);
});
afterAll(() => server.close());
const absoluteFetch: typeof fetch = (input, init) => {
if (input instanceof Request) return fetch(input, init);
const url = new URL(
input instanceof URL ? input.href : input,
"http://app.test",
);
return fetch(url, init);
};
describe("reference feature production vertical path", () => {
it("traverses bootstrap, router, application, HTTP schema/mapper and query cache", async () => {
const user = userEvent.setup();
const composition = await createRuntimeComposition({
fetcher: absoluteFetch,
host: {},
});
window.history.pushState(
{},
"",
"/examples/reference-resources?tags=open&tags=new&limit=5",
);
render(<RuntimeApplication composition={composition} />);
await user.click(
await screen.findByRole("button", { name: "로그인 시작" }),
);
expect(await screen.findByText("Existing")).toBeVisible();
expect(listRequests).toHaveBeenCalledWith(
"?limit=5&tags=open&tags=new",
);
await user.type(screen.getByLabelText("새 항목 이름"), " Created ");
await user.click(screen.getByRole("button", { name: "추가" }));
expect(await screen.findByText("Created")).toBeVisible();
expect(createRequests).toHaveBeenCalledWith({ name: "Created" });
expect(listRequests.mock.calls.length).toBeGreaterThanOrEqual(2);
});
});
+1 -1
View File
@@ -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;
+41 -39
View File
@@ -7,46 +7,48 @@ import { createApplication } from "../../src/application/create-application.js";
* preferences?: import("../../src/application/ports/storage-port.js").StoragePort,
* diagnostics?: import("../../src/application/ports/telemetry-port.js").TelemetryPort,
* releaseInfo?: import("../../src/application/ports/release-info-port.js").ReleaseInfoPort,
* navigation?: { reload(): void }
* navigation?: { reload(): void },
* featureInputs?: Readonly<Record<string, unknown>>
* }} [overrides]
*/
export function createTestApplication(overrides = {}) {
return createApplication({
session: overrides.session ?? createAnonymousSessionAdapter(),
preferences:
overrides.preferences ??
{
read: () => ({ ok: /** @type {const} */ (true), value: "system" }),
write: () => ({ ok: /** @type {const} */ (true) }),
remove: () => ({ ok: /** @type {const} */ (true) }),
},
diagnostics: overrides.diagnostics ?? { emit: () => {} },
releaseInfo:
overrides.releaseInfo ??
{
getCurrent: async () => ({
buildId: "test-build",
releaseId: "test-release",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "test-hash",
routeChunks: {
"route-home": "assets/home.js",
"route-sample-resources": "assets/sample.js",
},
}),
refresh: async () => ({
buildId: "test-build",
releaseId: "test-release",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "test-hash",
routeChunks: {
"route-home": "assets/home.js",
"route-sample-resources": "assets/sample.js",
},
}),
},
navigation: overrides.navigation ?? { reload: () => {} },
});
return createApplication(
{
session: overrides.session ?? createAnonymousSessionAdapter(),
preferences:
overrides.preferences ??
{
read: () => ({ ok: /** @type {const} */ (true), value: "system" }),
write: () => ({ ok: /** @type {const} */ (true) }),
remove: () => ({ ok: /** @type {const} */ (true) }),
},
diagnostics: overrides.diagnostics ?? { emit: () => {} },
releaseInfo:
overrides.releaseInfo ??
{
getCurrent: async () => ({
buildId: "test-build",
releaseId: "test-release",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "test-hash",
routeChunks: {
"route-home": "assets/home.js",
},
}),
refresh: async () => ({
buildId: "test-build",
releaseId: "test-release",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "test-hash",
routeChunks: {
"route-home": "assets/home.js",
},
}),
},
navigation: overrides.navigation ?? { reload: () => {} },
},
overrides.featureInputs,
);
}
+108
View File
@@ -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"
>;
+13 -7
View File
@@ -4,11 +4,12 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest
import { createExternalAuthSessionAdapter } from "../../src/adapters/auth/external-session-adapter.js";
import { createHttpClient } from "../../src/adapters/http/client.js";
import { TEST_HTTP_CONTRACT } from "../helpers/http-contract-fixture.js";
/** @type {number[]} */
let responseStatuses = [];
const server = setupServer(
http.get("https://api.test/api/sample/resources", () => {
http.get("https://api.test/api/entities", () => {
const status = responseStatuses.shift() ?? 200;
if (status === 401) {
return HttpResponse.json(
@@ -37,6 +38,11 @@ afterAll(() => server.close());
const clock = { now: () => 0, sleep: async () => {} };
/** @param {Parameters<typeof createHttpClient>[0]} options */
function testClient(options) {
return createHttpClient({ ...TEST_HTTP_CONTRACT, ...options });
}
/**
* @param {Partial<Parameters<typeof createExternalAuthSessionAdapter>[0]>} overrides
* @returns {Parameters<typeof createExternalAuthSessionAdapter>[0]}
@@ -63,13 +69,13 @@ describe("bounded 401 session recovery", () => {
recoverSession,
notifyUnauthenticated: vi.fn(),
}));
const client = createHttpClient({
const client = testClient({
baseUrl: "https://api.test",
authSession,
clock,
});
await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({
await expect(client.execute("LIST_ENTITIES")).resolves.toMatchObject({
ok: true,
});
expect(recoverSession).toHaveBeenCalledTimes(1);
@@ -83,13 +89,13 @@ describe("bounded 401 session recovery", () => {
recoverSession: async () => "restored",
notifyUnauthenticated,
}));
const client = createHttpClient({
const client = testClient({
baseUrl: "https://api.test",
authSession,
clock,
});
await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({
await expect(client.execute("LIST_ENTITIES")).resolves.toMatchObject({
ok: false,
error: { kind: "AUTH_REQUIRED" },
});
@@ -104,13 +110,13 @@ describe("bounded 401 session recovery", () => {
recoverSession: async () => "restored",
notifyUnauthenticated: vi.fn(),
}));
const client = createHttpClient({
const client = testClient({
baseUrl: "https://api.test",
authSession: attachFailure,
clock,
});
await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({
await expect(client.execute("LIST_ENTITIES")).resolves.toMatchObject({
ok: false,
error: { kind: "AUTH_INTEGRATION_FAILURE" },
});
+20 -14
View File
@@ -3,10 +3,11 @@ import { setupServer } from "msw/node";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { createHttpClient } from "../../src/adapters/http/client.js";
import { TEST_HTTP_CONTRACT } from "../helpers/http-contract-fixture.js";
let attempts = 0;
const server = setupServer(
http.get("https://api.test/api/sample/resources", () => {
http.get("https://api.test/api/entities", () => {
attempts += 1;
if (attempts < 3) {
return HttpResponse.json(
@@ -34,16 +35,21 @@ const clock = {
sleep: async () => {},
};
/** @param {Parameters<typeof createHttpClient>[0]} options */
function testClient(options) {
return createHttpClient({ ...TEST_HTTP_CONTRACT, ...options });
}
describe("shared HTTP client", () => {
it("retries a safe request at most twice and returns validated data", async () => {
const client = createHttpClient({
const client = testClient({
baseUrl: "https://api.test",
clock,
random: () => 0,
});
await expect(
client.execute("LIST_SAMPLE_RESOURCES", { routeId: "SAMPLE_RESOURCE_LIST" }),
client.execute("LIST_ENTITIES", { routeId: "TEST_ROUTE" }),
).resolves.toMatchObject({
ok: true,
value: [{ id: "resource-1", displayName: "Example" }],
@@ -55,12 +61,12 @@ describe("shared HTTP client", () => {
it("rejects a non-JSON response without exposing its body", async () => {
server.use(
http.get(
"https://api.test/api/sample/resources",
"https://api.test/api/entities",
() => new HttpResponse("<secret>raw body</secret>", { status: 502 }),
),
);
const client = createHttpClient({ baseUrl: "https://api.test", clock });
const result = await client.execute("LIST_SAMPLE_RESOURCES");
const client = testClient({ baseUrl: "https://api.test", clock });
const result = await client.execute("LIST_ENTITIES");
expect(result).toMatchObject({
ok: false,
@@ -72,21 +78,21 @@ describe("shared HTTP client", () => {
it("classifies malformed JSON and invalid payloads at the boundary", async () => {
server.use(
http.get(
"https://api.test/api/sample/resources",
"https://api.test/api/entities",
() =>
new HttpResponse("{", {
headers: { "Content-Type": "application/json" },
}),
),
);
const client = createHttpClient({ baseUrl: "https://api.test", clock });
await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({
const client = testClient({ baseUrl: "https://api.test", clock });
await expect(client.execute("LIST_ENTITIES")).resolves.toMatchObject({
ok: false,
error: { kind: "MALFORMED_JSON" },
});
server.use(
http.get("https://api.test/api/sample/resources", () =>
http.get("https://api.test/api/entities", () =>
HttpResponse.json({
success: true,
data: [{ id: "resource-1", name: 42 }],
@@ -94,7 +100,7 @@ describe("shared HTTP client", () => {
}),
),
);
await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({
await expect(client.execute("LIST_ENTITIES")).resolves.toMatchObject({
ok: false,
error: { kind: "SCHEMA_MISMATCH" },
});
@@ -102,7 +108,7 @@ describe("shared HTTP client", () => {
it("guards mapper exceptions as UNKNOWN_FAILURE", async () => {
server.use(
http.get("https://api.test/api/sample/resources", () =>
http.get("https://api.test/api/entities", () =>
HttpResponse.json({
success: true,
data: [{ id: "resource-1", name: "Example" }],
@@ -110,7 +116,7 @@ describe("shared HTTP client", () => {
}),
),
);
const client = createHttpClient({
const client = testClient({
baseUrl: "https://api.test",
clock,
mapPayload: () => {
@@ -118,7 +124,7 @@ describe("shared HTTP client", () => {
},
});
const result = await client.execute("LIST_SAMPLE_RESOURCES");
const result = await client.execute("LIST_ENTITIES");
expect(result).toMatchObject({
ok: false,
error: { kind: "UNKNOWN_FAILURE" },
@@ -1,7 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import { createHttpClient } from "../../src/adapters/http/client.js";
import { queryKeys } from "../../src/contracts/query-keys.js";
import {
entityQueryKeys,
TEST_HTTP_CONTRACT,
} from "../helpers/http-contract-fixture.js";
/** @param {unknown} data */
function successResponse(data) {
@@ -40,6 +43,11 @@ function recordingScheduler() {
};
}
/** @param {Parameters<typeof createHttpClient>[0]} options */
function testClient(options) {
return createHttpClient({ ...TEST_HTTP_CONTRACT, ...options });
}
describe("HTTP operation execution contract", () => {
it("sends parsed search/body values and aligns canonical query identity", async () => {
const requests = /** @type {Request[]} */ ([]);
@@ -51,7 +59,7 @@ describe("HTTP operation execution contract", () => {
}
return successResponse([]);
});
const client = createHttpClient({
const client = testClient({
baseUrl: "https://api.test",
fetcher,
clock: immediateClock(),
@@ -60,21 +68,21 @@ describe("HTTP operation execution contract", () => {
const filters = { tags: ["open", "new"], cursor: "a/b", limit: 5 };
await client.execute({
operationId: "LIST_SAMPLE_RESOURCES",
routeId: "SAMPLE_RESOURCE_LIST",
operationId: "LIST_ENTITIES",
routeId: "TEST_ROUTE",
searchParams: filters,
});
await client.execute({
operationId: "CREATE_SAMPLE_RESOURCE",
routeId: "SAMPLE_RESOURCE_LIST",
operationId: "CREATE_ENTITY",
routeId: "TEST_ROUTE",
body: { name: " Trimmed " },
idempotencyKey: "logical-command",
});
expect(requests[0].url).toBe(
"https://api.test/api/sample/resources?cursor=a%2Fb&limit=5&tags=open&tags=new",
"https://api.test/api/entities?cursor=a%2Fb&limit=5&tags=open&tags=new",
);
expect(queryKeys.resource.list(filters).at(-1)).toEqual(filters);
expect(entityQueryKeys.list(filters).at(-1)).toEqual(filters);
await expect(requests[1].json()).resolves.toEqual({ name: "Trimmed" });
expect(requests[1].headers.get("Idempotency-Key")).toBe("logical-command");
expect(scheduler.setTimeout).toHaveBeenCalledTimes(2);
@@ -84,7 +92,7 @@ describe("HTTP operation execution contract", () => {
it("performs no fetch or timer work for invalid request input", async () => {
const fetcher = vi.fn();
const scheduler = recordingScheduler();
const client = createHttpClient({
const client = testClient({
baseUrl: "https://api.test",
fetcher,
scheduler,
@@ -92,8 +100,8 @@ describe("HTTP operation execution contract", () => {
await expect(
client.execute({
operationId: "CREATE_SAMPLE_RESOURCE",
routeId: "SAMPLE_RESOURCE_LIST",
operationId: "CREATE_ENTITY",
routeId: "TEST_ROUTE",
body: { name: " " },
}),
).resolves.toMatchObject({
@@ -114,7 +122,7 @@ describe("HTTP operation execution contract", () => {
async (maxRetryAttempts, totalAttempts) => {
const fetcher = vi.fn(async () => failureResponse(503));
const scheduler = recordingScheduler();
const client = createHttpClient({
const client = testClient({
baseUrl: "https://api.test",
fetcher,
clock: immediateClock(),
@@ -124,8 +132,8 @@ describe("HTTP operation execution contract", () => {
await expect(
client.execute({
operationId: "LIST_SAMPLE_RESOURCES",
routeId: "SAMPLE_RESOURCE_LIST",
operationId: "LIST_ENTITIES",
routeId: "TEST_ROUTE",
}),
).resolves.toMatchObject({
ok: false,
@@ -149,15 +157,15 @@ describe("HTTP operation execution contract", () => {
);
}),
);
const client = createHttpClient({
const client = testClient({
baseUrl: "https://api.test",
fetcher,
scheduler,
maxRetryAttempts: 0,
});
const timeoutResult = client.execute({
operationId: "LIST_SAMPLE_RESOURCES",
routeId: "SAMPLE_RESOURCE_LIST",
operationId: "LIST_ENTITIES",
routeId: "TEST_ROUTE",
});
await vi.waitFor(() => expect(scheduler.callbacks).toHaveLength(1));
scheduler.callbacks[0]();
@@ -170,8 +178,8 @@ describe("HTTP operation execution contract", () => {
const add = vi.spyOn(caller.signal, "addEventListener");
const remove = vi.spyOn(caller.signal, "removeEventListener");
const abortResult = client.execute({
operationId: "LIST_SAMPLE_RESOURCES",
routeId: "SAMPLE_RESOURCE_LIST",
operationId: "LIST_ENTITIES",
routeId: "TEST_ROUTE",
signal: caller.signal,
});
await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2));
@@ -196,11 +204,11 @@ describe("HTTP operation execution contract", () => {
retry: "never",
requestSource: "none",
requestSchema: "unused",
responseSchema: "SampleResourcePayload",
responseSchema: "EntityPayload",
owner: "test",
});
const unsafeFetch = vi.fn(async () => failureResponse(503));
const unsafeClient = createHttpClient({
const unsafeClient = testClient({
baseUrl: "https://api.test",
fetcher: unsafeFetch,
clock: immediateClock(),
@@ -213,14 +221,14 @@ describe("HTTP operation execution contract", () => {
expect(unsafeFetch).toHaveBeenCalledOnce();
const statusFetch = vi.fn(async () => failureResponse(500));
const statusClient = createHttpClient({
const statusClient = testClient({
baseUrl: "https://api.test",
fetcher: statusFetch,
clock: immediateClock(),
});
await statusClient.execute({
operationId: "LIST_SAMPLE_RESOURCES",
routeId: "SAMPLE_RESOURCE_LIST",
operationId: "LIST_ENTITIES",
routeId: "TEST_ROUTE",
});
expect(statusFetch).toHaveBeenCalledOnce();
@@ -228,15 +236,15 @@ describe("HTTP operation execution contract", () => {
successResponse([{ id: "one", name: 42 }]),
);
const schemaScheduler = recordingScheduler();
const schemaClient = createHttpClient({
const schemaClient = testClient({
baseUrl: "https://api.test",
fetcher: schemaFetch,
clock: immediateClock(),
scheduler: schemaScheduler,
});
await schemaClient.execute({
operationId: "LIST_SAMPLE_RESOURCES",
routeId: "SAMPLE_RESOURCE_LIST",
operationId: "LIST_ENTITIES",
routeId: "TEST_ROUTE",
});
expect(schemaFetch).toHaveBeenCalledOnce();
expect(schemaScheduler.clearTimeout).toHaveBeenCalledOnce();
@@ -1,53 +0,0 @@
import { HttpResponse, http } from "msw";
import { setupServer } from "msw/node";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createHttpClient } from "../../src/adapters/http/client.js";
import {
createQueryCacheAdapter,
createQueryClient,
} from "../../src/adapters/query-cache/tanstack-query-cache.js";
import { queryKeys } from "../../src/contracts/query-keys.js";
import { createSampleFacade } from "../../src/sample/contract-fixture/sample-facade.js";
const server = setupServer(
http.get("https://api.test/api/sample/resources", () =>
HttpResponse.json({
success: true,
data: [{ id: "resource-1", name: "Example" }],
meta: { requestId: "request-1", traceId: "trace-1" },
}),
),
);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterAll(() => server.close());
describe("sample vertical contract fixture", () => {
it("traverses API, schema, mapper, application facade, and cache", async () => {
const queryClient = createQueryClient();
const cache = createQueryCacheAdapter(queryClient);
const facade = createSampleFacade({
http: createHttpClient({
baseUrl: "https://api.test",
clock: { now: () => 0, sleep: async () => {} },
}),
cache,
});
await expect(facade.listResources()).resolves.toEqual({
ok: true,
value: [
{
resourceId: "resource-1",
title: "Example",
createdAtLabel: null,
},
],
});
expect(cache.read(queryKeys.resource.list({}))).toMatchObject({
ok: true,
value: [{ id: "resource-1", displayName: "Example" }],
});
});
});
+20 -35
View File
@@ -6,49 +6,34 @@ import {
validateOperationRequest,
} from "../../src/adapters/http/schema-registry.js";
describe("HTTP runtime schema boundary", () => {
describe("HTTP platform schema boundary", () => {
it("rejects an invalid top-level envelope", () => {
expect(validateEnvelope({ success: true }).success).toBe(false);
});
it("rejects an invalid operation payload with safe issue metadata", () => {
const result = validateOperationPayload("SampleResourceListPayload", [
{ id: "resource-1", name: 42 },
]);
expect(result).toMatchObject({
success: false,
issues: [{ path: "0.name" }],
});
expect(JSON.stringify(result)).not.toContain("resource-1");
});
it("returns a deep-cloned additive-tolerant payload", () => {
const source = [{ id: "resource-1", name: "Example", additive: "accepted" }];
const result = validateOperationPayload("SampleResourceListPayload", source);
expect(result).toMatchObject({
it("accepts and clones a generic valid response envelope", () => {
const source = {
success: true,
data: [{ id: "resource-1", additive: "accepted" }],
});
if (!result.success) throw new Error("expected valid sample payload");
data: [],
meta: { requestId: "request-1", traceId: "trace-1" },
};
const result = validateEnvelope(source);
expect(result).toMatchObject({ success: true, data: source });
if (!result.success) throw new Error("expected valid envelope");
expect(result.data).not.toBe(source);
});
it("validates outbound commands before transport", () => {
expect(
validateOperationRequest("CreateSampleResourceCommand", { name: "" }).success,
).toBe(false);
expect(
validateOperationRequest("CreateSampleResourceCommand", { name: "Example" })
.success,
).toBe(true);
});
it("fails closed for an unregistered schema", () => {
expect(validateOperationPayload("UnknownPayload", {})).toMatchObject({
success: false,
issues: [{ code: "SCHEMA_NOT_REGISTERED" }],
it("fails closed without leaking input when a feature schema is absent", () => {
const result = validateOperationPayload("UnknownPayload", {
secret: "not-projected",
});
expect(result).toMatchObject({
success: false,
issues: [{ path: "", code: "SCHEMA_NOT_REGISTERED" }],
});
expect(JSON.stringify(result)).not.toContain("not-projected");
expect(
validateOperationRequest("UnknownCommand", { name: "Example" }).success,
).toBe(false);
});
});
+5
View File
@@ -16,10 +16,15 @@ describe("application input/output boundary", () => {
"diagnostics",
"runtime",
"recovery",
"features",
]);
expect(application).not.toHaveProperty("storage");
expect(application).not.toHaveProperty("telemetry");
expect(application).not.toHaveProperty("releaseInfo");
expect(application.features.has("not-installed")).toBe(false);
expect(() => application.features.get("not-installed")).toThrow(
"Application feature is not installed",
);
await expect(application.runtime.getReleaseSummary()).resolves.toEqual({
buildId: "test-build",
releaseId: "test-release",
+3 -44
View File
@@ -1,52 +1,11 @@
import { describe, expect, it } from "vitest";
import {
mapOperationPayload,
mapResourceDto,
} from "../../src/adapters/http/resource-mapper.js";
import { toResourceViewModel } from "../../src/application/view-models/resource-view-model.js";
import { mapOperationPayload } from "../../src/adapters/http/resource-mapper.js";
describe("DTO to model to view-model mapping", () => {
it("contains raw DTO names at the HTTP boundary", () => {
const model = mapResourceDto({
id: "resource-1",
name: "Example",
createdAt: "2026-07-25T00:00:00.000Z",
backendOnly: "not propagated",
});
expect(model).toEqual({
id: "resource-1",
displayName: "Example",
createdAt: "2026-07-25T00:00:00.000Z",
});
expect(model).not.toHaveProperty("name");
expect(model).not.toHaveProperty("backendOnly");
});
it("maps operation payloads and rejects missing mappers", () => {
expect(
mapOperationPayload("LIST_SAMPLE_RESOURCES", [
{ id: "resource-1", name: "Example" },
]),
).toEqual([
{ id: "resource-1", displayName: "Example", createdAt: null },
]);
describe("platform DTO mapper boundary", () => {
it("fails closed when no feature mapper was injected", () => {
expect(() => mapOperationPayload("UNKNOWN", {})).toThrow(
"No boundary mapper registered",
);
});
it("projects an application-owned render-ready shape", () => {
const model = mapResourceDto({
id: "resource-1",
name: "Example",
createdAt: null,
});
expect(toResourceViewModel(model)).toEqual({
resourceId: "resource-1",
title: "Example",
createdAtLabel: null,
});
});
});
+1 -1
View File
@@ -49,7 +49,7 @@ describe("frontend failure classification", () => {
};
const result = createFailure(
"SERVER_FAILURE",
"LIST_SAMPLE_RESOURCES",
"LIST_ENTITIES",
0,
untrustedDetails,
);
+1 -1
View File
@@ -4,7 +4,7 @@ import {
MANUAL_A11Y_ROUTE_IDS,
validateManualA11yEvidence,
} from "../../scripts/lib/manual-a11y-evidence.mjs";
import { ROUTE_REGISTRY } from "../../src/contracts/routes.js";
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.js";
const reviewed = `Status: reviewed
Route ID: APP_HOME
+16 -107
View File
@@ -1,126 +1,35 @@
import { describe, expect, it } from "vitest";
import {
NAVIGATION_ROUTES,
ROUTE_REGISTRY,
} from "../../src/features/installed-feature-contracts.js";
import {
createRedirectLoopGuard,
decideRouteAccess,
} from "../../src/presentation/routes/navigation-policy.js";
import {
NAVIGATION_ROUTES,
ROUTE_REGISTRY,
} from "../../src/contracts/routes.js";
describe("route registry", () => {
it("matches the stable registry snapshot", () => {
expect(ROUTE_REGISTRY).toMatchInlineSnapshot(`
{
"APP_HOME": {
"access": "public",
"chunkId": "route-home",
"errorSurface": "route-boundary",
"loadingSurface": "app-shell",
"navigationLabel": "시작",
"navigationOrder": 10,
"paramsSchema": null,
"path": "/",
"routeId": "APP_HOME",
"searchSchema": null,
"title": "시작",
},
"EXAMPLES_AUTH": {
"access": "public",
"chunkId": "route-examples-auth",
"errorSurface": "route-boundary",
"loadingSurface": "example-page",
"navigationLabel": "인증 연동",
"navigationOrder": 40,
"paramsSchema": null,
"path": "/examples/auth",
"routeId": "EXAMPLES_AUTH",
"searchSchema": null,
"title": "인증 연동",
},
"EXAMPLES_STATES": {
"access": "public",
"chunkId": "route-examples-states",
"errorSurface": "route-boundary",
"loadingSurface": "example-page",
"navigationLabel": "화면 상태",
"navigationOrder": 30,
"paramsSchema": null,
"path": "/examples/states",
"routeId": "EXAMPLES_STATES",
"searchSchema": null,
"title": "화면 상태",
},
"EXAMPLES_UI": {
"access": "public",
"chunkId": "route-examples-ui",
"errorSurface": "route-boundary",
"loadingSurface": "example-page",
"navigationLabel": "UI 구성요소",
"navigationOrder": 20,
"paramsSchema": null,
"path": "/examples/ui",
"routeId": "EXAMPLES_UI",
"searchSchema": null,
"title": "UI 구성요소",
},
"NOT_FOUND": {
"access": "public",
"chunkId": "route-not-found",
"errorSurface": "not-found",
"loadingSurface": "none",
"navigationLabel": null,
"navigationOrder": null,
"paramsSchema": "NotFoundSplat",
"path": "*",
"routeId": "NOT_FOUND",
"searchSchema": null,
"title": "페이지를 찾을 수 없음",
},
"SAMPLE_RESOURCE_LIST": {
"access": "integration-defined",
"chunkId": "route-sample-resources",
"errorSurface": "feature-boundary",
"loadingSurface": "sample-resource-list",
"navigationLabel": "보호된 연동 지점",
"navigationOrder": 50,
"paramsSchema": null,
"path": "/sample/resources",
"routeId": "SAMPLE_RESOURCE_LIST",
"searchSchema": "SampleResourceListQuery",
"title": "보호된 연동 지점",
},
}
`);
});
describe("installed route registry", () => {
it("derives visible navigation in explicit order", () => {
expect(NAVIGATION_ROUTES.map(({ routeId }) => routeId)).toEqual([
"APP_HOME",
"EXAMPLES_UI",
"EXAMPLES_STATES",
"EXAMPLES_AUTH",
"SAMPLE_RESOURCE_LIST",
]);
const expected = Object.values(ROUTE_REGISTRY)
.filter((route) => route.navigationOrder !== null)
.sort(
(left, right) =>
/** @type {number} */ (left.navigationOrder) -
/** @type {number} */ (right.navigationOrder),
)
.map((route) => route.routeId);
expect(NAVIGATION_ROUTES.map(({ routeId }) => routeId)).toEqual(expected);
});
it("treats client access as a UX hint, not authorization", () => {
it("allows public routes without consulting a product permission", () => {
expect(decideRouteAccess("APP_HOME", "unauthenticated")).toEqual({
allowed: true,
action: "none",
});
expect(decideRouteAccess("SAMPLE_RESOURCE_LIST", "unauthenticated")).toEqual({
allowed: false,
action: "show-sign-in",
});
expect(decideRouteAccess("SAMPLE_RESOURCE_LIST", "authenticated")).toEqual({
allowed: true,
action: "none",
});
});
it("allows at most one automatic redirect per source-target pair", () => {
it("bounds automatic redirects by pair and maximum hops", () => {
const guard = createRedirectLoopGuard(2);
expect(guard.allow("/private", "/signin")).toBe(true);
expect(guard.allow("/private", "/signin")).toBe(false);
+16 -7
View File
@@ -5,18 +5,27 @@ import {
createQueryCacheAdapter,
createQueryClient,
} from "../../src/adapters/query-cache/tanstack-query-cache.js";
import { queryKeys } from "../../src/contracts/query-keys.js";
import { canonicalize } from "../../src/contracts/query-keys.js";
const queryKeys = Object.freeze({
all: () => Object.freeze(["entity", 1]),
list: (filters = {}) =>
Object.freeze(["entity", 1, "list", canonicalize(filters)]),
/** @param {string} entityId */
detail: (entityId) =>
Object.freeze(["entity", 1, "detail", String(entityId)]),
});
describe("query key registry", () => {
it("canonicalizes filter order into the same stable key", () => {
expect(queryKeys.resource.list({ page: 1, status: "open" })).toEqual(
queryKeys.resource.list({ status: "open", page: 1 }),
expect(queryKeys.list({ page: 1, status: "open" })).toEqual(
queryKeys.list({ status: "open", page: 1 }),
);
});
it("contains no raw URL or token material", () => {
expect(JSON.stringify(queryKeys.resource.detail("resource-1"))).toBe(
'["resource",1,"detail","resource-1"]',
expect(JSON.stringify(queryKeys.detail("entity-1"))).toBe(
'["entity",1,"detail","entity-1"]',
);
});
});
@@ -25,7 +34,7 @@ describe("TanStack QueryCachePort adapter", () => {
it("reads, writes, and invalidates only the declared namespace", async () => {
const client = createQueryClient();
const adapter = createQueryCacheAdapter(client);
const listKey = queryKeys.resource.list({ page: 1 });
const listKey = queryKeys.list({ page: 1 });
const otherKey = ["other", 1];
expect(adapter.write(listKey, [{ id: "resource-1" }])).toEqual({ ok: true });
@@ -35,7 +44,7 @@ describe("TanStack QueryCachePort adapter", () => {
value: [{ id: "resource-1" }],
});
await adapter.invalidate(queryKeys.resource.all());
await adapter.invalidate(queryKeys.all());
expect(client.getQueryState(listKey)?.isInvalidated).toBe(true);
expect(client.getQueryState(otherKey)?.isInvalidated).toBe(false);
});
+8 -48
View File
@@ -1,15 +1,14 @@
import { describe, expect, it } from "vitest";
import { ROUTE_RUNTIME_CONTRACT } from "../../src/contracts/route-runtime-contract.js";
import { ROUTE_REGISTRY } from "../../src/contracts/routes.js";
import {
buildRouteUrl,
parseRouteInput,
} from "../../src/presentation/routes/route-codecs.js";
import { ROUTE_RUNTIME } from "../../src/presentation/routes/route-runtime.js";
ROUTE_REGISTRY,
ROUTE_RUNTIME_CONTRACT,
} from "../../src/features/installed-feature-contracts.js";
import { ROUTE_RUNTIME } from "../../src/features/installed-feature-runtimes.js";
import { parseRouteInput } from "../../src/presentation/routes/route-codecs.js";
describe("typed route contract and runtime", () => {
it("keeps contract, runtime contribution, and executable module complete", () => {
describe("typed installed route catalog", () => {
it("keeps contract and executable runtime contributions complete", () => {
expect(Object.keys(ROUTE_RUNTIME_CONTRACT).sort()).toEqual(
Object.keys(ROUTE_REGISTRY).sort(),
);
@@ -18,46 +17,7 @@ describe("typed route contract and runtime", () => {
);
});
it("round-trips canonical search through the registered codec", () => {
const url = buildRouteUrl("SAMPLE_RESOURCE_LIST", {
search: {
tags: ["open", "new"],
cursor: "a/b",
limit: 5,
},
});
expect(url).toBe(
"/sample/resources?cursor=a%2Fb&limit=5&tags=open&tags=new",
);
const parsedUrl = new URL(url, "https://app.test");
expect(
parseRouteInput(
"SAMPLE_RESOURCE_LIST",
{},
parsedUrl.searchParams,
),
).toEqual({
success: true,
data: {
routeId: "SAMPLE_RESOURCE_LIST",
params: {},
search: {
cursor: "a/b",
limit: 5,
tags: ["open", "new"],
},
},
});
});
it("rejects unknown search and accepts the not-found splat owner", () => {
expect(
parseRouteInput(
"SAMPLE_RESOURCE_LIST",
{},
new URLSearchParams("unknown=value"),
),
).toEqual({ success: false, code: "ROUTE_SEARCH_INVALID" });
it("accepts the platform not-found splat owner", () => {
expect(
parseRouteInput(
"NOT_FOUND",
+19 -15
View File
@@ -4,6 +4,7 @@ import {
createRuntimeAdapters,
createRuntimeHttpClient,
} from "../../src/bootstrap/runtime-adapters.js";
import { TEST_HTTP_CONTRACT } from "../helpers/http-contract-fixture.js";
const runtime = {
config: {
@@ -118,23 +119,26 @@ describe("runtime adapter composition", () => {
release,
host: {},
})).outputPorts.session;
const client = createRuntimeHttpClient({
runtime:
/** @type {Parameters<typeof createRuntimeHttpClient>[0]["runtime"]} */ (
runtime
),
authSession:
/** @type {import("../../src/application/ports/auth-session-port.js").AuthSessionPort} */ (
authSession
),
fetcher,
clock: { now: () => 0, sleep: async () => {} },
scheduler,
});
const client = createRuntimeHttpClient(
{
runtime:
/** @type {Parameters<typeof createRuntimeHttpClient>[0]["runtime"]} */ (
runtime
),
authSession:
/** @type {import("../../src/application/ports/auth-session-port.js").AuthSessionPort} */ (
authSession
),
fetcher,
clock: { now: () => 0, sleep: async () => {} },
scheduler,
},
TEST_HTTP_CONTRACT,
);
await client.execute({
operationId: "LIST_SAMPLE_RESOURCES",
routeId: "SAMPLE_RESOURCE_LIST",
operationId: "LIST_ENTITIES",
routeId: "TEST_ROUTE",
});
expect(fetcher).toHaveBeenCalledOnce();
+1 -1
View File
@@ -13,7 +13,7 @@ const validAttributes = {
error_kind: "SERVER_FAILURE",
http_status_group: "5xx",
attempt_count_bucket: "3",
route_id: "SAMPLE_RESOURCE_LIST",
route_id: "TEST_ROUTE",
};
describe("telemetry registry and redaction", () => {