`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.
429 lines
14 KiB
TypeScript
429 lines
14 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { render, screen, waitFor, within } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { describe, expect, expectTypeOf, it } from "vitest";
|
|
import { z } from "zod";
|
|
import {
|
|
createMemoryRouter,
|
|
matchRoutes,
|
|
Outlet,
|
|
RouterProvider,
|
|
} from "react-router-dom";
|
|
|
|
import { createExternalAuthSessionAdapter } from "../../src/adapters/auth/external-session-adapter.ts";
|
|
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 { TECH_LOG_FEATURE_ID } from "../../src/features/tech-log/application/tech-log-feature-input.ts";
|
|
import { ApplicationProvider } from "../../src/presentation/providers/application-provider.tsx";
|
|
import { renderWithQueryProviders } from "../helpers/query-providers.tsx";
|
|
import {
|
|
AppRouter,
|
|
createGroupedRouteObjects,
|
|
} from "../../src/presentation/routes/app-router.tsx";
|
|
import { createTestApplication } from "../helpers/create-test-application.ts";
|
|
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.ts";
|
|
import {
|
|
ROUTE_CODECS,
|
|
ROUTE_RUNTIME,
|
|
} from "../../src/features/installed-feature-runtimes.tsx";
|
|
import { LocaleProvider } from "../../src/presentation/i18n/index.ts";
|
|
import { SessionProvider } from "../../src/presentation/providers/session-provider.tsx";
|
|
import { ThemeProvider } from "../../src/presentation/providers/theme-provider.tsx";
|
|
import { useRouteInput } from "../../src/presentation/routes/route-input.tsx";
|
|
|
|
function StudioFallbackFixture() {
|
|
const input = useRouteInput();
|
|
return <h1>{String(input.params["*"])}</h1>;
|
|
}
|
|
|
|
const reviewFixtureCodecs = Object.freeze({
|
|
ReviewFixtureParams: z
|
|
.object({ reviewId: z.string().min(1) })
|
|
.strict(),
|
|
ReviewFixtureSearch: z
|
|
.object({
|
|
filter: z.preprocess(
|
|
(value) => (Array.isArray(value) ? value[0] : value),
|
|
z.string().trim().min(1).optional(),
|
|
),
|
|
})
|
|
.strip(),
|
|
});
|
|
|
|
const groupedFixtureCodecs = Object.freeze({
|
|
none: ROUTE_CODECS.none,
|
|
NotFoundSplat: ROUTE_CODECS.NotFoundSplat,
|
|
});
|
|
|
|
const reviewFixtureRegistry = Object.freeze({
|
|
REVIEW_FIXTURE: Object.freeze({
|
|
...ROUTE_REGISTRY.TECH_LOG_HOME,
|
|
routeId: "REVIEW_FIXTURE",
|
|
path: "/review/:reviewId",
|
|
paramsSchema: "ReviewFixtureParams",
|
|
searchSchema: "ReviewFixtureSearch",
|
|
}),
|
|
});
|
|
|
|
function ReviewRouteFixture() {
|
|
const input = useRouteInput<"REVIEW_FIXTURE">();
|
|
expectTypeOf(input.routeId).toEqualTypeOf<"REVIEW_FIXTURE">();
|
|
return (
|
|
<section>
|
|
<h1>{input.routeId}</h1>
|
|
<p data-testid="review-param">{String(input.params.reviewId)}</p>
|
|
<p data-testid="review-search">{JSON.stringify(input.search)}</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
const reviewFixtureRuntime = Object.freeze({
|
|
REVIEW_FIXTURE: Object.freeze({
|
|
moduleId: "review-fixture",
|
|
Component: ReviewRouteFixture,
|
|
}),
|
|
});
|
|
|
|
function compileTimeGroupedRouteContract() {
|
|
// @ts-expect-error A grouped route composition must provide its codec registry.
|
|
createGroupedRouteObjects(
|
|
reviewFixtureRegistry,
|
|
reviewFixtureRuntime,
|
|
{ PUBLIC: <Outlet />, STUDIO: <Outlet /> },
|
|
"type-test-build",
|
|
);
|
|
return createGroupedRouteObjects(
|
|
reviewFixtureRegistry,
|
|
reviewFixtureRuntime,
|
|
{ PUBLIC: <Outlet />, STUDIO: <Outlet /> },
|
|
"type-test-build",
|
|
reviewFixtureCodecs,
|
|
);
|
|
}
|
|
void compileTimeGroupedRouteContract;
|
|
|
|
/**
|
|
* Studio routes are `session-required`, so a Studio assertion needs a session
|
|
* that says so — with the anonymous adapter the router correctly renders the
|
|
* sign-in surface instead of the page, which is what the gate is for.
|
|
*/
|
|
function createSignedInSessionAdapter() {
|
|
return createExternalAuthSessionAdapter({
|
|
readState: () => "authenticated" as const,
|
|
subscribe: () => () => {},
|
|
beginSignIn: async () => {},
|
|
signOut: async () => {},
|
|
attachCredential: async () => ({ headers: {} }),
|
|
recoverSession: async () => "restored" as const,
|
|
notifyUnauthenticated: () => {},
|
|
});
|
|
}
|
|
|
|
function renderRouter(session = createAnonymousSessionAdapter()) {
|
|
const techLog = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT);
|
|
return render(
|
|
renderWithQueryProviders(
|
|
<ApplicationProvider
|
|
application={createTestApplication({
|
|
session,
|
|
featureInputs: { [TECH_LOG_FEATURE_ID]: techLog.input },
|
|
})}
|
|
>
|
|
<AppRouter />
|
|
</ApplicationProvider>,
|
|
),
|
|
);
|
|
}
|
|
|
|
describe("generic application router", () => {
|
|
it("renders and canonicalizes an isolated non-installed route codec contract", async () => {
|
|
expect(ROUTE_REGISTRY).not.toHaveProperty("REVIEW_FIXTURE");
|
|
expect(ROUTE_CODECS).not.toHaveProperty("ReviewFixtureParams");
|
|
expect(ROUTE_CODECS).not.toHaveProperty("ReviewFixtureSearch");
|
|
|
|
const routes = createGroupedRouteObjects(
|
|
reviewFixtureRegistry,
|
|
reviewFixtureRuntime,
|
|
{ PUBLIC: <Outlet />, STUDIO: <Outlet /> },
|
|
"test-build",
|
|
reviewFixtureCodecs,
|
|
);
|
|
const router = createMemoryRouter(routes, {
|
|
initialEntries: [
|
|
"/review/non-empty?filter=%20first%20&filter=second&unknown=drop",
|
|
],
|
|
});
|
|
|
|
render(
|
|
renderWithQueryProviders(
|
|
<ApplicationProvider application={createTestApplication()}>
|
|
<LocaleProvider>
|
|
<ThemeProvider>
|
|
<SessionProvider>
|
|
<RouterProvider router={router} />
|
|
</SessionProvider>
|
|
</ThemeProvider>
|
|
</LocaleProvider>
|
|
</ApplicationProvider>,
|
|
),
|
|
);
|
|
|
|
expect(
|
|
await screen.findByRole("heading", { name: "REVIEW_FIXTURE" }),
|
|
).toBeVisible();
|
|
expect(screen.getByTestId("review-param")).toHaveTextContent("non-empty");
|
|
expect(screen.getByTestId("review-search")).toHaveTextContent(
|
|
'{"filter":"first"}',
|
|
);
|
|
await waitFor(() =>
|
|
expect(router.state.location).toMatchObject({
|
|
pathname: "/review/non-empty",
|
|
search: "?filter=first",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("assembles generic Public and Studio parents with Studio catch-all precedence", () => {
|
|
const registry = {
|
|
TECH_LOG_HOME: ROUTE_REGISTRY.TECH_LOG_HOME,
|
|
STUDIO_FIXTURE: {
|
|
...ROUTE_REGISTRY.NOT_FOUND,
|
|
routeId: "STUDIO_FIXTURE",
|
|
path: "/studio/*",
|
|
layoutGroup: "STUDIO" as const,
|
|
},
|
|
NOT_FOUND: ROUTE_REGISTRY.NOT_FOUND,
|
|
};
|
|
const runtime = {
|
|
TECH_LOG_HOME: ROUTE_RUNTIME.TECH_LOG_HOME,
|
|
STUDIO_FIXTURE: ROUTE_RUNTIME.NOT_FOUND,
|
|
NOT_FOUND: ROUTE_RUNTIME.NOT_FOUND,
|
|
};
|
|
const routes = createGroupedRouteObjects(
|
|
registry,
|
|
runtime,
|
|
{
|
|
PUBLIC: <div data-layout="public" />,
|
|
STUDIO: <div data-layout="studio" />,
|
|
},
|
|
"test-build",
|
|
groupedFixtureCodecs,
|
|
);
|
|
|
|
expect(routes.map((route) => route.id)).toEqual([
|
|
"STUDIO_LAYOUT",
|
|
"PUBLIC_LAYOUT",
|
|
]);
|
|
expect(
|
|
matchRoutes(routes, "/studio/unknown")?.map((match) => match.route.id),
|
|
).toEqual(["STUDIO_LAYOUT", "STUDIO_FIXTURE"]);
|
|
expect(
|
|
matchRoutes(routes, "/publicly-unknown")?.map((match) => match.route.id),
|
|
).toEqual(["PUBLIC_LAYOUT", "NOT_FOUND"]);
|
|
|
|
const installedRoutes = createGroupedRouteObjects(
|
|
ROUTE_REGISTRY,
|
|
ROUTE_RUNTIME,
|
|
{
|
|
PUBLIC: <div data-layout="public" />,
|
|
STUDIO: <div data-layout="studio" />,
|
|
},
|
|
"test-build",
|
|
groupedFixtureCodecs,
|
|
);
|
|
expect(installedRoutes[1]?.children?.at(-1)?.id).toBe("NOT_FOUND");
|
|
});
|
|
|
|
it("rejects a grouped registry whose leaf runtime is missing", () => {
|
|
expect(() =>
|
|
createGroupedRouteObjects(
|
|
ROUTE_REGISTRY,
|
|
{},
|
|
{
|
|
PUBLIC: <div data-layout="public" />,
|
|
STUDIO: <div data-layout="studio" />,
|
|
},
|
|
"test-build",
|
|
groupedFixtureCodecs,
|
|
),
|
|
).toThrow("Missing route runtime: TECH_LOG_HOME");
|
|
});
|
|
|
|
it("renders a non-installed Studio wildcard leaf without rewriting its URL", async () => {
|
|
const routes = createGroupedRouteObjects(
|
|
{
|
|
STUDIO_FIXTURE: {
|
|
...ROUTE_REGISTRY.NOT_FOUND,
|
|
routeId: "STUDIO_FIXTURE",
|
|
path: "/studio/*",
|
|
layoutGroup: "STUDIO",
|
|
},
|
|
},
|
|
{
|
|
STUDIO_FIXTURE: {
|
|
moduleId: "studio-fixture",
|
|
Component: StudioFallbackFixture,
|
|
},
|
|
},
|
|
{
|
|
PUBLIC: <Outlet />,
|
|
STUDIO: (
|
|
<section data-testid="studio-layout">
|
|
<Outlet />
|
|
</section>
|
|
),
|
|
},
|
|
"test-build",
|
|
groupedFixtureCodecs,
|
|
);
|
|
const router = createMemoryRouter(routes, {
|
|
initialEntries: ["/studio/unknown/path"],
|
|
});
|
|
|
|
render(
|
|
renderWithQueryProviders(
|
|
<ApplicationProvider application={createTestApplication()}>
|
|
<LocaleProvider>
|
|
<ThemeProvider>
|
|
<SessionProvider>
|
|
<RouterProvider router={router} />
|
|
</SessionProvider>
|
|
</ThemeProvider>
|
|
</LocaleProvider>
|
|
</ApplicationProvider>,
|
|
),
|
|
);
|
|
|
|
expect(await screen.findByTestId("studio-layout")).toBeVisible();
|
|
expect(
|
|
await screen.findByRole("heading", { name: "unknown/path" }),
|
|
).toBeVisible();
|
|
expect(router.state.location.pathname).toBe("/studio/unknown/path");
|
|
});
|
|
|
|
it("applies authorization from a non-installed route definition", async () => {
|
|
const routes = createGroupedRouteObjects(
|
|
{
|
|
PROTECTED_FIXTURE: {
|
|
...ROUTE_REGISTRY.TECH_LOG_HOME,
|
|
routeId: "PROTECTED_FIXTURE",
|
|
path: "/private-fixture",
|
|
access: "session-required",
|
|
searchSchema: null,
|
|
},
|
|
},
|
|
{
|
|
PROTECTED_FIXTURE: {
|
|
moduleId: "protected-fixture",
|
|
Component: StudioFallbackFixture,
|
|
},
|
|
},
|
|
{
|
|
PUBLIC: <Outlet />,
|
|
STUDIO: <Outlet />,
|
|
},
|
|
"test-build",
|
|
groupedFixtureCodecs,
|
|
);
|
|
const router = createMemoryRouter(routes, {
|
|
initialEntries: ["/private-fixture"],
|
|
});
|
|
|
|
render(
|
|
renderWithQueryProviders(
|
|
<ApplicationProvider application={createTestApplication()}>
|
|
<LocaleProvider>
|
|
<ThemeProvider>
|
|
<SessionProvider>
|
|
<RouterProvider router={router} />
|
|
</SessionProvider>
|
|
</ThemeProvider>
|
|
</LocaleProvider>
|
|
</ApplicationProvider>,
|
|
),
|
|
);
|
|
|
|
expect(
|
|
await screen.findByRole("heading", { name: "세션이 필요합니다." }),
|
|
).toBeVisible();
|
|
});
|
|
|
|
it("renders the installed TechLog home in the Public layout", async () => {
|
|
const user = userEvent.setup();
|
|
window.history.pushState({}, "", "/");
|
|
renderRouter();
|
|
|
|
expect(
|
|
await screen.findByRole("heading", { name: "TechLog", level: 1 }),
|
|
).toBeVisible();
|
|
expect(screen.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
|
|
await user.click(
|
|
within(screen.getByRole("navigation", { name: "주요 탐색" })).getByRole(
|
|
"link",
|
|
{ name: "프로젝트" },
|
|
),
|
|
);
|
|
expect(
|
|
await screen.findByRole("heading", { name: "프로젝트", level: 1 }),
|
|
).toBeVisible();
|
|
expect(window.location.pathname).toBe("/projects");
|
|
});
|
|
|
|
it("keeps the unreachable client-side Public fallback inside its accessible shell", async () => {
|
|
window.history.pushState({}, "", "/missing");
|
|
renderRouter();
|
|
|
|
expect(
|
|
await screen.findByRole("heading", { name: "페이지를 찾을 수 없습니다." }),
|
|
).toBeVisible();
|
|
expect(screen.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
|
|
expect(screen.queryByText("Not Found", { exact: true })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("keeps a signed-out visitor out of the Studio layout entirely", async () => {
|
|
window.history.pushState({}, "", "/studio");
|
|
renderRouter();
|
|
|
|
// Not merely "no data": the Studio surface itself must not mount. Every
|
|
// TechLog route used to register as `access: "public"`, so a signed-out
|
|
// visitor who typed /studio got the shell, the navigation, and the page —
|
|
// and the page then issued Studio API calls.
|
|
await screen.findByRole("heading", { name: /세션|로그인/ });
|
|
expect(
|
|
screen.queryByRole("heading", { name: "작업 흐름" }),
|
|
).not.toBeInTheDocument();
|
|
expect(
|
|
screen.queryByRole("navigation", { name: "Studio 주 탐색" }),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("keeps Studio routes inside the persistent Studio layout", async () => {
|
|
window.history.pushState({}, "", "/studio");
|
|
renderRouter(createSignedInSessionAdapter());
|
|
|
|
expect(
|
|
await screen.findByRole("heading", { name: "작업 흐름" }),
|
|
).toBeVisible();
|
|
expect(screen.getByRole("navigation", { name: "Studio 주 탐색" })).toBeVisible();
|
|
expect(screen.getByRole("link", { name: "공개 사이트 보기" })).toHaveAttribute(
|
|
"href",
|
|
"/",
|
|
);
|
|
});
|
|
|
|
it("gives the Studio wildcard precedence over the Public not-found route", async () => {
|
|
window.history.pushState({}, "", "/studio/missing");
|
|
renderRouter(createSignedInSessionAdapter());
|
|
|
|
expect(
|
|
await screen.findByRole("heading", {
|
|
name: "Studio 화면을 찾을 수 없습니다",
|
|
}),
|
|
).toBeVisible();
|
|
expect(screen.getByRole("navigation", { name: "Studio 주 탐색" })).toBeVisible();
|
|
expect(window.location.pathname).toBe("/studio/missing");
|
|
});
|
|
});
|