Files
tech-log-frontend/tests/component/product-feature-switch.test.tsx
T
DongHyeonka 4566f2d7a8 refactor: make the public read port async so a network adapter can implement it
`PublicContentQueries` returned arrays, not promises. That signature is only
implementable by something already in memory, so the port could hold exactly
one adapter — the bundled fixture — and no amount of configuration could put
the public site on the backend. Turning it async is the change that makes a
second adapter possible; the adapter itself follows.

The markup is untouched. Every page reads a value and hands it to a
presentational component, so the shape those components receive is mapped at
the adapter boundary and nothing below the page changes.

Screens load through one query, not one per read. Several pages read in a loop
— the home timeline walks every project for its activity, the explore filter
walks search results to resolve titles — and a hook per read would mean a
variable number of hooks per render, which React forbids. `usePublicContent`
takes the whole screen's reads as one loader, where a loop is a loop and
`Promise.all` is available; the loops that used to be N sequential lookups now
issue together.

Two places deliberately do not show the loading surface. The explore filter
sits inside a page that already renders one, so a second skeleton would move
the layout under it — it keeps its structure and fills its options in when they
arrive. The search dialog is a type-ahead: re-querying per keystroke would
replace the results with a skeleton on every key, so it loads the catalog once
and applies the same predicate locally.

`usePublicContent` requires an object because `undefined` is how the query
layer says "no result yet". A loader returning the record itself would make a
missing slug indistinguishable from a request in flight, and the page would sit
on a skeleton instead of rendering its not-found route.

Studio's `resolvePublishedLabel` stays synchronous. It is called from inside
the public renderer, so making it async would push awaits through the render
tree; the shell loads the catalog once and the callback remains a lookup.

The component tests now assemble the query providers the running app assembles.
Without them the render throws "No QueryClient set" — not a harness quirk, but
the same failure the app would produce if it were mounted without its query
layer.
2026-08-20 16:53:51 +09:00

154 lines
6.1 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"
);
const { renderWithQueryProviders } = await import("../helpers/query-providers.tsx");
function renderAt(path: string, disabled: boolean) {
window.history.pushState({}, "", path);
return render(
renderWithQueryProviders(
<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);
}
});
});