// @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 } from "../../../src/features/reference-feature/contracts/reference-feature-contract.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 render(
,
);
}
function inputWith(
overrides: Partial = {},
): 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("renders loading, success and empty states through the installed route", async () => {
let resolveList:
| ((result: ReferenceResult) => void)
| undefined;
const pending = new Promise<
ReferenceResult
>((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) => void)
| undefined;
const createResource = vi.fn(
() =>
new Promise>((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,
),
});
expect(await screen.findByText(/다른 변경과 충돌했습니다/)).toBeVisible();
expect(
screen.getByRole("textbox", { name: /새 항목 이름/ }),
).toHaveValue("Conflicting");
expect(screen.getByLabelText("설명")).toHaveValue("Keep this input");
});
it("keeps stale data visible during refresh failure and recovers on retry", async () => {
const user = userEvent.setup();
const listResources = vi
.fn()
.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);
});
});