Files
tech-log-frontend/tests/component/router.test.tsx
T

384 lines
12 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 { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.ts";
import { createTechLogFeatureInstalledInput } from "../../src/features/tech-log/adapters/create-tech-log-feature-input.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 {
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;
function renderRouter() {
const techLog = createTechLogFeatureInstalledInput();
return render(
<ApplicationProvider
application={createTestApplication({
session: createAnonymousSessionAdapter(),
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(
<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(
<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(
<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 Studio routes inside the persistent Studio layout", async () => {
window.history.pushState({}, "", "/studio");
renderRouter();
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();
expect(
await screen.findByRole("heading", {
name: "Studio 화면을 찾을 수 없습니다",
}),
).toBeVisible();
expect(screen.getByRole("navigation", { name: "Studio 주 탐색" })).toBeVisible();
expect(window.location.pathname).toBe("/studio/missing");
});
});