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.
151 lines
6.0 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|