Merge branch 'feature-sample-feature-slice-contract-fixture' into develop
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
.tmp/
|
||||
playwright-report/
|
||||
test-results/
|
||||
coverage/
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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<string[]>} */
|
||||
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",
|
||||
`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
||||
`<testsuite name="sample-removal" tests="2" failures="${passed ? 0 : 1}">` +
|
||||
`<testcase name="no-product-import"/>` +
|
||||
`<testcase name="production-build">${passed ? "" : "<failure/>"}</testcase>` +
|
||||
`</testsuite>\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");
|
||||
@@ -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<string, unknown>): 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<import("../../domain/models/resource.js").Resource>} */ (
|
||||
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
|
||||
),
|
||||
),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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<typeof import("./sample-facade.js").createSampleFacade>
|
||||
* }} 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 (
|
||||
<main>
|
||||
<h1>샘플 리소스</h1>
|
||||
<AsyncSurface state={state}>
|
||||
<ul>
|
||||
{(result.resources ?? []).map((resource) => (
|
||||
<li key={resource.resourceId}>{resource.title}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AsyncSurface>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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(<SampleResourcePage facade={facade} />);
|
||||
|
||||
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(<SampleResourcePage facade={facade} />);
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("error.server_failure");
|
||||
});
|
||||
});
|
||||
@@ -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" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user