Files
tech-log-frontend/tests/features/tech-log/public-index-screens.test.tsx
T
DongHyeonka 760071156d fix: give each API surface its own error-code enum
The public site answered every screen with the terminal error surface. Three
defects stacked, and each one hid the next.

The first refused the request outright: `attachCredentials` asks the Studio
helper, which returns null for a profile it does not own, and the fallback
below read the session and rejected anything not authenticated. Public reads
declare the ANONYMOUS profile, so a signed-out visitor — the public site's
entire audience — never got a request out of the browser. An anonymous profile
carries no credentials by definition and must never consult the session.

With requests flowing, the second surfaced: `envelopeError()` pinned
`ApiError.code` to the Studio enum and all three surfaces shared it. Public and
Management each declare their own enum in their own contract, so every error
they returned failed validation and arrived as a CONTRACT_VIOLATION — an
unclassifiable transport fault — rather than the domain error it was. A strict
enum checked against the wrong surface's contract still looks strict, which is
why no gate caught it. Each surface now passes its own contract's codes.

The third was the not-found path: it read `status` and `code` off the problem
body, but the envelope has no `status` and names the code for its surface
(PUBLIC_RESOURCE_NOT_FOUND, not NOT_FOUND). The HTTP status from the transport
is the authoritative signal and the only one that holds across both shapes.

The regression test composes the real runtime adapters against the deployed
backend's actual 404 body. Neither the gateway tests (which stub the executor)
nor the screen tests (which stub the gateway) cover this seam, and the whole
outage lived in it.

Two page-level fixes came out of the same investigation: the profile page asked
for two project slugs that only ever existed in the static fixture, and the
index pages held their fixed header copy behind a request that had nothing to
do with it. Headers now paint immediately; only the sections that are actually
waiting show a fallback, and an empty list says so instead of rendering blank.
2026-08-21 01:14:20 +09:00

402 lines
16 KiB
TypeScript

// @vitest-environment jsdom
import { render, screen, waitFor, 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 가 되면서 첫 페인트에는 데이터가 없다. 화면이 정착한 뒤
// 단언하도록 여기서 한 번 기다린다 — 각 테스트에 흩어 놓으면 빠뜨린 곳이 생긴다.
//
// `main` 이 있다는 것만으로는 더 이상 정착이 아니다. index 화면들은 고정 카피인
// 헤더를 네트워크와 무관하게 즉시 그리므로 (그게 목적이다), `main` 은 데이터가
// 오기 전에 존재한다. 기다려야 하는 것은 대기 중이던 구역이 대기를 멈추는 것이다.
await screen.findByRole("main");
await waitFor(() => {
expect(document.querySelector('[aria-busy="true"]')).toBeNull();
});
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();
});
});