From c6b7a9b9bc657c04d6b740b43f5372db426a7261 Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 21:07:48 +0900 Subject: [PATCH] feat: add removable sample vertical contract fixture --- .gitignore | 1 + package.json | 1 + scripts/test-sample-removal.mjs | 77 +++++++++++++++++++ src/sample/contract-fixture/sample-facade.js | 53 +++++++++++++ .../contract-fixture/sample-resource-page.jsx | 61 +++++++++++++++ tests/component/sample-resource-page.test.jsx | 49 ++++++++++++ .../integration/sample-vertical-slice.test.js | 53 +++++++++++++ 7 files changed, 295 insertions(+) create mode 100644 scripts/test-sample-removal.mjs create mode 100644 src/sample/contract-fixture/sample-facade.js create mode 100644 src/sample/contract-fixture/sample-resource-page.jsx create mode 100644 tests/component/sample-resource-page.test.jsx create mode 100644 tests/integration/sample-vertical-slice.test.js diff --git a/.gitignore b/.gitignore index b25eba7..f2b1e12 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ dist/ .vite/ +.tmp/ playwright-report/ test-results/ coverage/ diff --git a/package.json b/package.json index 6d90f53..982deb5 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "test:integration": "vitest run tests/integration --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/integration.xml", "test:e2e": "playwright test", "test:a11y": "playwright test --grep @a11y", + "test:sample-removal": "node scripts/test-sample-removal.mjs", "test:all": "pnpm test:runtime-schema && pnpm test:unit && pnpm test:component && pnpm test:integration" }, "dependencies": { diff --git a/scripts/test-sample-removal.mjs b/scripts/test-sample-removal.mjs new file mode 100644 index 0000000..13b0d0f --- /dev/null +++ b/scripts/test-sample-removal.mjs @@ -0,0 +1,77 @@ +import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; + +const fixtureRoot = path.resolve(".tmp/sample-removal"); +const sampleRoot = path.resolve("src/sample/contract-fixture"); +const sourceRoot = path.resolve("src"); +const pnpmCli = /** @type {string} */ (process.env.npm_execpath); + +/** @param {string} directory @returns {Promise} */ +async function sourceFiles(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const nested = /** @type {string[][]} */ (await Promise.all( + entries.map((entry) => { + const target = path.join(directory, entry.name); + return entry.isDirectory() ? sourceFiles(target) : [target]; + }), + )); + return nested.flat(); +} + +await rm(fixtureRoot, { recursive: true, force: true }); +await mkdir(fixtureRoot, { recursive: true }); + +const incomingImports = []; +for (const sourceFile of await sourceFiles(sourceRoot)) { + if (sourceFile.startsWith(sampleRoot)) continue; + const content = await readFile(sourceFile, "utf8"); + if (/from\s+["'][^"']*sample\/contract-fixture/.test(content)) { + incomingImports.push(path.relative(".", sourceFile)); + } +} + +let buildStatus = 1; +if (incomingImports.length === 0) { + await cp("src", path.join(fixtureRoot, "src"), { + recursive: true, + filter: (source) => !source.startsWith(sampleRoot), + }); + await cp("public", path.join(fixtureRoot, "public"), { recursive: true }); + await cp("index.html", path.join(fixtureRoot, "index.html")); + + const result = spawnSync( + process.execPath, + [ + pnpmCli, + "exec", + "vite", + "build", + fixtureRoot, + "--outDir", + path.join(fixtureRoot, "dist"), + ], + { stdio: "inherit" }, + ); + buildStatus = result.status ?? 1; +} + +await mkdir("artifacts/tests", { recursive: true }); +const passed = incomingImports.length === 0 && buildStatus === 0; +await writeFile( + "artifacts/tests/sample-removal.xml", + `\n` + + `` + + `` + + `${passed ? "" : ""}` + + `\n`, +); +await rm(fixtureRoot, { recursive: true, force: true }); + +if (!passed) { + process.stderr.write( + `Sample removal failed. Incoming imports: ${incomingImports.join(", ")}\n`, + ); + process.exit(1); +} +process.stdout.write("Sample removal smoke: PASS\n"); diff --git a/src/sample/contract-fixture/sample-facade.js b/src/sample/contract-fixture/sample-facade.js new file mode 100644 index 0000000..62f4538 --- /dev/null +++ b/src/sample/contract-fixture/sample-facade.js @@ -0,0 +1,53 @@ +import { toResourceViewModel } from "../../application/view-models/resource-view-model.js"; +import { queryKeys } from "../../contracts/query-keys.js"; + +/** + * @param {{ + * http: { execute(operationId: string, input?: Record): Promise< + * {ok: true, value: unknown} | {ok: false, error: import("../../contracts/errors.js").ApiFailure} + * > }, + * cache: import("../../application/ports/query-cache-port.js").QueryCachePort + * }} ports + */ +export function createSampleFacade(ports) { + return Object.freeze({ + async listResources(filters = {}) { + const key = queryKeys.resource.list(filters); + const result = await ports.http.execute("LIST_SAMPLE_RESOURCES", { + routeId: "SAMPLE_RESOURCE_LIST", + }); + if (!result.ok) return result; + + const models = + /** @type {Array} */ ( + result.value + ); + const cached = ports.cache.write(key, models); + if (!cached.ok) return cached; + return { + ok: /** @type {true} */ (true), + value: models.map((model) => toResourceViewModel(model)), + }; + }, + + /** @param {{ name: string }} command */ + async createResource(command) { + const result = await ports.http.execute("CREATE_SAMPLE_RESOURCE", { + body: command, + routeId: "SAMPLE_RESOURCE_LIST", + }); + if (!result.ok) return result; + + const invalidated = await ports.cache.invalidate(queryKeys.resource.all()); + if (!invalidated.ok) return invalidated; + return { + ok: /** @type {true} */ (true), + value: toResourceViewModel( + /** @type {import("../../domain/models/resource.js").Resource} */ ( + result.value + ), + ), + }; + }, + }); +} diff --git a/src/sample/contract-fixture/sample-resource-page.jsx b/src/sample/contract-fixture/sample-resource-page.jsx new file mode 100644 index 0000000..6ace8ca --- /dev/null +++ b/src/sample/contract-fixture/sample-resource-page.jsx @@ -0,0 +1,61 @@ +import { useEffect, useState } from "react"; + +import { deriveAsyncState } from "../../application/view-models/async-state.js"; +import { AsyncSurface } from "../../presentation/components/async-surface.jsx"; + +/** + * @typedef {{ + * loading: boolean, + * resources?: Array<{resourceId: string, title: string, createdAtLabel: string | null}>, + * failure?: import("../../contracts/errors.js").ApiFailure + * }} SamplePageState + */ + +/** + * @param {{ + * facade: ReturnType + * }} props + */ +export function SampleResourcePage({ facade }) { + const [result, setResult] = useState( + /** @type {SamplePageState} */ ({ + loading: true, + resources: undefined, + failure: undefined, + }), + ); + + useEffect(() => { + let active = true; + void facade.listResources().then((outcome) => { + if (!active) return; + setResult( + outcome.ok + ? { loading: false, resources: outcome.value, failure: undefined } + : { loading: false, resources: undefined, failure: outcome.error }, + ); + }); + return () => { + active = false; + }; + }, [facade]); + + const state = deriveAsyncState({ + isInitialLoading: result.loading, + data: result.resources, + failure: result.failure, + }); + + return ( +
+

샘플 리소스

+ +
    + {(result.resources ?? []).map((resource) => ( +
  • {resource.title}
  • + ))} +
+
+
+ ); +} diff --git a/tests/component/sample-resource-page.test.jsx b/tests/component/sample-resource-page.test.jsx new file mode 100644 index 0000000..34d83bb --- /dev/null +++ b/tests/component/sample-resource-page.test.jsx @@ -0,0 +1,49 @@ +// @vitest-environment jsdom + +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { SampleResourcePage } from "../../src/sample/contract-fixture/sample-resource-page.jsx"; + +describe("removable sample feature page", () => { + it("renders the API-to-view-model result through AsyncSurface", async () => { + const facade = { + listResources: async () => ({ + ok: true, + value: [ + { + resourceId: "resource-1", + title: "Example", + createdAtLabel: null, + }, + ], + }), + createResource: async () => ({ ok: true, value: {} }), + }; + render(); + + expect(screen.getByLabelText("불러오는 중")).toBeVisible(); + expect(await screen.findByText("Example")).toBeVisible(); + }); + + it("renders normalized terminal errors without raw DTO fields", async () => { + const facade = { + listResources: async () => ({ + ok: false, + error: { + kind: "SERVER_FAILURE", + code: "SERVER_FAILURE", + retryable: true, + operationId: "LIST_SAMPLE_RESOURCES", + attemptCount: 1, + userMessageKey: "error.server_failure", + action: "retry", + }, + }), + createResource: async () => ({ ok: true, value: {} }), + }; + render(); + + expect(await screen.findByRole("alert")).toHaveTextContent("error.server_failure"); + }); +}); diff --git a/tests/integration/sample-vertical-slice.test.js b/tests/integration/sample-vertical-slice.test.js new file mode 100644 index 0000000..414ac28 --- /dev/null +++ b/tests/integration/sample-vertical-slice.test.js @@ -0,0 +1,53 @@ +import { HttpResponse, http } from "msw"; +import { setupServer } from "msw/node"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { createHttpClient } from "../../src/adapters/http/client.js"; +import { + createQueryCacheAdapter, + createQueryClient, +} from "../../src/adapters/query-cache/tanstack-query-cache.js"; +import { queryKeys } from "../../src/contracts/query-keys.js"; +import { createSampleFacade } from "../../src/sample/contract-fixture/sample-facade.js"; + +const server = setupServer( + http.get("https://api.test/api/sample/resources", () => + HttpResponse.json({ + success: true, + data: [{ id: "resource-1", name: "Example" }], + meta: { requestId: "request-1", traceId: "trace-1" }, + }), + ), +); + +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterAll(() => server.close()); + +describe("sample vertical contract fixture", () => { + it("traverses API, schema, mapper, application facade, and cache", async () => { + const queryClient = createQueryClient(); + const cache = createQueryCacheAdapter(queryClient); + const facade = createSampleFacade({ + http: createHttpClient({ + baseUrl: "https://api.test", + clock: { now: () => 0, sleep: async () => {} }, + }), + cache, + }); + + await expect(facade.listResources()).resolves.toEqual({ + ok: true, + value: [ + { + resourceId: "resource-1", + title: "Example", + createdAtLabel: null, + }, + ], + }); + expect(cache.read(queryKeys.resource.list({}))).toMatchObject({ + ok: true, + value: [{ id: "resource-1", displayName: "Example" }], + }); + }); +});