feat: complete TechLog Studio publication flow
This commit is contained in:
@@ -1,164 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SERVER_STATE_PROFILES } from "../../src/contracts/server-state.ts";
|
||||
import { COMPOSED_CONTRACT_CONTRIBUTIONS } from "../../src/features/installed-contract-contributions.ts";
|
||||
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.ts";
|
||||
import PlatformOverviewPage from "../../src/presentation/examples/platform-overview-page.tsx";
|
||||
import { LocaleProvider } from "../../src/presentation/i18n/index.ts";
|
||||
import { ApplicationProvider } from "../../src/presentation/providers/application-provider.tsx";
|
||||
import { createTestApplication } from "../helpers/create-test-application.ts";
|
||||
import { createRuntimeCapabilitiesStub } from "../helpers/runtime-capabilities-stub.ts";
|
||||
|
||||
/**
|
||||
* The page must stay a projection of the installed registries. Every assertion
|
||||
* below derives its expectation from the same registry the page reads, so a
|
||||
* template that removes its sample feature still satisfies this suite.
|
||||
*/
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<ApplicationProvider application={createTestApplication()}>
|
||||
<LocaleProvider>
|
||||
<PlatformOverviewPage />
|
||||
</LocaleProvider>
|
||||
</ApplicationProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function tableByCaption(caption: string): HTMLElement {
|
||||
return screen.getByRole("table", { name: caption });
|
||||
}
|
||||
|
||||
/** A metric is a `dt`/`dd` pair, which carries no ARIA role to query by. */
|
||||
function metricByLabel(scope: HTMLElement, label: string): HTMLElement {
|
||||
const term = within(scope).getByText(label).closest(".platform-metric");
|
||||
if (!(term instanceof HTMLElement)) {
|
||||
throw new Error(`No metric is labelled ${label}`);
|
||||
}
|
||||
return term;
|
||||
}
|
||||
|
||||
describe("platform overview page", () => {
|
||||
it("renders one route row per installed route registry entry", () => {
|
||||
renderPage();
|
||||
|
||||
const table = tableByCaption("설치된 라우트 목록");
|
||||
const dataRows = within(table).getAllByRole("row").slice(1);
|
||||
|
||||
expect(dataRows).toHaveLength(Object.keys(ROUTE_REGISTRY).length);
|
||||
for (const definition of Object.values(ROUTE_REGISTRY)) {
|
||||
expect(
|
||||
within(table).getByText(definition.routeId),
|
||||
).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders one operation row per installed HTTP contract", () => {
|
||||
const operationIds = [
|
||||
...COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.keys(),
|
||||
];
|
||||
renderPage();
|
||||
|
||||
if (operationIds.length === 0) {
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
name: "설치된 HTTP 오퍼레이션이 없습니다.",
|
||||
}),
|
||||
).toBeVisible();
|
||||
return;
|
||||
}
|
||||
|
||||
const table = tableByCaption("설치된 HTTP 오퍼레이션");
|
||||
expect(within(table).getAllByRole("row").slice(1)).toHaveLength(
|
||||
operationIds.length,
|
||||
);
|
||||
for (const operationId of operationIds) {
|
||||
expect(within(table).getByText(operationId)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders every fixed server-state profile with its own budget", () => {
|
||||
renderPage();
|
||||
|
||||
const table = tableByCaption("서버 상태 프로파일");
|
||||
for (const profile of Object.values(SERVER_STATE_PROFILES)) {
|
||||
expect(within(table).getByText(profile.profileId)).toBeInTheDocument();
|
||||
}
|
||||
expect(within(table).getAllByRole("row").slice(1)).toHaveLength(
|
||||
Object.keys(SERVER_STATE_PROFILES).length,
|
||||
);
|
||||
});
|
||||
|
||||
it("labels a capability that was never selected as unselected", () => {
|
||||
renderPage();
|
||||
|
||||
const region = screen.getByRole("region", { name: "선택적 런타임 능력" });
|
||||
|
||||
expect(within(region).getAllByText("미선택")).toHaveLength(4);
|
||||
expect(within(region).queryByText("운영자가 비활성화함")).toBeNull();
|
||||
});
|
||||
|
||||
it("separates an operator disable from a capability that was never selected", () => {
|
||||
render(
|
||||
<ApplicationProvider
|
||||
application={createTestApplication({
|
||||
runtimeCapabilities: createRuntimeCapabilitiesStub({
|
||||
SERVICE_WORKER: { selected: 1, active: 0, override: "DISABLED" },
|
||||
OFFLINE_COMMANDS: { selected: 1, active: 1 },
|
||||
}),
|
||||
})}
|
||||
>
|
||||
<LocaleProvider>
|
||||
<PlatformOverviewPage />
|
||||
</LocaleProvider>
|
||||
</ApplicationProvider>,
|
||||
);
|
||||
|
||||
const region = screen.getByRole("region", { name: "선택적 런타임 능력" });
|
||||
|
||||
expect(within(region).getByText("운영자가 비활성화함")).toBeVisible();
|
||||
expect(within(region).getByText("활성 (1)")).toBeVisible();
|
||||
expect(within(region).getAllByText("미선택")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("counts installed contract packages separately from template fixtures", () => {
|
||||
const fixtures = COMPOSED_CONTRACT_CONTRIBUTIONS.contributions.filter(
|
||||
(contribution) => contribution.source.kind === "TEMPLATE_FIXTURE",
|
||||
).length;
|
||||
renderPage();
|
||||
|
||||
const summary = screen.getByRole("region", { name: "설치 요약" });
|
||||
const packages = metricByLabel(summary, "외부 계약 패키지");
|
||||
|
||||
expect(
|
||||
within(packages).getByText(
|
||||
`${COMPOSED_CONTRACT_CONTRIBUTIONS.externalPackages.length}개`,
|
||||
),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
within(packages).getByText(`템플릿 픽스처 ${fixtures}개`),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
within(metricByLabel(summary, "라우트")).getByText(
|
||||
`${Object.keys(ROUTE_REGISTRY).length}개`,
|
||||
),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("renders the verified release identity once the runtime resolves it", async () => {
|
||||
renderPage();
|
||||
|
||||
const region = screen.getByRole("region", { name: "릴리스 신원" });
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(metricByLabel(region, "빌드")).getByText("test-build"),
|
||||
).toBeVisible(),
|
||||
);
|
||||
expect(
|
||||
within(metricByLabel(region, "릴리스")).getByText("test-release"),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
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";
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
} 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,
|
||||
@@ -54,7 +56,7 @@ const groupedFixtureCodecs = Object.freeze({
|
||||
|
||||
const reviewFixtureRegistry = Object.freeze({
|
||||
REVIEW_FIXTURE: Object.freeze({
|
||||
...ROUTE_REGISTRY.APP_HOME,
|
||||
...ROUTE_REGISTRY.TECH_LOG_HOME,
|
||||
routeId: "REVIEW_FIXTURE",
|
||||
path: "/review/:reviewId",
|
||||
paramsSchema: "ReviewFixtureParams",
|
||||
@@ -100,10 +102,12 @@ function compileTimeGroupedRouteContract() {
|
||||
void compileTimeGroupedRouteContract;
|
||||
|
||||
function renderRouter() {
|
||||
const techLog = createTechLogFeatureInstalledInput();
|
||||
return render(
|
||||
<ApplicationProvider
|
||||
application={createTestApplication({
|
||||
session: createAnonymousSessionAdapter(),
|
||||
featureInputs: { [TECH_LOG_FEATURE_ID]: techLog.input },
|
||||
})}
|
||||
>
|
||||
<AppRouter />
|
||||
@@ -159,7 +163,7 @@ describe("generic application router", () => {
|
||||
|
||||
it("assembles generic Public and Studio parents with Studio catch-all precedence", () => {
|
||||
const registry = {
|
||||
APP_HOME: ROUTE_REGISTRY.APP_HOME,
|
||||
TECH_LOG_HOME: ROUTE_REGISTRY.TECH_LOG_HOME,
|
||||
STUDIO_FIXTURE: {
|
||||
...ROUTE_REGISTRY.NOT_FOUND,
|
||||
routeId: "STUDIO_FIXTURE",
|
||||
@@ -169,7 +173,7 @@ describe("generic application router", () => {
|
||||
NOT_FOUND: ROUTE_REGISTRY.NOT_FOUND,
|
||||
};
|
||||
const runtime = {
|
||||
APP_HOME: ROUTE_RUNTIME.APP_HOME,
|
||||
TECH_LOG_HOME: ROUTE_RUNTIME.TECH_LOG_HOME,
|
||||
STUDIO_FIXTURE: ROUTE_RUNTIME.NOT_FOUND,
|
||||
NOT_FOUND: ROUTE_RUNTIME.NOT_FOUND,
|
||||
};
|
||||
@@ -220,7 +224,7 @@ describe("generic application router", () => {
|
||||
"test-build",
|
||||
groupedFixtureCodecs,
|
||||
),
|
||||
).toThrow("Missing route runtime: APP_HOME");
|
||||
).toThrow("Missing route runtime: TECH_LOG_HOME");
|
||||
});
|
||||
|
||||
it("renders a non-installed Studio wildcard leaf without rewriting its URL", async () => {
|
||||
@@ -277,10 +281,11 @@ describe("generic application router", () => {
|
||||
const routes = createGroupedRouteObjects(
|
||||
{
|
||||
PROTECTED_FIXTURE: {
|
||||
...ROUTE_REGISTRY.APP_HOME,
|
||||
...ROUTE_REGISTRY.TECH_LOG_HOME,
|
||||
routeId: "PROTECTED_FIXTURE",
|
||||
path: "/private-fixture",
|
||||
access: "session-required",
|
||||
searchSchema: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -317,89 +322,67 @@ describe("generic application router", () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("reaches the platform overview from the home starter actions", async () => {
|
||||
it("renders the installed TechLog home in the Public layout", async () => {
|
||||
const user = userEvent.setup();
|
||||
window.history.pushState({}, "", "/");
|
||||
renderRouter();
|
||||
|
||||
// The starter action lives inside the lazily loaded home chunk, so this is
|
||||
// the first wait in the file that has to outlast a chunk load rather than
|
||||
// an already-mounted shell element.
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "TechLog", level: 1 }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
|
||||
await user.click(
|
||||
await screen.findByRole(
|
||||
within(screen.getByRole("navigation", { name: "주요 탐색" })).getByRole(
|
||||
"link",
|
||||
{ name: "플랫폼 구성 보기" },
|
||||
{ timeout: 5000 },
|
||||
{ name: "프로젝트" },
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "플랫폼 구성", level: 1 }),
|
||||
).toHaveFocus(),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "프로젝트", level: 1 }),
|
||||
).toBeVisible();
|
||||
expect(window.location.pathname).toBe("/projects");
|
||||
});
|
||||
|
||||
it("renders the app shell and not-found route without a feature input", async () => {
|
||||
it("renders the Public shell and TechLog not-found route", async () => {
|
||||
window.history.pushState({}, "", "/missing");
|
||||
renderRouter();
|
||||
|
||||
expect(
|
||||
await screen.findByRole("heading", {
|
||||
name: "페이지를 찾을 수 없습니다.",
|
||||
name: "404",
|
||||
level: 1,
|
||||
}),
|
||||
).toBeVisible();
|
||||
expect(screen.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
|
||||
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: "세션이 필요합니다." }),
|
||||
screen.getByRole("heading", { name: "This page could not be found." }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("navigates between registry-backed platform routes", async () => {
|
||||
const user = userEvent.setup();
|
||||
window.history.pushState({}, "", "/");
|
||||
it("keeps Studio routes inside the persistent Studio layout", async () => {
|
||||
window.history.pushState({}, "", "/studio");
|
||||
renderRouter();
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole("link", { name: "UI 구성요소" }),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "UI 구성요소", level: 1 }),
|
||||
await screen.findByRole("heading", { name: "작업 흐름" }),
|
||||
).toBeVisible();
|
||||
expect(window.location.pathname).toBe("/examples/ui");
|
||||
expect(document.title).toBe("UI 구성요소 · Tech Log");
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "UI 구성요소", level: 1 }),
|
||||
).toHaveFocus(),
|
||||
expect(screen.getByRole("navigation", { name: "Studio 주 탐색" })).toBeVisible();
|
||||
expect(screen.getByRole("link", { name: "공개 사이트 보기" })).toHaveAttribute(
|
||||
"href",
|
||||
"/",
|
||||
);
|
||||
});
|
||||
|
||||
it("focuses the route heading again when the lazy chunk is already cached", async () => {
|
||||
// §9.7. The first visit resolves the route module asynchronously, so the
|
||||
// router lifecycle and the page header commit separately. A later visit
|
||||
// renders the cached module in the same commit, which is the ordering that
|
||||
// must still hand focus to the heading rather than leaving it on main.
|
||||
const user = userEvent.setup();
|
||||
window.history.pushState({}, "", "/");
|
||||
it("gives the Studio wildcard precedence over the Public not-found route", async () => {
|
||||
window.history.pushState({}, "", "/studio/missing");
|
||||
renderRouter();
|
||||
|
||||
for (const label of ["UI 구성요소", "화면 상태", "UI 구성요소"]) {
|
||||
await user.click(await screen.findByRole("link", { name: label }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { name: label, level: 1 }),
|
||||
).toHaveFocus(),
|
||||
);
|
||||
}
|
||||
expect(
|
||||
await screen.findByRole("heading", {
|
||||
name: "Studio 화면을 찾을 수 없습니다",
|
||||
}),
|
||||
).toBeVisible();
|
||||
expect(screen.getByRole("navigation", { name: "Studio 주 탐색" })).toBeVisible();
|
||||
expect(window.location.pathname).toBe("/studio/missing");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,10 +31,32 @@ const releaseManifest = {
|
||||
releaseId: "local-release",
|
||||
builtAt: "2026-07-26T00:00:00.000Z",
|
||||
routeChunks: {
|
||||
"route-home": "assets/home.js",
|
||||
"route-examples-ui": "assets/ui.js",
|
||||
"route-examples-states": "assets/states.js",
|
||||
"route-examples-auth": "assets/auth.js",
|
||||
"route-tech-log-home": "assets/tech-log-home.js",
|
||||
"route-tech-log-explore": "assets/tech-log-explore.js",
|
||||
"route-tech-log-explore-kind": "assets/tech-log-explore-kind.js",
|
||||
"route-tech-log-case": "assets/tech-log-case.js",
|
||||
"route-tech-log-reference": "assets/tech-log-reference.js",
|
||||
"route-tech-log-question": "assets/tech-log-question.js",
|
||||
"route-tech-log-topic": "assets/tech-log-topic.js",
|
||||
"route-tech-log-projects": "assets/tech-log-projects.js",
|
||||
"route-tech-log-project": "assets/tech-log-project.js",
|
||||
"route-tech-log-project-records": "assets/tech-log-project-records.js",
|
||||
"route-tech-log-project-decisions": "assets/tech-log-project-decisions.js",
|
||||
"route-tech-log-project-activity": "assets/tech-log-project-activity.js",
|
||||
"route-tech-log-releases": "assets/tech-log-releases.js",
|
||||
"route-tech-log-release": "assets/tech-log-release.js",
|
||||
"route-tech-log-profile": "assets/tech-log-profile.js",
|
||||
"route-tech-log-search": "assets/tech-log-search.js",
|
||||
"route-tech-log-studio-home": "assets/tech-log-studio-home.js",
|
||||
"route-tech-log-studio-documents": "assets/tech-log-studio-documents.js",
|
||||
"route-tech-log-studio-document-new": "assets/tech-log-studio-document-new.js",
|
||||
"route-tech-log-studio-document-edit": "assets/tech-log-studio-document-edit.js",
|
||||
"route-tech-log-studio-document-validation": "assets/tech-log-studio-document-validation.js",
|
||||
"route-tech-log-studio-document-preview": "assets/tech-log-studio-document-preview.js",
|
||||
"route-tech-log-studio-document-publish": "assets/tech-log-studio-document-publish.js",
|
||||
"route-tech-log-studio-publications": "assets/tech-log-studio-publications.js",
|
||||
"route-tech-log-studio-publication-preview": "assets/tech-log-studio-publication-preview.js",
|
||||
"route-tech-log-studio-not-found": "assets/tech-log-studio-not-found.js",
|
||||
"route-not-found": "assets/not-found.js",
|
||||
},
|
||||
};
|
||||
@@ -62,12 +84,14 @@ describe("production runtime application tree", () => {
|
||||
|
||||
expect(
|
||||
await screen.findByRole("heading", {
|
||||
name: "Tech Log",
|
||||
name: "TechLog",
|
||||
}),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
await screen.findByText("빌드 local-build · 릴리스 local-release"),
|
||||
).toBeVisible();
|
||||
await screen.findAllByText(
|
||||
"문제를 재현하고 검증해 운영 가능한 설계로 연결합니다.",
|
||||
),
|
||||
).toHaveLength(2);
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
expect(composition).not.toHaveProperty("ports");
|
||||
});
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
import { SERVER_STATE_PROFILES } from "../../src/contracts/server-state.ts";
|
||||
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.ts";
|
||||
|
||||
/**
|
||||
* The overview is a projection of the installed registries. These assertions
|
||||
* read the same registries the shipped bundle was built from, so they keep
|
||||
* meaning after a feature is added or removed.
|
||||
*/
|
||||
|
||||
test("projects the installed route registry into the shipped bundle", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/examples/platform");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "플랫폼 구성", level: 1 }),
|
||||
).toBeVisible();
|
||||
|
||||
const table = page.getByRole("table", { name: "설치된 라우트 목록" });
|
||||
await expect(table.locator("tbody tr")).toHaveCount(
|
||||
Object.keys(ROUTE_REGISTRY).length,
|
||||
);
|
||||
for (const routeId of Object.keys(ROUTE_REGISTRY)) {
|
||||
await expect(table.getByText(routeId, { exact: true })).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows every fixed server-state profile", async ({ page }) => {
|
||||
await page.goto("/examples/platform");
|
||||
|
||||
const table = page.getByRole("table", { name: "서버 상태 프로파일" });
|
||||
await expect(table.locator("tbody tr")).toHaveCount(
|
||||
Object.keys(SERVER_STATE_PROFILES).length,
|
||||
);
|
||||
});
|
||||
|
||||
test("states the release contract identity verified at boot", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/examples/platform");
|
||||
|
||||
const release = page.locator(
|
||||
"section[aria-labelledby='platform-release-title']",
|
||||
);
|
||||
await expect(release.getByText(/^sha256:/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("reports an unselected capability without claiming it was disabled", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/examples/platform");
|
||||
|
||||
const capabilities = page.locator(
|
||||
"section[aria-labelledby='platform-capabilities-title']",
|
||||
);
|
||||
await expect(capabilities.getByText("미선택")).toHaveCount(4);
|
||||
await expect(capabilities.getByText("운영자가 비활성화함")).toHaveCount(0);
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
async function openReferenceForm(page: Page) {
|
||||
await page.goto("/examples/reference-resources/new");
|
||||
await page.getByRole("button", { name: "로그인 시작" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Reference resource 만들기" }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
test("validates a reference form and focuses the first invalid field", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openReferenceForm(page);
|
||||
await page.getByRole("button", { name: "저장" }).click();
|
||||
|
||||
const firstField = page.getByRole("textbox", { name: /새 항목 이름/ });
|
||||
await expect(firstField).toBeFocused();
|
||||
await expect(firstField).toHaveAttribute("aria-invalid", "true");
|
||||
await expect(page.getByRole("alert")).toContainText("입력 내용을 확인해 주세요.");
|
||||
});
|
||||
|
||||
test("guards dirty cancellation and restores focus when writing continues", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openReferenceForm(page);
|
||||
await page
|
||||
.getByRole("textbox", { name: /새 항목 이름/ })
|
||||
.fill("Unsaved reference");
|
||||
const cancel = page.getByRole("button", { name: "취소" });
|
||||
await cancel.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "저장하지 않은 변경이 있습니다." }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "계속 작성" }).click();
|
||||
await expect(cancel).toBeFocused();
|
||||
await expect(page).toHaveURL(/\/examples\/reference-resources\/new$/);
|
||||
});
|
||||
|
||||
test("keeps the form template within a 320px viewport", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 320, height: 720 });
|
||||
await openReferenceForm(page);
|
||||
const viewport = await page.evaluate(() => ({
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
}));
|
||||
expect(viewport.scrollWidth).toBeLessThanOrEqual(viewport.clientWidth);
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.ts";
|
||||
|
||||
test("opens the protected integration route through the local demo seam", async ({
|
||||
page,
|
||||
}) => {
|
||||
const protectedRoute = ROUTE_REGISTRY.REFERENCE_RESOURCE_LIST;
|
||||
await page.route(
|
||||
"http://localhost:8080/api/reference-resources?*",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: [
|
||||
{
|
||||
id: "browser-reference",
|
||||
name: "Browser reference",
|
||||
createdAt: "2026-07-26T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await page.goto(protectedRoute.path);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "세션이 필요합니다." }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "로그인 시작" }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: protectedRoute.title }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("인증됨")).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { FIXTURE_IDS } from "../../src/features/tech-log/adapters/mock/fixtures.ts";
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
test("validates, previews, publishes, and unpublishes one Studio working copy", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(
|
||||
`/studio/documents/${FIXTURE_IDS.stateNonceReference}/validation`,
|
||||
);
|
||||
await expect(
|
||||
page.getByText("현재 저장 버전의 검증을 통과했습니다"),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole("link", { name: "Public Preview 만들기" }).click();
|
||||
await page
|
||||
.getByRole("button", { name: "Public Preview 만들기" })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByText("현재 저장 버전의 Public Preview입니다."),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole("link", { name: "게시 준비로 이동" }).click();
|
||||
await page.getByRole("button", { name: "게시" }).click();
|
||||
await expect(page).toHaveURL(
|
||||
/\/studio\/publications\/[0-9a-f-]+\/preview$/,
|
||||
);
|
||||
await expect(
|
||||
page.getByRole("heading", {
|
||||
level: 1,
|
||||
name: "Authorization Code Flow에서 state와 nonce의 경계",
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole("link", { name: "게시 기록으로 돌아가기" }).click();
|
||||
await page
|
||||
.getByRole("button", {
|
||||
name: /Authorization Code Flow에서 state와 nonce의 경계 게시 취소/,
|
||||
})
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "게시를 취소할까요?" }),
|
||||
).toContainText("작업본과 이전 Snapshot은 보존됩니다.");
|
||||
await page.getByRole("button", { name: "게시 취소 확인" }).click();
|
||||
await expect(page.getByRole("status")).toContainText(
|
||||
"게시를 취소했습니다.",
|
||||
);
|
||||
});
|
||||
|
||||
test("renders an immutable historical snapshot and Studio-local missing event", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(
|
||||
`/studio/publications/${FIXTURE_IDS.fetchPublishedEvent}/preview`,
|
||||
);
|
||||
await expect(
|
||||
page.getByText(/반환된 20건 뒤에서 전체 컬렉션이 로드되는 과정/),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("게시 후 본문 측정값을 보완한 저장본입니다."),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.goto(
|
||||
"/studio/publications/99999999-9999-4999-8999-999999999999/preview",
|
||||
);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "게시 기록을 찾을 수 없습니다" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("navigation", { name: "Studio 주 탐색" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
test("validates and reports the common text-field flow", async ({ page }) => {
|
||||
await page.goto("/examples/ui");
|
||||
await page.getByRole("button", { name: "입력 확인" }).click();
|
||||
|
||||
const field = page.getByRole("textbox", { name: "프로젝트 이름" });
|
||||
await expect(field).toHaveAttribute("aria-invalid", "true");
|
||||
await expect(field).toHaveAccessibleDescription(
|
||||
/프로젝트 이름을 입력해 주세요/,
|
||||
);
|
||||
|
||||
await field.fill("Starter");
|
||||
await page.getByRole("button", { name: "입력 확인" }).click();
|
||||
await expect(page.getByText("“Starter” 입력을 확인했습니다.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("traps modal interaction and restores focus to the trigger", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/examples/ui");
|
||||
const trigger = page.getByRole("button", { name: "모달 열기" });
|
||||
await trigger.click();
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: "연동 확인" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "연동 확인 닫기" }),
|
||||
).toBeFocused();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(trigger).toBeFocused();
|
||||
});
|
||||
@@ -1,9 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
buildRouteUrl,
|
||||
parseRouteInput,
|
||||
} from "../../../src/presentation/routes/route-codecs.ts";
|
||||
import {
|
||||
mapReferenceOperation,
|
||||
toReferenceView,
|
||||
@@ -47,35 +43,7 @@ describe("reference feature boundary contracts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips one canonical filter through the URL codec", () => {
|
||||
const filters = {
|
||||
tags: ["open", "new"],
|
||||
cursor: "a/b",
|
||||
limit: 5,
|
||||
};
|
||||
const url = buildRouteUrl("REFERENCE_RESOURCE_LIST", { search: filters });
|
||||
expect(url).toBe(
|
||||
"/examples/reference-resources?cursor=a%2Fb&limit=5&tags=open&tags=new",
|
||||
);
|
||||
const parsed = parseRouteInput(
|
||||
"REFERENCE_RESOURCE_LIST",
|
||||
{},
|
||||
new URL(url, "https://app.test").searchParams,
|
||||
);
|
||||
expect(parsed).toMatchObject({
|
||||
success: true,
|
||||
data: { search: filters },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unknown search and malformed DTO before mapping", () => {
|
||||
expect(
|
||||
parseRouteInput(
|
||||
"REFERENCE_RESOURCE_LIST",
|
||||
{},
|
||||
new URLSearchParams("unknown=value"),
|
||||
),
|
||||
).toEqual({ success: false, code: "ROUTE_SEARCH_INVALID" });
|
||||
expect(
|
||||
validateReferencePayload("ReferenceResourceListPayload", [
|
||||
{ id: "unsafe", name: 42 },
|
||||
@@ -109,13 +77,7 @@ describe("reference feature boundary contracts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("owns route and operation contributions in one removable contract", () => {
|
||||
expect(Object.keys(REFERENCE_FEATURE_CONTRACT.routes)).toEqual([
|
||||
"REFERENCE_RESOURCE_LIST",
|
||||
"REFERENCE_RESOURCE_DETAIL",
|
||||
"REFERENCE_RESOURCE_FORM",
|
||||
"REFERENCE_RESOURCE_STATUS",
|
||||
]);
|
||||
it("owns the retained API operation fixture contract", () => {
|
||||
expect(Object.keys(REFERENCE_FEATURE_CONTRACT.apiOperations)).toEqual([
|
||||
"LIST_REFERENCE_RESOURCES",
|
||||
"CREATE_REFERENCE_RESOURCE",
|
||||
|
||||
@@ -1,490 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
} from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createDemoSessionAdapter } from "../../../src/adapters/auth/external-session-adapter.ts";
|
||||
import type { AuthSessionPort } from "../../../src/application/ports/auth-session-port.ts";
|
||||
import type { MutationIntentFactory } from "../../../src/application/ports/mutation-intent-factory.ts";
|
||||
import type {
|
||||
ReferenceFeatureInput,
|
||||
ReferenceResult,
|
||||
} from "../../../src/features/reference-feature/application/reference-feature-api.ts";
|
||||
import {
|
||||
REFERENCE_FEATURE_ID,
|
||||
REFERENCE_RESOURCE_INVALIDATION_TOPIC,
|
||||
REFERENCE_RESOURCE_QUERY_NAMESPACE,
|
||||
} from "../../../src/features/reference-feature/contracts/reference-feature-contract.ts";
|
||||
import { INVALIDATION_REGISTRY } from "../../../src/features/installed-feature-contracts.ts";
|
||||
import type { ReferenceResourceView } from "../../../src/features/reference-feature/contracts/reference-mapper.ts";
|
||||
import { createFailure } from "../../../src/contracts/errors.ts";
|
||||
import type { QueryInvalidationCoordinator } from "../../../src/contracts/query-invalidation.ts";
|
||||
import { QueryInvalidationProvider } from "../../../src/presentation/adapters/query/query-invalidation-provider.tsx";
|
||||
import { MutationIntentProvider } from "../../../src/presentation/adapters/query/mutation-intent-provider.tsx";
|
||||
import { ServerStateScopeProvider } from "../../../src/presentation/adapters/query/server-state-scope-provider.tsx";
|
||||
import { createServerStateScopeRuntime } from "../../../src/adapters/query-cache/server-state-scope-runtime.ts";
|
||||
import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.tsx";
|
||||
import { AppRouter } from "../../../src/presentation/routes/app-router.tsx";
|
||||
import { createTestApplication } from "../../helpers/create-test-application.ts";
|
||||
|
||||
function renderReference(
|
||||
input: ReferenceFeatureInput,
|
||||
url = "/examples/reference-resources?limit=5",
|
||||
session: AuthSessionPort = createDemoSessionAdapter("authenticated"),
|
||||
) {
|
||||
window.history.pushState({}, "", url);
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: Infinity },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
const invalidation: QueryInvalidationCoordinator = Object.freeze({
|
||||
async invalidate() {},
|
||||
beginMutation() {
|
||||
return Object.freeze({
|
||||
async release() {},
|
||||
});
|
||||
},
|
||||
async resetLocal() {},
|
||||
dispose() {},
|
||||
});
|
||||
const serverStateScope = createServerStateScopeRuntime({
|
||||
session,
|
||||
queryInvalidation: invalidation,
|
||||
});
|
||||
let intentSequence = 0;
|
||||
const mutationIntentFactory: MutationIntentFactory = Object.freeze({
|
||||
create(input) {
|
||||
intentSequence += 1;
|
||||
return Object.freeze({
|
||||
intentId: `reference-page-intent-${intentSequence}`,
|
||||
operationId: input.operationId,
|
||||
canonicalInputIdentity: input.canonicalInputIdentity,
|
||||
...(input.requiresIdempotencyKey
|
||||
? { idempotencyKey: `reference-page-key-${intentSequence}` }
|
||||
: {}),
|
||||
createdAtMonotonicMs: intentSequence,
|
||||
});
|
||||
},
|
||||
});
|
||||
return Object.assign(render(
|
||||
<MutationIntentProvider factory={mutationIntentFactory}>
|
||||
<QueryClientProvider client={client}>
|
||||
<ServerStateScopeProvider runtime={serverStateScope}>
|
||||
<QueryInvalidationProvider coordinator={invalidation}>
|
||||
<ApplicationProvider
|
||||
application={createTestApplication({
|
||||
session,
|
||||
featureInputs: { [REFERENCE_FEATURE_ID]: input },
|
||||
})}
|
||||
>
|
||||
<AppRouter />
|
||||
</ApplicationProvider>
|
||||
</QueryInvalidationProvider>
|
||||
</ServerStateScopeProvider>
|
||||
</QueryClientProvider>
|
||||
</MutationIntentProvider>,
|
||||
), { client });
|
||||
}
|
||||
|
||||
function inputWith(
|
||||
overrides: Partial<ReferenceFeatureInput> = {},
|
||||
): ReferenceFeatureInput {
|
||||
return {
|
||||
listResources: async () => ({ ok: true, value: [] }),
|
||||
createResource: async ({ name }) => ({
|
||||
ok: true,
|
||||
value: {
|
||||
resourceId: "created",
|
||||
title: name,
|
||||
createdAt: null,
|
||||
},
|
||||
}),
|
||||
getResource: async (resourceId) => ({
|
||||
ok: true,
|
||||
value: {
|
||||
resourceId,
|
||||
title: "Detail",
|
||||
createdAt: null,
|
||||
},
|
||||
}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("reference feature page states", () => {
|
||||
it("mounts list and detail keys under the installed governed namespace", async () => {
|
||||
expect(REFERENCE_RESOURCE_QUERY_NAMESPACE).toEqual({
|
||||
namespaceId: "reference-resource",
|
||||
namespaceVersion: 1,
|
||||
});
|
||||
const installedEdge = INVALIDATION_REGISTRY.edges.find(
|
||||
(edge) => edge.topicId === REFERENCE_RESOURCE_INVALIDATION_TOPIC,
|
||||
);
|
||||
expect(installedEdge?.namespace).toEqual(
|
||||
REFERENCE_RESOURCE_QUERY_NAMESPACE,
|
||||
);
|
||||
|
||||
const list = renderReference(inputWith());
|
||||
await screen.findByRole("heading", { name: "표시할 항목이 없습니다." });
|
||||
const listKey = list.client.getQueryCache().getAll()[0]?.queryKey;
|
||||
expect(listKey?.slice(0, 4)).toEqual([
|
||||
"query",
|
||||
2,
|
||||
REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceId,
|
||||
REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceVersion,
|
||||
]);
|
||||
list.unmount();
|
||||
|
||||
const detail = renderReference(
|
||||
inputWith(),
|
||||
"/examples/reference-resources/reference-1",
|
||||
);
|
||||
await screen.findByText("Detail");
|
||||
const detailKey = detail.client.getQueryCache().getAll()[0]?.queryKey;
|
||||
expect(detailKey?.slice(0, 4)).toEqual([
|
||||
"query",
|
||||
2,
|
||||
installedEdge?.namespace.namespaceId,
|
||||
installedEdge?.namespace.namespaceVersion,
|
||||
]);
|
||||
detail.unmount();
|
||||
});
|
||||
|
||||
it("renders loading, success and empty states through the installed route", async () => {
|
||||
let resolveList:
|
||||
| ((result: ReferenceResult<readonly ReferenceResourceView[]>) => void)
|
||||
| undefined;
|
||||
const pending = new Promise<
|
||||
ReferenceResult<readonly ReferenceResourceView[]>
|
||||
>((resolve) => {
|
||||
resolveList = resolve;
|
||||
});
|
||||
const loaded = renderReference(
|
||||
inputWith({ listResources: async () => pending }),
|
||||
);
|
||||
expect(await screen.findByLabelText("불러오는 중")).toBeVisible();
|
||||
resolveList?.({
|
||||
ok: true,
|
||||
value: [
|
||||
{
|
||||
resourceId: "reference-1",
|
||||
title: "Loaded",
|
||||
createdAt: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(await screen.findByText("Loaded")).toBeVisible();
|
||||
loaded.unmount();
|
||||
|
||||
renderReference(inputWith(), "/examples/reference-resources?limit=10");
|
||||
expect(
|
||||
await screen.findByRole("heading", {
|
||||
name: "표시할 항목이 없습니다.",
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("rejects invalid URL input before the feature application input", async () => {
|
||||
const listResources = vi.fn();
|
||||
renderReference(
|
||||
inputWith({ listResources }),
|
||||
"/examples/reference-resources?limit=invalid",
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("heading", {
|
||||
name: "올바르지 않은 주소입니다.",
|
||||
}),
|
||||
).toBeVisible();
|
||||
expect(listResources).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders backend forbidden even when the client access hint allowed entry", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderReference(
|
||||
inputWith({
|
||||
listResources: async () => ({
|
||||
ok: false,
|
||||
error: createFailure(
|
||||
"FORBIDDEN",
|
||||
"LIST_REFERENCE_RESOURCES",
|
||||
0,
|
||||
),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"이 작업을 수행할 권한이 없습니다.",
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "안전한 화면으로 이동" }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("heading", {
|
||||
level: 1,
|
||||
name: "Tech Log",
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("starts sign-in with the current route for a backend auth failure", async () => {
|
||||
const user = userEvent.setup();
|
||||
const demoSession = createDemoSessionAdapter("authenticated");
|
||||
const beginSignIn = vi.fn(async () => {});
|
||||
const session = { ...demoSession, beginSignIn };
|
||||
renderReference(
|
||||
inputWith({
|
||||
listResources: async () => ({
|
||||
ok: false,
|
||||
error: createFailure(
|
||||
"AUTH_REQUIRED",
|
||||
"LIST_REFERENCE_RESOURCES",
|
||||
0,
|
||||
),
|
||||
}),
|
||||
}),
|
||||
"/examples/reference-resources?limit=5",
|
||||
session,
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "로그인" }));
|
||||
expect(beginSignIn).toHaveBeenCalledWith(
|
||||
"/examples/reference-resources?limit=5",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to a public route when starting sign-in fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
const demoSession = createDemoSessionAdapter("authenticated");
|
||||
const beginSignIn = vi.fn(async () => {
|
||||
throw new Error("identity provider unavailable");
|
||||
});
|
||||
renderReference(
|
||||
inputWith({
|
||||
listResources: async () => ({
|
||||
ok: false,
|
||||
error: createFailure(
|
||||
"AUTH_REQUIRED",
|
||||
"LIST_REFERENCE_RESOURCES",
|
||||
0,
|
||||
),
|
||||
}),
|
||||
}),
|
||||
"/examples/reference-resources?limit=5",
|
||||
{ ...demoSession, beginSignIn },
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "로그인" }));
|
||||
expect(
|
||||
await screen.findByRole("heading", {
|
||||
level: 1,
|
||||
name: "Tech Log",
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("deduplicates create, preserves input and surfaces a conflict", async () => {
|
||||
const user = userEvent.setup();
|
||||
let finish:
|
||||
| ((result: ReferenceResult<ReferenceResourceView>) => void)
|
||||
| undefined;
|
||||
const createResource = vi.fn(
|
||||
() =>
|
||||
new Promise<ReferenceResult<ReferenceResourceView>>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
renderReference(
|
||||
inputWith({ createResource }),
|
||||
"/examples/reference-resources/new",
|
||||
);
|
||||
await screen.findByRole("heading", {
|
||||
name: "Reference resource 만들기",
|
||||
});
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: /새 항목 이름/ }),
|
||||
"Conflicting",
|
||||
);
|
||||
await user.type(screen.getByLabelText("설명"), "Keep this input");
|
||||
const submit = screen.getByRole("button", { name: "저장" });
|
||||
await user.dblClick(submit);
|
||||
|
||||
await waitFor(() => expect(createResource).toHaveBeenCalledOnce());
|
||||
expect(screen.getByRole("button", { name: "저장 중…" })).toBeDisabled();
|
||||
|
||||
finish?.({
|
||||
ok: false,
|
||||
error: createFailure(
|
||||
"CONFLICT",
|
||||
"CREATE_REFERENCE_RESOURCE",
|
||||
0,
|
||||
{ effect: "NOT_APPLIED" },
|
||||
),
|
||||
});
|
||||
expect(await screen.findByText(/다른 변경과 충돌했습니다/)).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("textbox", { name: /새 항목 이름/ }),
|
||||
).toHaveValue("Conflicting");
|
||||
expect(screen.getByLabelText("설명")).toHaveValue("Keep this input");
|
||||
});
|
||||
|
||||
it("blocks resubmit and exposes only reconciliation for an unknown create effect", async () => {
|
||||
const user = userEvent.setup();
|
||||
const createResource = vi.fn(async () => ({
|
||||
ok: false as const,
|
||||
error: createFailure(
|
||||
"SERVER_FAILURE",
|
||||
"CREATE_REFERENCE_RESOURCE",
|
||||
0,
|
||||
{ effect: "MAYBE_APPLIED" },
|
||||
),
|
||||
}));
|
||||
renderReference(
|
||||
inputWith({ createResource }),
|
||||
"/examples/reference-resources/new",
|
||||
);
|
||||
await user.type(
|
||||
await screen.findByRole("textbox", { name: /새 항목 이름/ }),
|
||||
"Unknown result",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "저장" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("변경 결과를 확인할 수 없습니다."),
|
||||
).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "저장" })).toBeDisabled();
|
||||
expect(
|
||||
screen.queryByText("저장하지 못했습니다. 잠시 후 다시 시도해 주세요."),
|
||||
).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "저장" }));
|
||||
expect(createResource).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
screen.getByRole("textbox", { name: /새 항목 이름/ }),
|
||||
).toHaveValue("Unknown result");
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "변경되지 않음으로 확인" }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "저장" })).toBeEnabled(),
|
||||
);
|
||||
expect(createResource).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
screen.getByRole("textbox", { name: /새 항목 이름/ }),
|
||||
).toHaveValue("Unknown result");
|
||||
});
|
||||
|
||||
it("settles the form after confirming an unknown create was applied", async () => {
|
||||
const user = userEvent.setup();
|
||||
const createResource = vi.fn(async () => ({
|
||||
ok: false as const,
|
||||
error: createFailure(
|
||||
"SERVER_FAILURE",
|
||||
"CREATE_REFERENCE_RESOURCE",
|
||||
0,
|
||||
{ effect: "MAYBE_APPLIED" },
|
||||
),
|
||||
}));
|
||||
renderReference(
|
||||
inputWith({ createResource }),
|
||||
"/examples/reference-resources/new",
|
||||
);
|
||||
const name = await screen.findByRole("textbox", {
|
||||
name: /새 항목 이름/,
|
||||
});
|
||||
await user.type(name, "Already created");
|
||||
await user.click(screen.getByRole("button", { name: "저장" }));
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "변경됨으로 확인" }),
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("status")).toHaveTextContent("저장했습니다.");
|
||||
expect(name).toHaveValue("");
|
||||
await user.click(screen.getByRole("button", { name: "저장" }));
|
||||
expect(createResource).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("treats an applied-confirmed failure as a settled create", async () => {
|
||||
const user = userEvent.setup();
|
||||
const createResource = vi.fn(async () => ({
|
||||
ok: false as const,
|
||||
error: createFailure(
|
||||
"SERVER_FAILURE",
|
||||
"CREATE_REFERENCE_RESOURCE",
|
||||
0,
|
||||
{ effect: "APPLIED_CONFIRMED" },
|
||||
),
|
||||
}));
|
||||
renderReference(
|
||||
inputWith({ createResource }),
|
||||
"/examples/reference-resources/new",
|
||||
);
|
||||
const name = await screen.findByRole("textbox", {
|
||||
name: /새 항목 이름/,
|
||||
});
|
||||
await user.type(name, "Committed despite response");
|
||||
await user.click(screen.getByRole("button", { name: "저장" }));
|
||||
|
||||
expect(await screen.findByRole("status")).toHaveTextContent("저장했습니다.");
|
||||
expect(name).toHaveValue("");
|
||||
expect(
|
||||
screen.queryByText("저장하지 못했습니다. 잠시 후 다시 시도해 주세요."),
|
||||
).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "저장" }));
|
||||
expect(createResource).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps stale data visible during refresh failure and recovers on retry", async () => {
|
||||
const user = userEvent.setup();
|
||||
const listResources = vi
|
||||
.fn<ReferenceFeatureInput["listResources"]>()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
value: [
|
||||
{
|
||||
resourceId: "existing",
|
||||
title: "Existing",
|
||||
createdAt: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: createFailure(
|
||||
"SERVER_FAILURE",
|
||||
"LIST_REFERENCE_RESOURCES",
|
||||
0,
|
||||
),
|
||||
})
|
||||
.mockResolvedValue({
|
||||
ok: true,
|
||||
value: [
|
||||
{
|
||||
resourceId: "recovered",
|
||||
title: "Recovered",
|
||||
createdAt: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
renderReference(inputWith({ listResources }));
|
||||
await screen.findByText("Existing");
|
||||
await user.click(screen.getByRole("button", { name: "새로고침" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("기존 정보를 표시하고 있습니다."),
|
||||
).toBeVisible();
|
||||
expect(screen.getByText("Existing")).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: "다시 시도" }));
|
||||
expect(await screen.findByText("Recovered")).toBeVisible();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText("stale-degraded")).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(listResources).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
|
||||
import { createRuntimeComposition } from "../../../src/bootstrap/create-runtime-composition.ts";
|
||||
import { RuntimeApplication } from "../../../src/bootstrap/runtime-application.tsx";
|
||||
import { createBootstrapHandlers } from "../../mocks/handlers/bootstrap.ts";
|
||||
import { createReferenceScenarioHandlers } from "../../mocks/handlers/reference-resources.ts";
|
||||
import { createStrictMockServer } from "../../mocks/server.ts";
|
||||
|
||||
const runtimeConfig = {
|
||||
APP_ENV: "local",
|
||||
API_BASE_URL: "https://api.test",
|
||||
REQUEST_TIMEOUT_MS: 10_000,
|
||||
MAX_RETRY_ATTEMPTS: 0,
|
||||
TELEMETRY_ENABLED: false,
|
||||
AUTH_MODE: "demo",
|
||||
CONFIG_SCHEMA_VERSION: "1",
|
||||
API_CONTRACT_VERSION: "1",
|
||||
RELEASE_MANIFEST_URL: "/release-manifest.json",
|
||||
BUILD_ID: "local-build",
|
||||
RELEASE_ID: "local-release",
|
||||
};
|
||||
const releaseManifest = {
|
||||
schemaVersion: 1,
|
||||
appVersion: "0.1.0",
|
||||
buildId: "local-build",
|
||||
commitSha: "local",
|
||||
configSchemaVersion: "1",
|
||||
apiContractVersion: "1",
|
||||
assetManifestHash: "test-hash",
|
||||
releaseId: "local-release",
|
||||
builtAt: "2026-07-26T00:00:00.000Z",
|
||||
routeChunks: {
|
||||
"route-home": "assets/home.js",
|
||||
"route-examples-ui": "assets/ui.js",
|
||||
"route-examples-states": "assets/states.js",
|
||||
"route-examples-auth": "assets/auth.js",
|
||||
"route-reference-resources": "assets/reference.js",
|
||||
"route-reference-resource-detail": "assets/reference-detail.js",
|
||||
"route-reference-resource-form": "assets/reference-form.js",
|
||||
"route-reference-resource-status": "assets/reference-status.js",
|
||||
"route-not-found": "assets/not-found.js",
|
||||
},
|
||||
};
|
||||
|
||||
const listRequests = vi.fn();
|
||||
const createRequests = vi.fn();
|
||||
const resources = [{ id: "reference-1", name: "Existing" }];
|
||||
const mockApi = createStrictMockServer(
|
||||
...createBootstrapHandlers(runtimeConfig, releaseManifest),
|
||||
...createReferenceScenarioHandlers({
|
||||
resources,
|
||||
onList: listRequests,
|
||||
onCreate: createRequests,
|
||||
}),
|
||||
);
|
||||
|
||||
beforeAll(mockApi.listen);
|
||||
afterEach(() => {
|
||||
mockApi.reset();
|
||||
listRequests.mockClear();
|
||||
createRequests.mockClear();
|
||||
resources.splice(1);
|
||||
});
|
||||
afterAll(mockApi.close);
|
||||
|
||||
const absoluteFetch: typeof fetch = (input, init) => {
|
||||
if (input instanceof Request) return fetch(input, init);
|
||||
const url = new URL(
|
||||
input instanceof URL ? input.href : input,
|
||||
"http://app.test",
|
||||
);
|
||||
return fetch(url, init);
|
||||
};
|
||||
|
||||
describe("reference feature production vertical path", () => {
|
||||
it("traverses bootstrap, router, application, HTTP schema/mapper and query cache", async () => {
|
||||
const user = userEvent.setup();
|
||||
const composition = await createRuntimeComposition({
|
||||
fetcher: absoluteFetch,
|
||||
host: {},
|
||||
});
|
||||
window.history.pushState(
|
||||
{},
|
||||
"",
|
||||
"/examples/reference-resources?tags=open&tags=new&limit=5",
|
||||
);
|
||||
render(<RuntimeApplication composition={composition} />);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "로그인 시작" }),
|
||||
);
|
||||
expect(await screen.findByText("Existing")).toBeVisible();
|
||||
expect(listRequests).toHaveBeenCalledWith(
|
||||
"?limit=5&tags=open&tags=new",
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "새 항목 만들기" }));
|
||||
await user.type(
|
||||
await screen.findByRole("textbox", { name: /새 항목 이름/ }),
|
||||
" Created ",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "저장" }));
|
||||
expect(await screen.findByText("저장했습니다.")).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: "목록으로 돌아가기" }));
|
||||
expect(await screen.findByText("Created")).toBeVisible();
|
||||
expect(createRequests).toHaveBeenCalledWith({ name: "Created" });
|
||||
expect(listRequests.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -27,9 +27,9 @@ import { ExploreKindPage } from "../../../src/features/tech-log/presentation/pub
|
||||
import { ExplorePage } from "../../../src/features/tech-log/presentation/public/pages/explore-page.tsx";
|
||||
import { HomePage } from "../../../src/features/tech-log/presentation/public/pages/home-page.tsx";
|
||||
import { SearchPage } from "../../../src/features/tech-log/presentation/public/pages/search-page.tsx";
|
||||
import { PublicNotFoundPage as NotFoundPage } from "../../../src/features/tech-log/presentation/public/pages/public-not-found-page.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 NotFoundPage from "../../../src/presentation/pages/not-found-page.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";
|
||||
|
||||
@@ -18,10 +18,10 @@ import { CasePage } from "../../../src/features/tech-log/presentation/public/pag
|
||||
import { QuestionPage } from "../../../src/features/tech-log/presentation/public/pages/question-page.tsx";
|
||||
import { ReferencePage } from "../../../src/features/tech-log/presentation/public/pages/reference-page.tsx";
|
||||
import { TopicPage } from "../../../src/features/tech-log/presentation/public/pages/topic-page.tsx";
|
||||
import { PublicNotFoundPage as NotFoundPage } from "../../../src/features/tech-log/presentation/public/pages/public-not-found-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 NotFoundPage from "../../../src/presentation/pages/not-found-page.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";
|
||||
|
||||
@@ -197,9 +197,11 @@ describe("TechLog route boundary contract", () => {
|
||||
).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));
|
||||
it("atomically installs the complete TechLog route and runtime inventories", () => {
|
||||
expect(Object.keys(ROUTE_REGISTRY)).toEqual(
|
||||
expectedRoutes.map(([routeId]) => routeId),
|
||||
);
|
||||
expect(Object.keys(ROUTE_RUNTIME)).toEqual(Object.keys(ROUTE_REGISTRY));
|
||||
expect(ROUTE_REGISTRY).toEqual(TECH_LOG_ROUTE_REGISTRY);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
|
||||
import { createTechLogFeatureInstalledInput } from "../../../src/features/tech-log/adapters/create-tech-log-feature-input.ts";
|
||||
import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts";
|
||||
import { StudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts";
|
||||
import type { StudioGateway } from "../../../src/features/tech-log/application/ports/studio-gateway.ts";
|
||||
import type { WorkingCopyInput } from "../../../src/features/tech-log/contracts/studio/contract.ts";
|
||||
import { PublicationEventPreviewScreen } from "../../../src/features/tech-log/presentation/studio/components/publication-event-preview-screen.tsx";
|
||||
import { PublicationList } from "../../../src/features/tech-log/presentation/studio/components/publication-list.tsx";
|
||||
import { PublishScreen } from "../../../src/features/tech-log/presentation/studio/components/publish-screen.tsx";
|
||||
import { StudioProvider } from "../../../src/features/tech-log/presentation/studio/studio-provider.tsx";
|
||||
|
||||
const originalShowModal = HTMLDialogElement.prototype.showModal;
|
||||
const originalClose = HTMLDialogElement.prototype.close;
|
||||
|
||||
class NoopIntersectionObserver implements IntersectionObserver {
|
||||
readonly root = null;
|
||||
readonly rootMargin = "0px";
|
||||
readonly scrollMargin = "0px";
|
||||
readonly thresholds = [0];
|
||||
|
||||
disconnect() {}
|
||||
observe() {}
|
||||
takeRecords(): IntersectionObserverEntry[] {
|
||||
return [];
|
||||
}
|
||||
unobserve() {}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal("IntersectionObserver", NoopIntersectionObserver);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(HTMLDialogElement.prototype, "showModal", {
|
||||
configurable: true,
|
||||
value(this: HTMLDialogElement) {
|
||||
this.setAttribute("open", "");
|
||||
},
|
||||
});
|
||||
Object.defineProperty(HTMLDialogElement.prototype, "close", {
|
||||
configurable: true,
|
||||
value(this: HTMLDialogElement) {
|
||||
this.removeAttribute("open");
|
||||
this.dispatchEvent(new Event("close"));
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
Object.defineProperty(HTMLDialogElement.prototype, "showModal", {
|
||||
configurable: true,
|
||||
value: originalShowModal,
|
||||
});
|
||||
Object.defineProperty(HTMLDialogElement.prototype, "close", {
|
||||
configurable: true,
|
||||
value: originalClose,
|
||||
});
|
||||
});
|
||||
|
||||
function renderInStudio(
|
||||
node: React.ReactNode,
|
||||
gateway: StudioGateway = createTechLogFeatureInstalledInput().input.createStudioGateway(),
|
||||
navigate: (href: string) => void = () => undefined,
|
||||
) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={["/studio"]}>
|
||||
<StudioProvider createGateway={() => gateway} navigate={navigate}>
|
||||
{node}
|
||||
</StudioProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
async function warningReadyDocument(gateway: StudioGateway) {
|
||||
const input: WorkingCopyInput = {
|
||||
kind: "CASE",
|
||||
title: "게시 경고 예시",
|
||||
slug: "publish-warning-example",
|
||||
summary: "경고 확인 뒤 게시합니다.",
|
||||
topicId: FIXTURE_IDS.topicJpa,
|
||||
projectId: null,
|
||||
relations: [],
|
||||
problem: "경고가 있습니다.",
|
||||
conclusion: "확인 뒤 게시합니다.",
|
||||
environment: "Studio",
|
||||
reproduction: "Mock",
|
||||
lastVerifiedOn: "2026-08-14",
|
||||
bodyMarkdown: "게시할 본문",
|
||||
};
|
||||
const document = await gateway.createDocument(input, {
|
||||
idempotencyKey: "publication-test-create",
|
||||
});
|
||||
const validation = await gateway.validateDocument(
|
||||
document.id,
|
||||
{ expectedVersion: 1 },
|
||||
{ idempotencyKey: "publication-test-validation" },
|
||||
);
|
||||
await gateway.createPreview(
|
||||
document.id,
|
||||
{ expectedVersion: 1, validationId: validation.validationId },
|
||||
{ idempotencyKey: "publication-test-preview" },
|
||||
);
|
||||
return document;
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((nextResolve, nextReject) => {
|
||||
resolve = nextResolve;
|
||||
reject = nextReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe("TechLog Studio publication flow", () => {
|
||||
it("blocks invalid and stale saved versions before a publish command can start", async () => {
|
||||
const invalid = renderInStudio(
|
||||
<PublishScreen documentId={FIXTURE_IDS.edgeTokenQuestion} />,
|
||||
);
|
||||
expect(await screen.findByText("검증 오류를 먼저 수정해야 합니다")).toBeVisible();
|
||||
expect(screen.queryByRole("button", { name: "게시" })).not.toBeInTheDocument();
|
||||
invalid.unmount();
|
||||
|
||||
renderInStudio(<PublishScreen documentId={FIXTURE_IDS.fetchJoinCase} />);
|
||||
expect(await screen.findByText("검증 결과가 현재 버전과 다릅니다")).toBeVisible();
|
||||
expect(screen.getByRole("link", { name: "다시 검증" })).toHaveAttribute(
|
||||
"href",
|
||||
`/studio/documents/${FIXTURE_IDS.fetchJoinCase}/validation`,
|
||||
);
|
||||
});
|
||||
|
||||
it("publishes a current warning preview only after every warning is acknowledged", async () => {
|
||||
const gateway = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||
const document = await warningReadyDocument(gateway);
|
||||
const destinations: string[] = [];
|
||||
renderInStudio(
|
||||
<PublishScreen documentId={document.id} />,
|
||||
gateway,
|
||||
(href) => destinations.push(href),
|
||||
);
|
||||
|
||||
const publish = await screen.findByRole("button", { name: "게시" });
|
||||
expect(publish).toBeDisabled();
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: /PROJECT_MISSING/ }));
|
||||
expect(publish).toBeEnabled();
|
||||
await userEvent.click(publish);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(destinations[0]).toMatch(
|
||||
/^\/studio\/publications\/[0-9a-f-]+\/preview$/,
|
||||
),
|
||||
);
|
||||
expect(screen.getByRole("status", { name: "" })).toHaveTextContent("게시했습니다.");
|
||||
expect((await gateway.listPublications({ limit: 100 })).items[0]).toMatchObject({
|
||||
event: { type: "PUBLISHED", publishedVersion: 1 },
|
||||
publication: { status: "PUBLISHED", publicPath: "/cases/publish-warning-example" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the publish pending state, preserves gateway command order, and retries with a new key", async () => {
|
||||
const user = userEvent.setup();
|
||||
const base = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||
const document = await warningReadyDocument(base);
|
||||
const first = deferred<never>();
|
||||
const calls: string[] = [];
|
||||
const keys: string[] = [];
|
||||
const publishDocument = vi
|
||||
.fn<StudioGateway["publishDocument"]>()
|
||||
.mockImplementationOnce((_id, _command, options) => {
|
||||
calls.push("publishDocument");
|
||||
keys.push(options.idempotencyKey);
|
||||
return first.promise;
|
||||
})
|
||||
.mockImplementation((...args) => {
|
||||
calls.push("publishDocument");
|
||||
keys.push(args[2].idempotencyKey);
|
||||
return base.publishDocument(...args);
|
||||
});
|
||||
const gateway = {
|
||||
...base,
|
||||
getDocument(...args: Parameters<StudioGateway["getDocument"]>) {
|
||||
calls.push("getDocument");
|
||||
return base.getDocument(...args);
|
||||
},
|
||||
getCurrentPreview(...args: Parameters<StudioGateway["getCurrentPreview"]>) {
|
||||
calls.push("getCurrentPreview");
|
||||
return base.getCurrentPreview(...args);
|
||||
},
|
||||
publishDocument,
|
||||
} satisfies StudioGateway;
|
||||
renderInStudio(<PublishScreen documentId={document.id} />, gateway);
|
||||
|
||||
await user.click(await screen.findByRole("checkbox", { name: /PROJECT_MISSING/ }));
|
||||
await user.click(screen.getByRole("button", { name: "게시" }));
|
||||
expect(screen.getByRole("button", { name: "게시 중…" })).toBeDisabled();
|
||||
expect(calls.slice(0, 3)).toEqual([
|
||||
"getDocument",
|
||||
"getCurrentPreview",
|
||||
"publishDocument",
|
||||
]);
|
||||
|
||||
first.reject(new StudioGatewayError({
|
||||
type: "https://techlog.local/problems/studio-unavailable",
|
||||
title: "STUDIO_UNAVAILABLE",
|
||||
status: 503,
|
||||
detail: "Studio가 잠시 응답하지 않습니다.",
|
||||
code: "STUDIO_UNAVAILABLE",
|
||||
retryable: true,
|
||||
}));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Studio가 잠시 응답하지 않습니다.",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "게시" }));
|
||||
await waitFor(() => expect(publishDocument).toHaveBeenCalledTimes(2));
|
||||
expect(keys[1]).not.toBe(keys[0]);
|
||||
});
|
||||
|
||||
it("filters publication events and recovers a failed history read", async () => {
|
||||
const user = userEvent.setup();
|
||||
const base = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||
const listPublications = vi
|
||||
.fn<StudioGateway["listPublications"]>()
|
||||
.mockRejectedValueOnce(new Error("offline"))
|
||||
.mockImplementation((query, options) => base.listPublications(query, options));
|
||||
renderInStudio(<PublicationList />, { ...base, listPublications });
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"게시 기록을 불러오지 못했습니다offline",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "다시 시도" }));
|
||||
expect(await screen.findByRole("heading", { name: "Redis Adapter가 도메인 TTL 정책을 소유하지 않는 이유" })).toBeVisible();
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("이벤트"), "UNPUBLISHED");
|
||||
await user.type(screen.getByLabelText("검색"), "Fetch 전략");
|
||||
await user.click(screen.getByRole("button", { name: "적용" }));
|
||||
expect(await screen.findByRole("heading", { name: "JPA 목록 조회에서 Fetch 전략을 선택하는 기준" })).toBeVisible();
|
||||
expect(screen.queryByRole("heading", { name: "Redis Adapter가 도메인 TTL 정책을 소유하지 않는 이유" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("unpublishes only the selected current row after the source confirmation", async () => {
|
||||
const user = userEvent.setup();
|
||||
const gateway = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||
renderInStudio(<PublicationList />, gateway);
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: /Redis Adapter.*게시 취소/ }),
|
||||
);
|
||||
|
||||
expect(screen.getByRole("dialog", { name: "게시를 취소할까요?" })).toHaveTextContent(
|
||||
"Studio 게시 상태를 중단하고 게시 취소 이벤트를 남깁니다.",
|
||||
);
|
||||
expect(screen.getByText("작업본과 이전 Snapshot은 보존됩니다.")).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: "게시 취소 확인" }));
|
||||
|
||||
await waitFor(() => expect(screen.getAllByText("게시를 취소했습니다.").length).toBeGreaterThanOrEqual(1));
|
||||
expect(await screen.findAllByRole("link", { name: "게시 취소 전 Snapshot 보기" })).not.toHaveLength(0);
|
||||
});
|
||||
|
||||
it("renders the event's immutable snapshot instead of a newer working copy", async () => {
|
||||
const view = renderInStudio(
|
||||
<PublicationEventPreviewScreen publicationEventId={FIXTURE_IDS.fetchPublishedEvent} />,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("heading", { level: 1, name: "컬렉션 Fetch Join과 페이징은 왜 충돌하는가" })).toBeVisible();
|
||||
expect(screen.getByText(/반환된 20건 뒤에서 전체 컬렉션이 로드되는 과정/)).toBeVisible();
|
||||
expect(screen.queryByText("게시 후 본문 측정값을 보완한 저장본입니다.")).not.toBeInTheDocument();
|
||||
expect(view.container.querySelectorAll("main")).toHaveLength(0);
|
||||
expect(view.container.querySelector(".public-record-embedded")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps unknown publication events inside the Studio not-found screen", async () => {
|
||||
renderInStudio(
|
||||
<PublicationEventPreviewScreen publicationEventId="99999999-9999-4999-8999-999999999999" />,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("heading", { level: 1, name: "게시 기록을 찾을 수 없습니다" })).toBeVisible();
|
||||
expect(screen.getByRole("link", { name: "게시 기록으로 돌아가기" })).toHaveAttribute(
|
||||
"href",
|
||||
"/studio/publications",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,7 @@ describe("installed route registry", () => {
|
||||
});
|
||||
|
||||
it("allows public routes without consulting a product permission", () => {
|
||||
expect(decideRouteAccess("APP_HOME", "unauthenticated")).toEqual({
|
||||
expect(decideRouteAccess("TECH_LOG_HOME", "unauthenticated")).toEqual({
|
||||
allowed: true,
|
||||
action: "none",
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user