feat: connect application input and output boundaries
This commit is contained in:
@@ -17,22 +17,22 @@ function Defect() {
|
||||
|
||||
describe("render recovery boundaries", () => {
|
||||
it("catches programmer defects and emits best-effort safe telemetry", () => {
|
||||
const telemetry = { emit: vi.fn() };
|
||||
const onRenderFailure = vi.fn();
|
||||
render(
|
||||
<FeatureBoundary
|
||||
routeId="APP_HOME"
|
||||
buildId="build-a"
|
||||
telemetry={telemetry}
|
||||
onRenderFailure={onRenderFailure}
|
||||
>
|
||||
<Defect />
|
||||
</FeatureBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("error.render_failure");
|
||||
expect(telemetry.emit).toHaveBeenCalledWith("ui.render.failed", {
|
||||
route_id: "APP_HOME",
|
||||
build_id: "build-a",
|
||||
component_boundary: "feature",
|
||||
expect(onRenderFailure).toHaveBeenCalledWith({
|
||||
routeId: "APP_HOME",
|
||||
buildId: "build-a",
|
||||
boundaryName: "feature",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,12 +8,25 @@ import {
|
||||
createAnonymousSessionAdapter,
|
||||
createDemoSessionAdapter,
|
||||
} 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
|
||||
*/
|
||||
function renderRouter(session) {
|
||||
return render(
|
||||
<ApplicationProvider application={createTestApplication({ session })}>
|
||||
<AppRouter />
|
||||
</ApplicationProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("application router", () => {
|
||||
it("renders the app shell and not-found route without an API request", async () => {
|
||||
window.history.pushState({}, "", "/missing");
|
||||
render(<AppRouter authSession={createAnonymousSessionAdapter()} />);
|
||||
renderRouter(createAnonymousSessionAdapter());
|
||||
|
||||
expect(
|
||||
await screen.findByRole("heading", {
|
||||
@@ -27,7 +40,7 @@ describe("application router", () => {
|
||||
it("navigates between registry-backed example routes", async () => {
|
||||
const user = userEvent.setup();
|
||||
window.history.pushState({}, "", "/");
|
||||
render(<AppRouter authSession={createAnonymousSessionAdapter()} />);
|
||||
renderRouter(createAnonymousSessionAdapter());
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole("link", { name: "UI 구성요소" }),
|
||||
@@ -43,7 +56,7 @@ describe("application router", () => {
|
||||
const user = userEvent.setup();
|
||||
const authSession = createDemoSessionAdapter();
|
||||
window.history.pushState({}, "", "/sample/resources");
|
||||
render(<AppRouter authSession={authSession} />);
|
||||
renderRouter(authSession);
|
||||
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "세션이 필요합니다." }),
|
||||
@@ -59,7 +72,7 @@ describe("application router", () => {
|
||||
it("fails closed when the auth integration does not change state", async () => {
|
||||
const user = userEvent.setup();
|
||||
window.history.pushState({}, "", "/sample/resources");
|
||||
render(<AppRouter authSession={createAnonymousSessionAdapter()} />);
|
||||
renderRouter(createAnonymousSessionAdapter());
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "로그인 시작" }),
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { 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: "http://localhost:8080",
|
||||
REQUEST_TIMEOUT_MS: 10_000,
|
||||
MAX_RETRY_ATTEMPTS: 2,
|
||||
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",
|
||||
};
|
||||
|
||||
describe("production runtime application tree", () => {
|
||||
it("connects validated config and release through composition and ApplicationProvider", async () => {
|
||||
const fetcher = vi.fn(async (input) => {
|
||||
const url =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url;
|
||||
return Response.json(
|
||||
url.includes("release-manifest") ? releaseManifest : runtimeConfig,
|
||||
);
|
||||
});
|
||||
const composition = await createRuntimeComposition({
|
||||
fetcher,
|
||||
host: {},
|
||||
});
|
||||
|
||||
window.history.pushState({}, "", "/");
|
||||
render(<RuntimeApplication composition={composition} />);
|
||||
|
||||
expect(
|
||||
await screen.findByRole("heading", {
|
||||
name: "Clean Architecture Frontend",
|
||||
}),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
await screen.findByText("빌드 local-build · 릴리스 local-release"),
|
||||
).toBeVisible();
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
expect(composition).not.toHaveProperty("ports");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createRuntimeAdapters } from "../../../../src/bootstrap/runtime-adapters.js";
|
||||
|
||||
export const bootstrapFactory = createRuntimeAdapters;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createHttpClient } from "../../../../src/adapters/http/client.js";
|
||||
|
||||
export const leakedHttpFactory = createHttpClient;
|
||||
@@ -0,0 +1,4 @@
|
||||
export function DirectFetchPage() {
|
||||
void fetch("/api/forbidden");
|
||||
return <p>forbidden</p>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ApplicationOutputPorts } from "../../../../src/application/ports/out/application-output-ports.js";
|
||||
|
||||
export function OutputPortLeak(_props: ApplicationOutputPorts) {
|
||||
return <p>forbidden</p>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ApplicationApi } from "../../../src/application/create-application.js";
|
||||
|
||||
export const incompleteApplication: ApplicationApi = {
|
||||
session: {} as ApplicationApi["session"],
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ApplicationOutputPorts } from "../../../src/application/create-application.js";
|
||||
|
||||
export const invalidOutputPorts: ApplicationOutputPorts = {
|
||||
session: {
|
||||
getState: () => "signed-in",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.js";
|
||||
import { createApplication } from "../../src/application/create-application.js";
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* session?: import("../../src/application/ports/auth-session-port.js").AuthSessionPort,
|
||||
* 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
|
||||
* }} [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",
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createApplication,
|
||||
type ApplicationOutputPorts,
|
||||
} from "../../src/application/create-application.js";
|
||||
import { createTestApplication } from "../helpers/create-test-application.js";
|
||||
|
||||
describe("application input/output boundary", () => {
|
||||
it("exposes intent-oriented input APIs without leaking output ports", async () => {
|
||||
const application = createTestApplication();
|
||||
|
||||
expect(Object.keys(application)).toEqual([
|
||||
"session",
|
||||
"preferences",
|
||||
"diagnostics",
|
||||
"runtime",
|
||||
]);
|
||||
expect(application).not.toHaveProperty("storage");
|
||||
expect(application).not.toHaveProperty("telemetry");
|
||||
expect(application).not.toHaveProperty("releaseInfo");
|
||||
await expect(application.runtime.getReleaseSummary()).resolves.toEqual({
|
||||
buildId: "test-build",
|
||||
releaseId: "test-release",
|
||||
configSchemaVersion: "1",
|
||||
apiContractVersion: "1",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses fake output ports for preference, session, and safe diagnostics flows", () => {
|
||||
const write = vi.fn(() => ({ ok: true as const }));
|
||||
const emit = vi.fn();
|
||||
const ports = {
|
||||
session: {
|
||||
getState: () => "authenticated" as const,
|
||||
subscribe: () => () => {},
|
||||
beginSignIn: async () => {},
|
||||
signOut: async () => {},
|
||||
recover: async () => "restored" as const,
|
||||
},
|
||||
preferences: {
|
||||
read: () => ({ ok: true as const, value: "dark" }),
|
||||
write,
|
||||
remove: () => ({ ok: true as const }),
|
||||
},
|
||||
diagnostics: { emit },
|
||||
releaseInfo: {
|
||||
getCurrent: async () => ({
|
||||
buildId: "build-a",
|
||||
releaseId: "release-a",
|
||||
configSchemaVersion: "1",
|
||||
apiContractVersion: "1",
|
||||
assetManifestHash: "hash-a",
|
||||
}),
|
||||
},
|
||||
} satisfies ApplicationOutputPorts;
|
||||
const application = createApplication(ports);
|
||||
|
||||
expect(application.session.getSnapshot()).toBe("authenticated");
|
||||
expect(application.preferences.getColorScheme()).toBe("dark");
|
||||
expect(application.preferences.setColorScheme("light")).toEqual({ ok: true });
|
||||
expect(write).toHaveBeenCalledWith("COLOR_SCHEME", "light");
|
||||
|
||||
application.diagnostics.reportRenderFailure({
|
||||
routeId: "APP_HOME",
|
||||
buildId: "build-a",
|
||||
boundaryName: "route",
|
||||
});
|
||||
expect(emit).toHaveBeenCalledWith("ui.render.failed", {
|
||||
route_id: "APP_HOME",
|
||||
build_id: "build-a",
|
||||
component_boundary: "route",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not let a failing diagnostics output escape into presentation", () => {
|
||||
const application = createTestApplication({
|
||||
diagnostics: {
|
||||
emit() {
|
||||
throw new Error("sink details");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
application.diagnostics.reportRenderFailure({
|
||||
routeId: "APP_HOME",
|
||||
buildId: "build-a",
|
||||
boundaryName: "feature",
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -21,17 +21,9 @@ describe("color scheme policy", () => {
|
||||
});
|
||||
|
||||
it("applies a persisted preference before application paint", () => {
|
||||
const storage =
|
||||
/** @type {import("../../src/application/ports/storage-port.js").StoragePort} */ ({
|
||||
read: () => ({
|
||||
ok: /** @type {const} */ (true),
|
||||
value: "system",
|
||||
}),
|
||||
write: () => ({ ok: /** @type {const} */ (true) }),
|
||||
remove: () => ({ ok: /** @type {const} */ (true) }),
|
||||
});
|
||||
|
||||
const result = initializeColorScheme(storage, {
|
||||
const result = initializeColorScheme({
|
||||
getColorScheme: () => "system",
|
||||
}, {
|
||||
documentElement: document.documentElement,
|
||||
matchMedia: () =>
|
||||
/** @type {MediaQueryList} */ ({ matches: true }),
|
||||
|
||||
@@ -33,14 +33,13 @@ describe("runtime adapter composition", () => {
|
||||
host: {},
|
||||
});
|
||||
|
||||
expect(adapters.authSession.getState()).toBe("unauthenticated");
|
||||
expect(adapters.cache.read(["missing"])).toEqual({
|
||||
ok: true,
|
||||
value: undefined,
|
||||
});
|
||||
await expect(adapters.releaseInfo.getCurrent()).resolves.toMatchObject({
|
||||
expect(adapters.outputPorts.session.getState()).toBe("unauthenticated");
|
||||
await expect(adapters.outputPorts.releaseInfo.getCurrent()).resolves.toMatchObject({
|
||||
releaseId: "release-a",
|
||||
});
|
||||
expect(adapters.infrastructure.queryClient).toBeDefined();
|
||||
expect(adapters).not.toHaveProperty("http");
|
||||
expect(adapters).not.toHaveProperty("storage");
|
||||
});
|
||||
|
||||
it("fails closed when an external auth owner was not installed", async () => {
|
||||
@@ -52,6 +51,6 @@ describe("runtime adapter composition", () => {
|
||||
release,
|
||||
host: {},
|
||||
});
|
||||
expect(adapters.authSession.getState()).toBe("integration-failed");
|
||||
expect(adapters.outputPorts.session.getState()).toBe("integration-failed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { systemClock } from "../../src/application/ports/clock-port.js";
|
||||
import { systemClock } from "../../src/adapters/platform/system-clock.js";
|
||||
|
||||
describe("systemClock", () => {
|
||||
it("resolves after the requested duration", async () => {
|
||||
|
||||
Reference in New Issue
Block a user