384 lines
12 KiB
TypeScript
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");
|
|
});
|
|
});
|