// @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, { 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() .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); }); });