Publishing needs a topic and nothing could create one. The backend now owns that surface; this is its consumer — the management contract vendored, a gateway over its nine operations, and one Studio screen that lists, creates, and deletes topics and projects. The screen adds no CSS. It reuses the classes the working-copy list already uses, so it inherits Studio's spacing, type, and colour rather than growing a second visual vocabulary beside them. Scope stops at list/create/delete: renaming, phase changes, and visibility are implemented in the backend and declared in the contract, but their screens are a separate design. Two real defects surfaced while making the public port async, and both would have shipped: The search page and the header search dialog shared a query key. With an empty query, `["tech-log","search",""]` was identical for both, so react-query handed one surface the other's cache — different shapes — and the page died reading a field that was not there. Keys now name the surface. The explore filter's selects are uncontrolled and read `defaultValue`, which React applies once. Their options arrive later now, so the first render had nothing to match and the value stayed empty: a topic in the URL no longer showed as selected. The form key includes whether the catalog has arrived, so it remounts with the options present. Controlled inputs would be the other answer, but this form submits to build a URL — the URL owns the value. The route brought its own bookkeeping: a build chunk, a manual accessibility evidence file, and the CI artifact baseline that counts them. The gate pins a digest of its own shape precisely so a new route cannot slip in without that count being reviewed. Test harnesses that render public screens now assemble the query providers and await the settled paint, because the screens they render became async.
395 lines
16 KiB
TypeScript
395 lines
16 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { render, screen, within } from "@testing-library/react";
|
|
import { type ComponentType } from "react";
|
|
import {
|
|
createMemoryRouter,
|
|
Outlet,
|
|
RouterProvider,
|
|
} from "react-router-dom";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
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_ROUTE_REGISTRY,
|
|
TECH_LOG_ROUTE_RUNTIME_CONTRACT,
|
|
} from "../../../src/features/tech-log/contracts/tech-log-route-contract.ts";
|
|
import { CasePage } from "../../../src/features/tech-log/presentation/public/pages/case-page.tsx";
|
|
import { ProfilePage } from "../../../src/features/tech-log/presentation/public/pages/profile-page.tsx";
|
|
import { ProjectActivityPage } from "../../../src/features/tech-log/presentation/public/pages/project-activity-page.tsx";
|
|
import { ProjectDecisionsPage } from "../../../src/features/tech-log/presentation/public/pages/project-decisions-page.tsx";
|
|
import { ProjectOverviewPage } from "../../../src/features/tech-log/presentation/public/pages/project-overview-page.tsx";
|
|
import { ProjectRecordsPage } from "../../../src/features/tech-log/presentation/public/pages/project-records-page.tsx";
|
|
import { ProjectsPage } from "../../../src/features/tech-log/presentation/public/pages/projects-page.tsx";
|
|
import { PublicNotFoundPage } from "../../../src/features/tech-log/presentation/public/pages/public-not-found-page.tsx";
|
|
import { ReleasePage } from "../../../src/features/tech-log/presentation/public/pages/release-page.tsx";
|
|
import { ReleasesPage } from "../../../src/features/tech-log/presentation/public/pages/releases-page.tsx";
|
|
import { PublicShell } from "../../../src/features/tech-log/presentation/public/public-shell.tsx";
|
|
import { TECH_LOG_ROUTE_CODECS } from "../../../src/features/tech-log/presentation/tech-log-route-codecs.ts";
|
|
import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.tsx";
|
|
import { createGroupedRouteObjects } from "../../../src/presentation/routes/app-router.tsx";
|
|
import { PLATFORM_ROUTE_CODECS } from "../../../src/presentation/routes/platform-route-codecs.ts";
|
|
import { createTestApplication } from "../../helpers/create-test-application.ts";
|
|
import { renderWithQueryProviders } from "../../helpers/query-providers.tsx";
|
|
|
|
const routeCodecs = Object.freeze({
|
|
...PLATFORM_ROUTE_CODECS,
|
|
...TECH_LOG_ROUTE_CODECS,
|
|
});
|
|
|
|
const routeComponents = {
|
|
TECH_LOG_PROJECTS: ProjectsPage,
|
|
TECH_LOG_PROJECT: ProjectOverviewPage,
|
|
TECH_LOG_PROJECT_RECORDS: ProjectRecordsPage,
|
|
TECH_LOG_PROJECT_DECISIONS: ProjectDecisionsPage,
|
|
TECH_LOG_PROJECT_ACTIVITY: ProjectActivityPage,
|
|
TECH_LOG_RELEASES: ReleasesPage,
|
|
TECH_LOG_RELEASE: ReleasePage,
|
|
TECH_LOG_PROFILE: ProfilePage,
|
|
TECH_LOG_CASE: CasePage,
|
|
NOT_FOUND: PublicNotFoundPage,
|
|
} as const satisfies Record<string, ComponentType>;
|
|
|
|
type PublicIndexRouteId = keyof typeof routeComponents;
|
|
|
|
async function renderPublicRoute(routeId: PublicIndexRouteId, initialEntry: string) {
|
|
const routeIds = routeId === "NOT_FOUND" ? [routeId] : [routeId, "NOT_FOUND" as const];
|
|
const registry = Object.fromEntries(
|
|
routeIds.map((id) => [id, TECH_LOG_ROUTE_REGISTRY[id]]),
|
|
);
|
|
const runtime = Object.fromEntries(
|
|
routeIds.map((id) => [
|
|
id,
|
|
{
|
|
moduleId: TECH_LOG_ROUTE_RUNTIME_CONTRACT[id].moduleId,
|
|
Component: routeComponents[id],
|
|
},
|
|
]),
|
|
);
|
|
const router = createMemoryRouter(
|
|
createGroupedRouteObjects(
|
|
registry,
|
|
runtime,
|
|
{
|
|
PUBLIC: (
|
|
<PublicShell>
|
|
<Outlet />
|
|
</PublicShell>
|
|
),
|
|
STUDIO: <Outlet />,
|
|
},
|
|
"task-9-test-build",
|
|
routeCodecs,
|
|
),
|
|
{ initialEntries: [initialEntry] },
|
|
);
|
|
const techLog = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input;
|
|
const view = render(
|
|
renderWithQueryProviders(
|
|
<ApplicationProvider
|
|
application={createTestApplication({
|
|
featureInputs: { "tech-log": techLog },
|
|
})}
|
|
>
|
|
<RouterProvider router={router} />
|
|
</ApplicationProvider>,
|
|
));
|
|
// 포트가 async 가 되면서 첫 페인트에는 데이터가 없다. 화면이 정착한 뒤
|
|
// 단언하도록 여기서 한 번 기다린다 — 각 테스트에 흩어 놓으면 빠뜨린 곳이 생긴다.
|
|
await screen.findByRole("main");
|
|
return { ...view, router };
|
|
}
|
|
|
|
function projectNavigation() {
|
|
return screen.getByRole("navigation", { name: "프로젝트 탐색" });
|
|
}
|
|
|
|
describe("TechLog project screens", () => {
|
|
it("renders the exact ordered project index and project links", async () => {
|
|
const { container } = await renderPublicRoute("TECH_LOG_PROJECTS", "/projects");
|
|
const main = screen.getByRole("main");
|
|
|
|
expect(within(main).getByRole("heading", { level: 1, name: "프로젝트" })).toBeVisible();
|
|
expect(
|
|
within(main).getByText(
|
|
"Case와 Reference, Question을 실제 설계 목표와 결정의 흐름으로 묶어 봅니다.",
|
|
),
|
|
).toBeVisible();
|
|
expect(
|
|
Array.from(container.querySelectorAll(".project-index-list > li > a"), (link) => ({
|
|
href: link.getAttribute("href"),
|
|
text: link.textContent,
|
|
})),
|
|
).toEqual([
|
|
{
|
|
href: "/projects/backend-skeleton",
|
|
text: "01Backend SkeletonDESIGN저장소·Redis·JPA·MongoDB 같은 기술을 붙일 때 애플리케이션 경계를 다시 만들지 않도록 공통 계약을 정리하는 프로젝트입니다.현재 목표Filesystem과 Object Storage를 하나의 StoragePort로 통합다음 작업MinIO Adapter와 공통 계약 테스트 연결↗",
|
|
},
|
|
{
|
|
href: "/projects/auth-lab",
|
|
text: "02Auth LabVALIDATION브라우저·Edge·Spring 사이에서 Token과 Session의 책임을 나누고 인증 경계를 검증하는 프로젝트입니다.현재 목표oauth2-proxy 뒤의 Spring 신뢰 경계 결정다음 작업JWT 재검증안과 신뢰 헤더안의 위협 모델 비교↗",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
path: "/projects/backend-skeleton",
|
|
title: "Backend Skeleton",
|
|
current: "개요",
|
|
thesis: "기술별 기능을 많이 제공하는 것보다 교체 가능한 경계와 검증 가능한 계약을 먼저 고정합니다.",
|
|
stats: ["4공개 기록", "2설계 결정", "3활동 기록"],
|
|
topics: ["Backend Architecture", "JPA", "Redis", "Storage"],
|
|
},
|
|
{
|
|
path: "/projects/auth-lab",
|
|
title: "Auth Lab",
|
|
current: "개요",
|
|
thesis: "인증 방식을 이름으로 비교하지 않고 Code 교환, Token 보관, 요청 검증의 실제 주체를 기준으로 나눕니다.",
|
|
stats: ["2공개 기록", "1설계 결정", "2활동 기록"],
|
|
topics: ["Authentication", "OAuth 2.0", "OIDC", "Keycloak"],
|
|
},
|
|
])("renders $path overview with source counts and topics", async ({ path, title, current, thesis, stats, topics }) => {
|
|
const { container } = await renderPublicRoute("TECH_LOG_PROJECT", path);
|
|
const main = screen.getByRole("main");
|
|
|
|
expect(within(main).getByRole("heading", { level: 1, name: title })).toBeVisible();
|
|
expect(within(main).getByText(thesis)).toBeVisible();
|
|
expect(
|
|
within(projectNavigation()).getByRole("link", { name: current }),
|
|
).toHaveAttribute("aria-current", "page");
|
|
expect(
|
|
Array.from(container.querySelectorAll(".project-stat-links a"), (link) => link.textContent),
|
|
).toEqual(stats);
|
|
expect(
|
|
Array.from(container.querySelectorAll(".project-topics li"), (item) => item.textContent),
|
|
).toEqual(topics);
|
|
});
|
|
|
|
it.each([
|
|
["TECH_LOG_PROJECT" as const, "/projects/backend-skeleton", "개요"],
|
|
["TECH_LOG_PROJECT_RECORDS" as const, "/projects/backend-skeleton/records", "기록"],
|
|
["TECH_LOG_PROJECT_DECISIONS" as const, "/projects/backend-skeleton/decisions", "결정"],
|
|
["TECH_LOG_PROJECT_ACTIVITY" as const, "/projects/backend-skeleton/activity", "활동"],
|
|
])("derives the active project tab from %s location", async (routeId, path, activeLabel) => {
|
|
await renderPublicRoute(routeId, path);
|
|
|
|
expect(
|
|
within(projectNavigation())
|
|
.getAllByRole("link")
|
|
.map((link) => [link.textContent, link.getAttribute("aria-current")]),
|
|
).toEqual([
|
|
["개요", activeLabel === "개요" ? "page" : null],
|
|
["기록", activeLabel === "기록" ? "page" : null],
|
|
["결정", activeLabel === "결정" ? "page" : null],
|
|
["활동", activeLabel === "활동" ? "page" : null],
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
slug: "backend-skeleton",
|
|
expected: [
|
|
"/cases/collection-fetch-join-pagination",
|
|
"/references/jpa-list-fetch-strategy",
|
|
"/cases/redis-adapter-ttl-boundary",
|
|
"/questions/collection-fetch-join-with-pagination",
|
|
],
|
|
},
|
|
{
|
|
slug: "auth-lab",
|
|
expected: [
|
|
"/references/state-and-nonce-boundary",
|
|
"/questions/validate-edge-token-again",
|
|
],
|
|
},
|
|
])("keeps $slug records filtered and published in source order", async ({ slug, expected }) => {
|
|
const { container } = await renderPublicRoute(
|
|
"TECH_LOG_PROJECT_RECORDS",
|
|
`/projects/${slug}/records`,
|
|
);
|
|
|
|
expect(
|
|
Array.from(container.querySelectorAll(".public-record-list > li > a"), (link) =>
|
|
link.getAttribute("href"),
|
|
),
|
|
).toEqual(expected);
|
|
expect(screen.getByText(`${expected.length}개의 공개 기록`)).toBeVisible();
|
|
});
|
|
|
|
it("keeps project decisions ordered with stable IDs and evidence links", async () => {
|
|
const { container } = await renderPublicRoute(
|
|
"TECH_LOG_PROJECT_DECISIONS",
|
|
"/projects/backend-skeleton/decisions",
|
|
);
|
|
|
|
expect(
|
|
Array.from(container.querySelectorAll(".project-decision-list > li > article"), (item) => ({
|
|
id: item.id,
|
|
title: item.querySelector("h2")?.textContent,
|
|
})),
|
|
).toEqual([
|
|
{ id: "storage-port-unification", title: "파일 저장 계약을 하나로 통합합니다" },
|
|
{ id: "feed-pagination-boundary", title: "목록 페이징과 컬렉션 로딩을 분리합니다" },
|
|
]);
|
|
expect(
|
|
Array.from(container.querySelectorAll(".project-decision-list a"), (link) =>
|
|
link.getAttribute("href"),
|
|
),
|
|
).toEqual([
|
|
"/projects/backend-skeleton/activity#storage-contract",
|
|
"/cases/redis-adapter-ttl-boundary",
|
|
"/cases/collection-fetch-join-pagination",
|
|
"/references/jpa-list-fetch-strategy",
|
|
]);
|
|
});
|
|
|
|
it("keeps project activity ordered and preserves self-fragment and record links", async () => {
|
|
const { container } = await renderPublicRoute(
|
|
"TECH_LOG_PROJECT_ACTIVITY",
|
|
"/projects/backend-skeleton/activity",
|
|
);
|
|
|
|
expect(
|
|
Array.from(container.querySelectorAll(".project-activity-list > li > article"), (item) => ({
|
|
id: item.id,
|
|
href: item.querySelector("a")?.getAttribute("href"),
|
|
label: item.querySelector("a")?.textContent,
|
|
})),
|
|
).toEqual([
|
|
{
|
|
id: "fetch-join-case-published",
|
|
href: "/cases/collection-fetch-join-pagination",
|
|
label: "연결된 공개 기록 읽기",
|
|
},
|
|
{
|
|
id: "storage-contract",
|
|
href: "/projects/backend-skeleton/activity#storage-contract",
|
|
label: "이 활동 위치 열기",
|
|
},
|
|
{
|
|
id: "redis-case-published",
|
|
href: "/cases/redis-adapter-ttl-boundary",
|
|
label: "연결된 공개 기록 읽기",
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("TechLog release and profile screens", () => {
|
|
it("renders the ordered release index and complete version link", async () => {
|
|
const { container } = await renderPublicRoute("TECH_LOG_RELEASES", "/releases");
|
|
|
|
expect(screen.getByRole("heading", { level: 1, name: "변경 기록" })).toBeVisible();
|
|
expect(
|
|
screen.getByText(
|
|
"릴리즈 버전별로 무엇을 바꿨고 왜 바꿨는지, 공개 화면에 어떤 영향이 생겼는지 남깁니다.",
|
|
),
|
|
).toBeVisible();
|
|
expect(
|
|
Array.from(container.querySelectorAll(".release-index-list > li > a"), (link) => ({
|
|
href: link.getAttribute("href"),
|
|
version: link.querySelector(".release-version")?.textContent,
|
|
title: link.querySelector("h2")?.textContent,
|
|
})),
|
|
).toEqual([
|
|
{
|
|
href: "/releases/0.1.0",
|
|
version: "v0.1.0",
|
|
title: "TechLog Public·Studio 경계를 확정했습니다",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("renders exact release sections and ordered cross-links", async () => {
|
|
const { container } = await renderPublicRoute("TECH_LOG_RELEASE", "/releases/0.1.0");
|
|
const main = screen.getByRole("main");
|
|
|
|
expect(
|
|
screen.getByRole("heading", {
|
|
level: 1,
|
|
name: "TechLog Public·Studio 경계를 확정했습니다",
|
|
}),
|
|
).toBeVisible();
|
|
expect(
|
|
Array.from(container.querySelectorAll(".release-document > section h2"), (heading) =>
|
|
heading.textContent,
|
|
),
|
|
).toEqual(["무엇이 바뀌었나", "왜 바꿨나", "영향", "연결된 화면"]);
|
|
expect(within(main).getByText("불변 게시 Snapshot", { exact: false })).toBeVisible();
|
|
expect(
|
|
within(main).getByText(
|
|
"Studio는 API 계약을 따르는 세션 전용 프론트엔드 시뮬레이션으로 구성했습니다.",
|
|
),
|
|
).toBeVisible();
|
|
expect(
|
|
Array.from(container.querySelectorAll(".release-related-links a"), (link) =>
|
|
link.getAttribute("href"),
|
|
),
|
|
).toEqual(["/", "/explore", "/cases/collection-fetch-join-pagination"]);
|
|
});
|
|
|
|
it("renders the grounded profile, principles, project links, and topics", async () => {
|
|
const { container } = await renderPublicRoute("TECH_LOG_PROFILE", "/profile");
|
|
const main = screen.getByRole("main");
|
|
|
|
expect(within(main).getByRole("heading", { level: 1, name: "동현" })).toBeVisible();
|
|
expect(
|
|
within(main).getByText("문제를 재현하고 검증해 운영 가능한 설계로 연결합니다."),
|
|
).toBeVisible();
|
|
expect(
|
|
Array.from(container.querySelectorAll(".profile-principles h3"), (heading) =>
|
|
heading.textContent,
|
|
),
|
|
).toEqual([
|
|
"관찰한 사실과 판단을 나눕니다",
|
|
"결론보다 경계를 남깁니다",
|
|
"프로젝트 맥락으로 다시 연결합니다",
|
|
]);
|
|
expect(
|
|
Array.from(container.querySelectorAll(".profile-projects a"), (link) =>
|
|
link.getAttribute("href"),
|
|
),
|
|
).toEqual(["/projects/backend-skeleton", "/projects/auth-lab"]);
|
|
expect(
|
|
Array.from(container.querySelectorAll(".profile-topics li"), (item) => item.textContent),
|
|
).toEqual(["Backend Architecture", "JPA", "Authentication", "Redis"]);
|
|
expect(within(main).queryByText(/이메일|연락처|경력|소속|회사/)).not.toBeInTheDocument();
|
|
expect(container.querySelector('a[href^="mailto:"]')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("TechLog Public not-found runtime", () => {
|
|
it.each([
|
|
["TECH_LOG_PROJECT" as const, "/projects/missing-project"],
|
|
["TECH_LOG_PROJECT_RECORDS" as const, "/projects/missing-project/records"],
|
|
["TECH_LOG_RELEASE" as const, "/releases/9.9.9"],
|
|
["TECH_LOG_CASE" as const, "/cases/not-registered"],
|
|
])("keeps the client-only fallback accessible for %s", async (routeId, path) => {
|
|
const { container, router } = await renderPublicRoute(routeId, path);
|
|
|
|
expect(router.state.location.pathname).toBe(path);
|
|
expect(
|
|
await screen.findByRole("heading", { name: "페이지를 찾을 수 없습니다." }),
|
|
).toBeVisible();
|
|
expect(
|
|
screen.queryByRole("heading", { name: "화면을 표시하지 못했습니다." }),
|
|
).not.toBeInTheDocument();
|
|
expect(container.querySelector(".site-frame")).not.toBeNull();
|
|
});
|
|
|
|
it("keeps the client-only Public catch-all accessible", async () => {
|
|
const { container, router } = await renderPublicRoute(
|
|
"NOT_FOUND",
|
|
"/definitely-not-a-product-route",
|
|
);
|
|
|
|
expect(router.state.location.pathname).toBe("/definitely-not-a-product-route");
|
|
expect(
|
|
screen.getByRole("heading", { name: "페이지를 찾을 수 없습니다." }),
|
|
).toBeVisible();
|
|
expect(container.querySelector(".site-frame")).not.toBeNull();
|
|
});
|
|
});
|