feat: add removable reference feature vertical slice
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,7 +49,7 @@ describe("frontend failure classification", () => {
|
||||
};
|
||||
const result = createFailure(
|
||||
"SERVER_FAILURE",
|
||||
"LIST_SAMPLE_RESOURCES",
|
||||
"LIST_ENTITIES",
|
||||
0,
|
||||
untrustedDetails,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user