491 lines
16 KiB
TypeScript
491 lines
16 KiB
TypeScript
// @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: "Clean Architecture Frontend",
|
|
}),
|
|
).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: "Clean Architecture Frontend",
|
|
}),
|
|
).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);
|
|
});
|
|
});
|