`PublicContentQueries` returned arrays, not promises. That signature is only implementable by something already in memory, so the port could hold exactly one adapter — the bundled fixture — and no amount of configuration could put the public site on the backend. Turning it async is the change that makes a second adapter possible; the adapter itself follows. The markup is untouched. Every page reads a value and hands it to a presentational component, so the shape those components receive is mapped at the adapter boundary and nothing below the page changes. Screens load through one query, not one per read. Several pages read in a loop — the home timeline walks every project for its activity, the explore filter walks search results to resolve titles — and a hook per read would mean a variable number of hooks per render, which React forbids. `usePublicContent` takes the whole screen's reads as one loader, where a loop is a loop and `Promise.all` is available; the loops that used to be N sequential lookups now issue together. Two places deliberately do not show the loading surface. The explore filter sits inside a page that already renders one, so a second skeleton would move the layout under it — it keeps its structure and fills its options in when they arrive. The search dialog is a type-ahead: re-querying per keystroke would replace the results with a skeleton on every key, so it loads the catalog once and applies the same predicate locally. `usePublicContent` requires an object because `undefined` is how the query layer says "no result yet". A loader returning the record itself would make a missing slug indistinguishable from a request in flight, and the page would sit on a skeleton instead of rendering its not-found route. Studio's `resolvePublishedLabel` stays synchronous. It is called from inside the public renderer, so making it async would push awaits through the render tree; the shell loads the catalog once and the callback remains a lookup. The component tests now assemble the query providers the running app assembles. Without them the render throws "No QueryClient set" — not a harness quirk, but the same failure the app would produce if it were mounted without its query layer.
131 lines
4.7 KiB
TypeScript
131 lines
4.7 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { test } from "vitest";
|
|
|
|
import type { ApplicationFeatureInputs } from "../../../src/application/ports/in/application-api.ts";
|
|
import { createInstalledFeatureInputs } from "../../../src/features/installed-feature-adapters.ts";
|
|
import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts";
|
|
import { createCsrfTokenProvider } from "../../../src/features/tech-log/adapters/http/studio-session-csrf.ts";
|
|
import type { TechLogFeatureInput } from "../../../src/features/tech-log/application/tech-log-feature-input.ts";
|
|
import type { WorkingCopy } from "../../../src/features/tech-log/contracts/studio/contract.ts";
|
|
|
|
type Equal<Left, Right> =
|
|
(<Value>() => Value extends Left ? 1 : 2) extends <Value>() =>
|
|
Value extends Right ? 1 : 2
|
|
? true
|
|
: false;
|
|
type Expect<Value extends true> = Value;
|
|
|
|
type InstalledInputs = ReturnType<typeof createInstalledFeatureInputs>;
|
|
type _InstalledIdsAreExact = Expect<
|
|
Equal<keyof InstalledInputs, "reference-feature" | "tech-log">
|
|
>;
|
|
type _TechLogRegistryValueIsExact = Expect<
|
|
Equal<ApplicationFeatureInputs["tech-log"], TechLogFeatureInput>
|
|
>;
|
|
void (0 as unknown as _InstalledIdsAreExact);
|
|
void (0 as unknown as _TechLogRegistryValueIsExact);
|
|
|
|
function inputOf(document: WorkingCopy) {
|
|
const { id, version, updatedAt, ...input } = document;
|
|
void id;
|
|
void version;
|
|
void updatedAt;
|
|
return input;
|
|
}
|
|
|
|
function installedInputs(
|
|
studioSource: "MOCK" | "HTTP" = "MOCK",
|
|
publicSource: "MOCK" | "HTTP" = "MOCK",
|
|
): InstalledInputs {
|
|
return createInstalledFeatureInputs({
|
|
studioSource,
|
|
publicSource,
|
|
contractOperations: {
|
|
async execute() {
|
|
throw new Error("reference executor is not used by composition tests");
|
|
},
|
|
},
|
|
apiBaseUrl: "http://composition.test/",
|
|
requestTimeoutMs: 10_000,
|
|
csrf: createCsrfTokenProvider({
|
|
async execute() {
|
|
throw new Error("CSRF provider is not used by composition tests");
|
|
},
|
|
}),
|
|
});
|
|
}
|
|
|
|
test("installs TechLog beside the retained reference feature through application-facing inputs", async () => {
|
|
const installed = installedInputs();
|
|
|
|
assert.deepEqual(Object.keys(installed), ["reference-feature", "tech-log"]);
|
|
assert.deepEqual(Object.keys(installed["tech-log"]).sort(), [
|
|
"createStudioAssetGateway",
|
|
"createStudioGateway",
|
|
"publicContent",
|
|
]);
|
|
assert.equal(Object.isFrozen(installed), true);
|
|
assert.equal(Object.isFrozen(installed["tech-log"]), true);
|
|
assert.equal(Object.isFrozen(installed["tech-log"].publicContent), true);
|
|
assert.equal(
|
|
(await installed["tech-log"].publicContent.getRelease("0.1.0"))?.title,
|
|
"TechLog Public·Studio 경계를 확정했습니다",
|
|
);
|
|
});
|
|
|
|
test("selects the mock gateway by default and the HTTP gateway when switched", async () => {
|
|
const mockGateway = installedInputs("MOCK")["tech-log"].createStudioGateway();
|
|
const httpGateway = installedInputs("HTTP")["tech-log"].createStudioGateway();
|
|
|
|
// The mock reads an in-memory fixture immediately. HTTP defers to the
|
|
// (here, throwing) contract executor, so it rejects instead.
|
|
await assert.doesNotReject(mockGateway.getDashboard());
|
|
await assert.rejects(httpGateway.getDashboard());
|
|
});
|
|
|
|
test("each createStudioGateway call owns an isolated mutable Studio session", async () => {
|
|
const installed = installedInputs();
|
|
const first = installed["tech-log"].createStudioGateway();
|
|
const second = installed["tech-log"].createStudioGateway();
|
|
|
|
assert.notEqual(first, second);
|
|
const firstBefore = await first.getDocument(FIXTURE_IDS.fetchJoinCase);
|
|
const secondBefore = await second.getDocument(FIXTURE_IDS.fetchJoinCase);
|
|
assert.equal(firstBefore.document.title, secondBefore.document.title);
|
|
|
|
const saved = await first.saveDocument(
|
|
firstBefore.document.id,
|
|
{
|
|
expectedVersion: firstBefore.document.version,
|
|
document: {
|
|
...inputOf(firstBefore.document),
|
|
title: "첫 번째 세션에서만 수정",
|
|
},
|
|
},
|
|
{ idempotencyKey: "session-isolation" },
|
|
);
|
|
|
|
assert.equal(saved.document.title, "첫 번째 세션에서만 수정");
|
|
assert.equal(
|
|
(await second.getDocument(FIXTURE_IDS.fetchJoinCase)).document.title,
|
|
secondBefore.document.title,
|
|
);
|
|
assert.equal(
|
|
(
|
|
await installed["tech-log"].publicContent.getRecord(
|
|
"CASE",
|
|
"collection-fetch-join-pagination",
|
|
)
|
|
)?.title,
|
|
"컬렉션 Fetch Join과 페이징은 왜 충돌하는가",
|
|
);
|
|
});
|
|
|
|
test("each Studio session gets its own asset gateway instance", () => {
|
|
const installed = installedInputs("HTTP");
|
|
assert.notEqual(
|
|
installed["tech-log"].createStudioAssetGateway(),
|
|
installed["tech-log"].createStudioAssetGateway(),
|
|
);
|
|
});
|