Files
tech-log-frontend/tests/component/product-feature-switch.test.tsx
T
DongHyeonka 3b641906b8 feat: select the TechLog Studio adapter from runtime configuration
Adds TECH_LOG_STUDIO_SOURCE (MOCK | HTTP, default MOCK) to the V2 runtime
config schema so a build can switch createTechLogFeatureInstalledInput
between the mock and HTTP Studio gateways without a rebuild. V1 documents
predate the key and always normalize to MOCK. The HTTP gateway is
constructed with only { operations } per Task 4's actual signature -
no CSRF provider is wired here; that lands with attachCredentials at a
later composition-root task.

Updates every existing Studio test call site to the new required
createTechLogFeatureInstalledInput(context) signature via a shared
tests/helpers/studio-install-context.ts MOCK fixture, so the whole
existing Studio suite keeps exercising the mock adapter unchanged.
2026-08-18 01:57:15 +09:00

151 lines
6.0 KiB
TypeScript

// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.ts";
import { createTechLogFeatureInstalledInput } from "../../src/features/tech-log/adapters/create-tech-log-feature-input.ts";
import { MOCK_STUDIO_INSTALL_CONTEXT } from "../helpers/studio-install-context.ts";
import { createTestApplication } from "../helpers/create-test-application.ts";
import { createProductFeaturesStub } from "../helpers/runtime-capabilities-stub.ts";
/**
* §3.5. The runtime kill switch, exercised through the running app.
*
* Withdrawing a feature from navigation is not the same as taking it out of
* service: a typed deep link would still mount it. Both halves are asserted on
* the same render, so the switch cannot be half-wired.
*
* The template asserted this by rendering its own demonstration screens. Those
* screens exist to explain the template, and a product replaces them with its
* domain — so a test that needs them makes deleting them look like a
* regression. It is not: the mechanism under test is the ownership lookup plus
* `isFeatureActive`, and neither has anything to do with which screens ship.
*
* So the ownership map is the fixture. `ROUTE_FEATURE_OWNER` is empty in this
* product because no installed feature contributes a route; one real registered
* route is attributed to a real installed feature here, and everything else —
* router, shell, components, codecs — is the product's own.
*/
const OWNED_ROUTE_ID = "TECH_LOG_EXPLORE";
const OWNER_FEATURE_ID = "reference-feature";
const OWNED_ROUTE_PATH = "/explore";
const OWNED_ROUTE_NAV_LABEL = "탐색";
vi.mock("../../src/features/installed-feature-contracts.ts", async (original) => {
const actual = await original<
typeof import("../../src/features/installed-feature-contracts.ts")
>();
return {
...actual,
ROUTE_FEATURE_OWNER: Object.freeze({
[OWNED_ROUTE_ID]: OWNER_FEATURE_ID,
}),
};
});
const { AppRouter } = await import("../../src/presentation/routes/app-router.tsx");
const { ApplicationProvider } = await import(
"../../src/presentation/providers/application-provider.tsx"
);
function renderAt(path: string, disabled: boolean) {
window.history.pushState({}, "", path);
return render(
<ApplicationProvider
application={createTestApplication({
session: createAnonymousSessionAdapter(),
featureInputs: { "tech-log": createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input },
...(disabled
? {
productFeatures: createProductFeaturesStub({
[OWNER_FEATURE_ID]: "DISABLED_BY_CONFIG",
}),
}
: {}),
})}
>
<AppRouter />
</ApplicationProvider>,
);
}
function outOfServiceSurface() {
return screen.queryByText(
(_, element) =>
element?.getAttribute("data-disabled-feature") === OWNER_FEATURE_ID,
);
}
describe("runtime product feature switch", () => {
it("takes the owned route out of service for a direct deep link", async () => {
renderAt(OWNED_ROUTE_PATH, true);
const surface = await screen.findByText(
(_, element) =>
element?.getAttribute("data-disabled-feature") === OWNER_FEATURE_ID,
{},
{ timeout: 5000 },
);
expect(surface).toBeTruthy();
});
it("serves the same deep link while the feature is active", async () => {
renderAt(OWNED_ROUTE_PATH, false);
expect(outOfServiceSurface()).toBeNull();
});
});
/**
* Composition invariants that hold whichever screens a product ships. The first
* one is what caught a bad merge resolution: composing the route registry from
* every installed feature's `contract.routes` registered the reference
* feature's paths after this product had deleted their components.
*/
describe("route composition", () => {
it("registers no route that cannot be mounted", async () => {
const { ROUTE_REGISTRY } = await vi.importActual<
typeof import("../../src/features/installed-feature-contracts.ts")
>("../../src/features/installed-feature-contracts.ts");
const { ROUTE_RUNTIME } = await import(
"../../src/features/installed-feature-runtimes.tsx"
);
const unmountable = Object.keys(ROUTE_REGISTRY).filter(
(routeId) => !(routeId in ROUTE_RUNTIME),
);
expect(unmountable).toEqual([]);
});
/**
* The other half of the switch — withdrawing a disabled feature's entry from
* navigation — lives in the template's `PrimaryNavigation`, which this
* product does not render: TechLog's public header is a hand-written list of
* paths, and the studio shell has its own. So the withdrawal half is not
* implemented here.
*
* That is harmless only while no feature-owned route is navigable, which is
* what this asserts. If it fails, the header has to consult
* `ROUTE_FEATURE_OWNER` (or navigation has to move back onto
* `NAVIGATION_ROUTES`) before that route ships — otherwise a disabled
* feature would keep advertising a link to a page that refuses to load.
*/
it("has no navigable feature-owned route while the shell is not feature-aware", async () => {
const actual = await vi.importActual<
typeof import("../../src/features/installed-feature-contracts.ts")
>("../../src/features/installed-feature-contracts.ts");
const navigableOwned = actual.NAVIGATION_ROUTES.filter(
(definition) => actual.ROUTE_FEATURE_OWNER[definition.routeId] !== undefined,
).map((definition) => definition.routeId);
expect(navigableOwned).toEqual([]);
});
it("attributes ownership only to routes the product registered", async () => {
const actual = await vi.importActual<
typeof import("../../src/features/installed-feature-contracts.ts")
>("../../src/features/installed-feature-contracts.ts");
for (const routeId of Object.keys(actual.ROUTE_FEATURE_OWNER)) {
expect(Object.keys(actual.ROUTE_REGISTRY)).toContain(routeId);
}
});
});