feat: add grouped TechLog route contracts
This commit is contained in:
@@ -3,11 +3,31 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createMemoryRouter,
|
||||
matchRoutes,
|
||||
Outlet,
|
||||
RouterProvider,
|
||||
} from "react-router-dom";
|
||||
|
||||
import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.ts";
|
||||
import { ApplicationProvider } from "../../src/presentation/providers/application-provider.tsx";
|
||||
import { AppRouter } from "../../src/presentation/routes/app-router.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_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>;
|
||||
}
|
||||
|
||||
function renderRouter() {
|
||||
return render(
|
||||
@@ -22,6 +42,161 @@ function renderRouter() {
|
||||
}
|
||||
|
||||
describe("generic application router", () => {
|
||||
it("assembles generic Public and Studio parents with Studio catch-all precedence", () => {
|
||||
const registry = {
|
||||
APP_HOME: ROUTE_REGISTRY.APP_HOME,
|
||||
STUDIO_FIXTURE: {
|
||||
...ROUTE_REGISTRY.NOT_FOUND,
|
||||
routeId: "STUDIO_FIXTURE",
|
||||
path: "/studio/*",
|
||||
layoutGroup: "STUDIO" as const,
|
||||
},
|
||||
NOT_FOUND: ROUTE_REGISTRY.NOT_FOUND,
|
||||
};
|
||||
const runtime = {
|
||||
APP_HOME: ROUTE_RUNTIME.APP_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",
|
||||
);
|
||||
|
||||
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",
|
||||
);
|
||||
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",
|
||||
),
|
||||
).toThrow("Missing route runtime: APP_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",
|
||||
);
|
||||
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.APP_HOME,
|
||||
routeId: "PROTECTED_FIXTURE",
|
||||
path: "/private-fixture",
|
||||
access: "session-required",
|
||||
},
|
||||
},
|
||||
{
|
||||
PROTECTED_FIXTURE: {
|
||||
moduleId: "protected-fixture",
|
||||
Component: StudioFallbackFixture,
|
||||
},
|
||||
},
|
||||
{
|
||||
PUBLIC: <Outlet />,
|
||||
STUDIO: <Outlet />,
|
||||
},
|
||||
"test-build",
|
||||
);
|
||||
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("reaches the platform overview from the home starter actions", async () => {
|
||||
const user = userEvent.setup();
|
||||
window.history.pushState({}, "", "/");
|
||||
@@ -58,6 +233,16 @@ describe("generic application router", () => {
|
||||
expect(screen.getByRole("main")).toBeVisible();
|
||||
});
|
||||
|
||||
it("preserves authorization around grouped registered leaves", async () => {
|
||||
window.history.pushState({}, "", "/examples/reference-resources");
|
||||
renderRouter();
|
||||
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "세션이 필요합니다." }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("navigates between registry-backed platform routes", async () => {
|
||||
const user = userEvent.setup();
|
||||
window.history.pushState({}, "", "/");
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
TECH_LOG_ROUTE_REGISTRY,
|
||||
TECH_LOG_ROUTE_RUNTIME_CONTRACT,
|
||||
} from "../../../src/features/tech-log/contracts/tech-log-route-contract.ts";
|
||||
import { TECH_LOG_MESSAGE_CATALOGS } from "../../../src/features/tech-log/contracts/tech-log-message-catalog.ts";
|
||||
import { TECH_LOG_ROUTE_CODECS } from "../../../src/features/tech-log/presentation/tech-log-route-codecs.ts";
|
||||
import { ROUTE_REGISTRY } from "../../../src/features/installed-feature-contracts.ts";
|
||||
import { ROUTE_RUNTIME } from "../../../src/features/installed-feature-runtimes.tsx";
|
||||
import { buildRouteUrlFromContract } from "../../../src/presentation/routes/route-codecs.ts";
|
||||
import { PLATFORM_ROUTE_CODECS } from "../../../src/presentation/routes/platform-route-codecs.ts";
|
||||
|
||||
const expectedRoutes = [
|
||||
["TECH_LOG_HOME", "/", "PUBLIC", null, "TechLogHomeSearch"],
|
||||
["TECH_LOG_EXPLORE", "/explore", "PUBLIC", null, "TechLogExploreSearch"],
|
||||
["TECH_LOG_EXPLORE_KIND", "/explore/:kind", "PUBLIC", "TechLogExploreKindParams", "TechLogExploreKindSearch"],
|
||||
["TECH_LOG_CASE", "/cases/:slug", "PUBLIC", "TechLogSlugParams", "TechLogCaseStateSearch"],
|
||||
["TECH_LOG_REFERENCE", "/references/:slug", "PUBLIC", "TechLogSlugParams", null],
|
||||
["TECH_LOG_QUESTION", "/questions/:slug", "PUBLIC", "TechLogSlugParams", null],
|
||||
["TECH_LOG_TOPIC", "/topics/:slug", "PUBLIC", "TechLogSlugParams", null],
|
||||
["TECH_LOG_PROJECTS", "/projects", "PUBLIC", null, null],
|
||||
["TECH_LOG_PROJECT", "/projects/:slug", "PUBLIC", "TechLogSlugParams", null],
|
||||
["TECH_LOG_PROJECT_RECORDS", "/projects/:slug/records", "PUBLIC", "TechLogSlugParams", null],
|
||||
["TECH_LOG_PROJECT_DECISIONS", "/projects/:slug/decisions", "PUBLIC", "TechLogSlugParams", null],
|
||||
["TECH_LOG_PROJECT_ACTIVITY", "/projects/:slug/activity", "PUBLIC", "TechLogSlugParams", null],
|
||||
["TECH_LOG_RELEASES", "/releases", "PUBLIC", null, null],
|
||||
["TECH_LOG_RELEASE", "/releases/:version", "PUBLIC", "TechLogVersionParams", null],
|
||||
["TECH_LOG_PROFILE", "/profile", "PUBLIC", null, null],
|
||||
["TECH_LOG_SEARCH", "/search", "PUBLIC", null, "TechLogSearchQuery"],
|
||||
["TECH_LOG_STUDIO_HOME", "/studio", "STUDIO", null, null],
|
||||
["TECH_LOG_STUDIO_DOCUMENTS", "/studio/documents", "STUDIO", null, null],
|
||||
["TECH_LOG_STUDIO_DOCUMENT_NEW", "/studio/documents/new", "STUDIO", null, null],
|
||||
["TECH_LOG_STUDIO_DOCUMENT_EDIT", "/studio/documents/:id/edit", "STUDIO", "TechLogDocumentIdParams", null],
|
||||
["TECH_LOG_STUDIO_DOCUMENT_VALIDATION", "/studio/documents/:id/validation", "STUDIO", "TechLogDocumentIdParams", null],
|
||||
["TECH_LOG_STUDIO_DOCUMENT_PREVIEW", "/studio/documents/:id/preview", "STUDIO", "TechLogDocumentIdParams", null],
|
||||
["TECH_LOG_STUDIO_DOCUMENT_PUBLISH", "/studio/documents/:id/publish", "STUDIO", "TechLogDocumentIdParams", null],
|
||||
["TECH_LOG_STUDIO_PUBLICATIONS", "/studio/publications", "STUDIO", null, null],
|
||||
["TECH_LOG_STUDIO_PUBLICATION_PREVIEW", "/studio/publications/:publicationEventId/preview", "STUDIO", "TechLogPublicationEventIdParams", null],
|
||||
["TECH_LOG_STUDIO_NOT_FOUND", "/studio/*", "STUDIO", "TechLogStudioSplat", null],
|
||||
["NOT_FOUND", "*", "PUBLIC", "NotFoundSplat", null],
|
||||
] as const;
|
||||
|
||||
const expectedTitles = {
|
||||
TECH_LOG_HOME: "TechLog",
|
||||
TECH_LOG_EXPLORE: "탐색",
|
||||
TECH_LOG_EXPLORE_KIND: "유형별 탐색",
|
||||
TECH_LOG_CASE: "Case",
|
||||
TECH_LOG_REFERENCE: "Reference",
|
||||
TECH_LOG_QUESTION: "Open Question",
|
||||
TECH_LOG_TOPIC: "Topic",
|
||||
TECH_LOG_PROJECTS: "프로젝트",
|
||||
TECH_LOG_PROJECT: "프로젝트",
|
||||
TECH_LOG_PROJECT_RECORDS: "프로젝트 기록",
|
||||
TECH_LOG_PROJECT_DECISIONS: "프로젝트 결정",
|
||||
TECH_LOG_PROJECT_ACTIVITY: "프로젝트 활동",
|
||||
TECH_LOG_RELEASES: "변경 기록",
|
||||
TECH_LOG_RELEASE: "변경 기록",
|
||||
TECH_LOG_PROFILE: "프로필",
|
||||
TECH_LOG_SEARCH: "검색",
|
||||
TECH_LOG_STUDIO_HOME: "TechLog Studio",
|
||||
TECH_LOG_STUDIO_DOCUMENTS: "작업본",
|
||||
TECH_LOG_STUDIO_DOCUMENT_NEW: "새 문서",
|
||||
TECH_LOG_STUDIO_DOCUMENT_EDIT: "문서 편집",
|
||||
TECH_LOG_STUDIO_DOCUMENT_VALIDATION: "문서 검증",
|
||||
TECH_LOG_STUDIO_DOCUMENT_PREVIEW: "Public Preview",
|
||||
TECH_LOG_STUDIO_DOCUMENT_PUBLISH: "게시",
|
||||
TECH_LOG_STUDIO_PUBLICATIONS: "게시 기록",
|
||||
TECH_LOG_STUDIO_PUBLICATION_PREVIEW: "게시 Snapshot",
|
||||
TECH_LOG_STUDIO_NOT_FOUND: "Studio 화면을 찾을 수 없습니다",
|
||||
NOT_FOUND: "페이지를 찾을 수 없습니다.",
|
||||
} as const;
|
||||
|
||||
describe("TechLog route boundary contract", () => {
|
||||
it("freezes the standalone 27-route inventory before runtime installation", () => {
|
||||
expect(
|
||||
Object.values(TECH_LOG_ROUTE_REGISTRY).map((definition) => [
|
||||
definition.routeId,
|
||||
definition.path,
|
||||
definition.layoutGroup,
|
||||
definition.paramsSchema,
|
||||
definition.searchSchema,
|
||||
]),
|
||||
).toEqual(expectedRoutes);
|
||||
|
||||
expect(
|
||||
Object.values(TECH_LOG_ROUTE_REGISTRY).map((definition) => [
|
||||
definition.routeId,
|
||||
definition.access,
|
||||
definition.chunkId,
|
||||
]),
|
||||
).toEqual(
|
||||
expectedRoutes.map(([routeId]) => [
|
||||
routeId,
|
||||
"public",
|
||||
routeId === "NOT_FOUND"
|
||||
? "route-not-found"
|
||||
: `route-${routeId.toLowerCase().replaceAll("_", "-")}`,
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps runtime module and codec bindings aligned with every dormant route", () => {
|
||||
expect(Object.keys(TECH_LOG_ROUTE_RUNTIME_CONTRACT)).toEqual(
|
||||
expectedRoutes.map(([routeId]) => routeId),
|
||||
);
|
||||
for (const [routeId, , , paramsSchema, searchSchema] of expectedRoutes) {
|
||||
expect(TECH_LOG_ROUTE_RUNTIME_CONTRACT[routeId]).toEqual({
|
||||
routeId,
|
||||
moduleId:
|
||||
routeId === "NOT_FOUND"
|
||||
? "route-not-found"
|
||||
: `route-${routeId.toLowerCase().replaceAll("_", "-")}`,
|
||||
paramsCodec: paramsSchema ?? "none",
|
||||
searchCodec: searchSchema ?? "none",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves source navigation labels and the complete route message catalog", () => {
|
||||
expect(
|
||||
Object.values(TECH_LOG_ROUTE_REGISTRY)
|
||||
.filter((definition) => definition.navigationLabel !== null)
|
||||
.map(({ routeId, layoutGroup, navigationLabel, navigationOrder }) => [
|
||||
routeId,
|
||||
layoutGroup,
|
||||
navigationLabel,
|
||||
navigationOrder,
|
||||
]),
|
||||
).toEqual([
|
||||
["TECH_LOG_EXPLORE", "PUBLIC", "탐색", 10],
|
||||
["TECH_LOG_PROJECTS", "PUBLIC", "프로젝트", 20],
|
||||
["TECH_LOG_RELEASES", "PUBLIC", "변경 기록", 30],
|
||||
["TECH_LOG_PROFILE", "PUBLIC", "프로필", 40],
|
||||
["TECH_LOG_STUDIO_DOCUMENTS", "STUDIO", "작업본", 10],
|
||||
["TECH_LOG_STUDIO_DOCUMENT_NEW", "STUDIO", "새 문서", 30],
|
||||
["TECH_LOG_STUDIO_PUBLICATIONS", "STUDIO", "게시 기록", 20],
|
||||
]);
|
||||
|
||||
for (const locale of ["ko-KR", "en-US"] as const) {
|
||||
const catalog: Readonly<Record<string, string>> =
|
||||
TECH_LOG_MESSAGE_CATALOGS[locale];
|
||||
expect(Object.keys(catalog)).toHaveLength(54);
|
||||
for (const [routeId, title] of Object.entries(expectedTitles)) {
|
||||
expect(catalog[`route.${routeId}.title`]).toBe(title);
|
||||
expect(catalog[`route.${routeId}.navigation`]).toBe(title);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("normalizes source search semantics and rejects empty path parameters", () => {
|
||||
expect(
|
||||
TECH_LOG_ROUTE_CODECS.TechLogExploreSearch.parse({
|
||||
type: ["CASE", "REFERENCE"],
|
||||
topic: [" JPA ", "Redis"],
|
||||
project: " tech-log ",
|
||||
unknown: "drop-me",
|
||||
}),
|
||||
).toEqual({ type: "CASE", topic: "JPA", project: "tech-log" });
|
||||
expect(
|
||||
TECH_LOG_ROUTE_CODECS.TechLogHomeSearch.parse({
|
||||
focus: ["architecture", "delivery"],
|
||||
state: ["latest-empty", "site-error"],
|
||||
}),
|
||||
).toEqual({ focus: "architecture", state: "latest-empty" });
|
||||
expect(
|
||||
TECH_LOG_ROUTE_CODECS.TechLogSearchQuery.parse({ q: [" gateway ", "ignored"] }),
|
||||
).toEqual({ q: "gateway" });
|
||||
expect(
|
||||
TECH_LOG_ROUTE_CODECS.TechLogSlugParams.safeParse({ slug: "" }).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
TECH_LOG_ROUTE_CODECS.TechLogDocumentIdParams.safeParse({ id: "" }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("builds canonical TechLog URLs with non-empty string codecs", () => {
|
||||
const route = TECH_LOG_ROUTE_REGISTRY.TECH_LOG_STUDIO_PUBLICATION_PREVIEW;
|
||||
const runtime =
|
||||
TECH_LOG_ROUTE_RUNTIME_CONTRACT.TECH_LOG_STUDIO_PUBLICATION_PREVIEW;
|
||||
expect(
|
||||
buildRouteUrlFromContract(route, runtime, {
|
||||
...PLATFORM_ROUTE_CODECS,
|
||||
...TECH_LOG_ROUTE_CODECS,
|
||||
}, {
|
||||
params: { publicationEventId: "event/01" },
|
||||
}),
|
||||
).toBe("/studio/publications/event%2F01/preview");
|
||||
|
||||
expect(() =>
|
||||
buildRouteUrlFromContract(route, runtime, {
|
||||
...PLATFORM_ROUTE_CODECS,
|
||||
...TECH_LOG_ROUTE_CODECS,
|
||||
}, {
|
||||
params: { publicationEventId: "" },
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("does not install unfinished TechLog route or runtime entries", () => {
|
||||
expect(Object.keys(ROUTE_REGISTRY).some((routeId) => routeId.startsWith("TECH_LOG_"))).toBe(false);
|
||||
expect(Object.keys(ROUTE_RUNTIME).some((routeId) => routeId.startsWith("TECH_LOG_"))).toBe(false);
|
||||
expect(Object.keys(ROUTE_REGISTRY)).toEqual(Object.keys(ROUTE_RUNTIME));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user