Files
clean-architecture-frontend…/tests/component/application-query.test.tsx
T

467 lines
14 KiB
TypeScript

// @vitest-environment jsdom
import {
act,
renderHook,
waitFor,
} from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import {
type ApplicationResult,
useApplicationMutation,
useApplicationQuery,
} from "../../src/presentation/adapters/query/application-query.ts";
import { QueryInvalidationProvider } from "../../src/presentation/adapters/query/query-invalidation-provider.tsx";
import {
defineQueryInvalidationTopic,
type QueryInvalidationCoordinator,
} from "../../src/contracts/query-invalidation.ts";
import { createFailure } from "../../src/contracts/errors.ts";
const RESOURCE_INVALIDATION_TOPIC =
defineQueryInvalidationTopic("resource");
function queryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: 0, gcTime: Infinity },
mutations: { retry: false },
},
});
}
function wrapper(client: QueryClient) {
const coordinator: QueryInvalidationCoordinator = {
async invalidate(topics) {
for (const topic of topics) {
await client.invalidateQueries({
queryKey: [topic],
exact: false,
refetchType: "active",
});
}
},
beginMutation() {
return { release: async () => {} };
},
async resetLocal() {
await client.cancelQueries();
client.clear();
},
dispose() {},
};
return function QueryWrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={client}>
<QueryInvalidationProvider coordinator={coordinator}>
{children}
</QueryInvalidationProvider>
</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: ApplicationResult<string[]>[] = [
{ ok: true, value: ["first"] },
{
ok: false,
error: createFailure("SERVER_FAILURE", "LIST", 0),
},
{ ok: 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("normalizes an unexpected execute rejection into a safe terminal failure", async () => {
const client = queryClient();
const hook = renderHook(
() =>
useApplicationQuery({
queryKey: ["unexpected-rejection"],
execute: async () => {
throw new Error("private upstream detail");
},
}),
{ wrapper: wrapper(client) },
);
await waitFor(() =>
expect(hook.result.current.state.base).toBe("terminal-error"),
);
expect(hook.result.current.state.failure).toMatchObject({
kind: "UNKNOWN_FAILURE",
operationId: "APPLICATION_QUERY",
userMessageKey: "error.unknown_failure",
action: "contact-support",
});
expect(JSON.stringify(hook.result.current.state.failure)).not.toContain(
"private upstream detail",
);
});
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 }: { signal: AbortSignal }) =>
new Promise<ApplicationResult<unknown>>((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"]);
let complete: (value: ApplicationResult<string>) => void = () => {};
const execute = vi.fn(
() =>
new Promise<ApplicationResult<string>>((resolve) => {
complete = resolve;
}),
);
const hook = renderHook(
() =>
useApplicationMutation<string, string>({
execute,
invalidate: [RESOURCE_INVALIDATION_TOPIC],
currentData: client.getQueryData(key),
optimistic: {
queryKey: key,
update: (previous, input) => [
...(previous as string[]),
input,
],
},
}),
{ wrapper: wrapper(client) },
);
let first: Promise<ApplicationResult<string>> | null = null;
let duplicate: Promise<ApplicationResult<string>> | null = null;
act(() => {
first = hook.result.current.submit("created");
duplicate = hook.result.current.submit("created");
});
expect(first).toBe(duplicate);
await waitFor(() =>
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("never joins distinct mutation inputs to the same runtime promise", async () => {
const client = queryClient();
const resolvers = new Map<
string,
(value: ApplicationResult<string>) => void
>();
const execute = vi.fn(
(input: string) =>
new Promise<ApplicationResult<string>>((resolve) => {
resolvers.set(input, resolve);
}),
);
const hook = renderHook(
() =>
useApplicationMutation<string, string>({
execute,
currentData: true,
}),
{ wrapper: wrapper(client) },
);
let first: Promise<ApplicationResult<string>> | undefined;
let second: Promise<ApplicationResult<string>> | undefined;
act(() => {
first = hook.result.current.submit("first");
second = hook.result.current.submit("second");
});
expect(first).not.toBe(second);
await waitFor(() => expect(execute).toHaveBeenCalledTimes(2));
resolvers.get("first")?.({ ok: true, value: "first" });
resolvers.get("second")?.({ ok: true, value: "second" });
if (!first || !second) throw new Error("expected pending mutations");
await act(async () => {
await Promise.all([first, second]);
});
});
it("cancels an in-flight query before taking the optimistic snapshot", async () => {
const client = queryClient();
const key = ["resource", "ordered-update"];
client.setQueryData(key, ["existing"]);
let finishCancellation: () => void = () => {};
const cancellation = new Promise<void>((resolve) => {
finishCancellation = resolve;
});
const cancelQueries = vi
.spyOn(client, "cancelQueries")
.mockImplementation(async () => cancellation);
const getQueryData = vi.spyOn(client, "getQueryData");
const update = vi.fn((previous, input) => [
...(previous as string[]),
input,
]);
const execute = vi.fn(async () => ({
ok: true as const,
value: "created",
}));
const hook = renderHook(
() =>
useApplicationMutation<string, string>({
execute,
currentData: ["existing"],
optimistic: { queryKey: key, update },
}),
{ wrapper: wrapper(client) },
);
let pending: Promise<ApplicationResult<string>> | undefined;
act(() => {
pending = hook.result.current.submit("created");
});
expect(cancelQueries).toHaveBeenCalledWith({
queryKey: key,
exact: true,
});
expect(getQueryData).not.toHaveBeenCalled();
expect(update).not.toHaveBeenCalled();
expect(execute).not.toHaveBeenCalled();
finishCancellation();
if (!pending) throw new Error("expected pending mutation");
await act(() => pending);
expect(getQueryData).toHaveBeenCalledWith(key);
expect(update).toHaveBeenCalledWith(["existing"], "created");
expect(execute).toHaveBeenCalledOnce();
expect(client.getQueryData(key)).toEqual(["existing", "created"]);
});
it("keeps a committed optimistic update when invalidation fails", async () => {
const client = queryClient();
const key = ["resource", "committed-update"];
client.setQueryData(key, ["existing"]);
vi.spyOn(client, "invalidateQueries").mockRejectedValue(
new Error("cache refresh failed"),
);
const hook = renderHook(
() =>
useApplicationMutation<string, string>({
execute: async () => ({ ok: true, value: "created" }),
invalidate: [RESOURCE_INVALIDATION_TOPIC],
currentData: ["existing"],
optimistic: {
queryKey: key,
update: (previous, input) => [
...(previous as string[]),
input,
],
},
}),
{ wrapper: wrapper(client) },
);
let outcome;
await act(async () => {
outcome = await hook.result.current.submit("created");
});
expect(outcome).toEqual({ ok: true, value: "created" });
expect(client.getQueryData(key)).toEqual(["existing", "created"]);
});
it("normalizes an optimistic preparation defect without running the command", async () => {
const client = queryClient();
const key = ["resource", "invalid-optimistic-update"];
client.setQueryData(key, ["existing"]);
const execute = vi.fn(async () => ({
ok: true as const,
value: "created",
}));
const hook = renderHook(
() =>
useApplicationMutation<string, string>({
execute,
currentData: ["existing"],
optimistic: {
queryKey: key,
update: () => {
throw new Error("private optimistic detail");
},
},
}),
{ wrapper: wrapper(client) },
);
let outcome;
await act(async () => {
outcome = await hook.result.current.submit("created");
});
expect(outcome).toMatchObject({
ok: false,
error: {
kind: "UNKNOWN_FAILURE",
operationId: "APPLICATION_MUTATION",
userMessageKey: "error.unknown_failure",
},
});
expect(JSON.stringify(outcome)).not.toContain("private optimistic detail");
expect(execute).not.toHaveBeenCalled();
expect(client.getQueryData(key)).toEqual(["existing"]);
});
it("removes an optimistic cache entry when no prior data existed", async () => {
const client = queryClient();
const key = ["resource", "new-optimistic-entry"];
const failure = createFailure("SERVER_FAILURE", "CREATE", 0);
const hook = renderHook(
() =>
useApplicationMutation<string, string>({
execute: async () => ({ ok: false, error: failure }),
currentData: true,
optimistic: {
queryKey: key,
update: (_previous, input) => [input],
},
}),
{ wrapper: wrapper(client) },
);
let outcome;
await act(async () => {
outcome = await hook.result.current.submit("temporary");
});
expect(outcome).toEqual({ ok: false, error: failure });
expect(client.getQueryData(key)).toBeUndefined();
expect(client.getQueryState(key)).toBeUndefined();
});
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<string, string>({
execute: async () => ({ ok: false, error: conflict }),
invalidate: [RESOURCE_INVALIDATION_TOPIC],
currentData: client.getQueryData(key),
optimistic: {
queryKey: key,
update: (previous, input) => [
...(previous as string[]),
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);
});
});