feat: execute HTTP and query runtime contracts

This commit is contained in:
donghyeon-ka
2026-07-26 14:05:12 +09:00
parent 8aaaa033c0
commit ad55e21a3d
29 changed files with 1326 additions and 185 deletions
+218
View File
@@ -0,0 +1,218 @@
// @vitest-environment jsdom
import {
act,
renderHook,
waitFor,
} from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import {
useApplicationMutation,
useApplicationQuery,
} from "../../src/presentation/adapters/query/application-query.js";
import { createFailure } from "../../src/contracts/errors.js";
function queryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: 0, gcTime: Infinity },
mutations: { retry: false },
},
});
}
/** @param {QueryClient} client */
function wrapper(client) {
/** @param {{children: React.ReactNode}} props */
return function QueryWrapper({ children }) {
return (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
};
}
describe("application query inbound bridge", () => {
it("latches a background failure over stale data and clears it on retry success", async () => {
const client = queryClient();
const responses = [
{ ok: /** @type {const} */ (true), value: ["first"] },
{
ok: /** @type {const} */ (false),
error: createFailure("SERVER_FAILURE", "LIST", 0),
},
{ ok: /** @type {const} */ (true), value: ["recovered"] },
];
const execute = vi.fn(async () => responses.shift() ?? responses[0]);
const hook = renderHook(
() =>
useApplicationQuery({
queryKey: ["resource", "list"],
execute,
}),
{ wrapper: wrapper(client) },
);
await waitFor(() => expect(hook.result.current.data).toEqual(["first"]));
await act(() => hook.result.current.retry());
await waitFor(() =>
expect(hook.result.current.state.indicator).toBe("stale-degraded"),
);
expect(hook.result.current.state.base).toBe("success");
expect(hook.result.current.data).toEqual(["first"]);
await act(() => hook.result.current.retry());
await waitFor(() =>
expect(hook.result.current.data).toEqual(["recovered"]),
);
expect(hook.result.current.state.indicator).toBeNull();
expect(execute).toHaveBeenCalledTimes(3);
});
it("projects an initial application failure into terminal state", async () => {
const client = queryClient();
const failure = createFailure("FORBIDDEN", "LIST", 0);
const hook = renderHook(
() =>
useApplicationQuery({
queryKey: ["forbidden"],
execute: async () => ({ ok: false, error: failure }),
}),
{ wrapper: wrapper(client) },
);
await waitFor(() =>
expect(hook.result.current.state.base).toBe("terminal-error"),
);
expect(hook.result.current.state.failure).toBe(failure);
});
it("passes cancellation to the application and does not retain an unmounted error", async () => {
const client = queryClient();
let aborted = false;
const execute = vi.fn(
({ signal }) =>
new Promise((resolve) => {
signal.addEventListener(
"abort",
() => {
aborted = true;
resolve({
ok: false,
error: createFailure("REQUEST_ABORTED", "LIST", 0),
});
},
{ once: true },
);
}),
);
const hook = renderHook(
() =>
useApplicationQuery({
queryKey: ["cancelled"],
execute,
}),
{ wrapper: wrapper(client) },
);
await waitFor(() => expect(execute).toHaveBeenCalledOnce());
hook.unmount();
await waitFor(() => expect(aborted).toBe(true));
expect(client.getQueryState(["cancelled"])?.status).not.toBe("error");
});
});
describe("application mutation inbound bridge", () => {
it("deduplicates submit and commits one optimistic mutation", async () => {
const client = queryClient();
const key = ["resource", "list"];
client.setQueryData(key, ["existing"]);
/** @type {(value: {ok: true, value: string}) => void} */
let complete = () => {};
const execute = vi.fn(
() =>
new Promise((resolve) => {
complete = resolve;
}),
);
const hook = renderHook(
() =>
useApplicationMutation({
execute,
invalidate: [["resource"]],
currentData: client.getQueryData(key),
optimistic: {
queryKey: key,
update: (previous, input) => [
.../** @type {string[]} */ (previous),
input,
],
},
}),
{ wrapper: wrapper(client) },
);
/** @type {ReturnType<typeof hook.result.current.submit> | null} */
let first = null;
/** @type {ReturnType<typeof hook.result.current.submit> | null} */
let duplicate = null;
act(() => {
first = hook.result.current.submit("created");
duplicate = hook.result.current.submit("created");
});
expect(first).toBe(duplicate);
expect(client.getQueryData(key)).toEqual(["existing", "created"]);
await waitFor(() => expect(execute).toHaveBeenCalledOnce());
await waitFor(() =>
expect(hook.result.current.state.indicator).toBe("mutation-pending"),
);
complete({ ok: true, value: "created" });
if (!first) throw new Error("expected pending mutation");
await act(() => first);
expect(client.getQueryState(key)?.isInvalidated).toBe(true);
await waitFor(() =>
expect(hook.result.current.state.indicator).toBeNull(),
);
});
it("rolls optimistic data back and exposes a resolvable conflict", async () => {
const client = queryClient();
const key = ["resource", "list"];
client.setQueryData(key, ["existing"]);
const conflict = createFailure("CONFLICT", "CREATE", 0);
const hook = renderHook(
() =>
useApplicationMutation({
execute: async () => ({ ok: false, error: conflict }),
invalidate: [["resource"]],
currentData: client.getQueryData(key),
optimistic: {
queryKey: key,
update: (previous, input) => [
.../** @type {string[]} */ (previous),
input,
],
},
}),
{ wrapper: wrapper(client) },
);
let outcome;
await act(async () => {
outcome = await hook.result.current.submit("conflicting");
});
expect(outcome).toEqual({ ok: false, error: conflict });
expect(client.getQueryData(key)).toEqual(["existing"]);
expect(hook.result.current.state.indicator).toBe("mutation-conflict");
expect(hook.result.current.state.overlay).toMatchObject({
mutationPending: false,
mutationConflict: true,
});
await act(() => hook.result.current.resolveConflict());
expect(hook.result.current.state.indicator).toBeNull();
expect(client.getQueryState(key)?.isInvalidated).toBe(true);
});
});
+39 -5
View File
@@ -1,7 +1,8 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { deriveAsyncState } from "../../src/application/view-models/async-state.js";
import { AsyncSurface } from "../../src/presentation/components/async-surface.jsx";
@@ -29,7 +30,7 @@ describe("async UI state matrix", () => {
expect(deriveAsyncState(signals).indicator).toBe(indicator);
});
it("uses deterministic overlay priority for crossed states", () => {
it("makes crossed overlay inputs mutually exclusive by priority", () => {
const state = deriveAsyncState({
data: ["value"],
isFetching: true,
@@ -38,8 +39,8 @@ describe("async UI state matrix", () => {
});
expect(state.indicator).toBe("mutation-conflict");
expect(state.overlay).toMatchObject({
refreshing: true,
mutationPending: true,
refreshing: false,
mutationPending: false,
mutationConflict: true,
});
});
@@ -52,12 +53,45 @@ describe("async UI state matrix", () => {
expect(screen.getByRole("status")).toHaveTextContent("refreshing");
});
it("connects stale retry and conflict resolution to real callbacks", async () => {
const user = userEvent.setup();
const retry = vi.fn();
const resolveConflict = vi.fn();
const stale = deriveAsyncState({
data: ["value"],
isStale: true,
isDegraded: true,
});
const view = render(
<AsyncSurface state={stale} onRetry={retry}>
existing content
</AsyncSurface>,
);
await user.click(screen.getByRole("button", { name: "다시 시도" }));
expect(retry).toHaveBeenCalledOnce();
const conflict = deriveAsyncState({
data: ["value"],
hasMutationConflict: true,
});
view.rerender(
<AsyncSurface
state={conflict}
onResolveConflict={resolveConflict}
>
existing content
</AsyncSurface>,
);
await user.click(screen.getByRole("button", { name: "충돌 해결" }));
expect(resolveConflict).toHaveBeenCalledOnce();
});
it("renders only safe error vocabulary", () => {
const failure = createFailure("SERVER_FAILURE", "LIST", 0, {
code: "SERVER_FAILURE",
});
const state = deriveAsyncState({ failure });
render(<AsyncSurface state={state} />);
render(<AsyncSurface state={state} onRetry={vi.fn()} />);
expect(screen.getByRole("alert")).toHaveTextContent(
"요청을 완료하지 못했습니다.",