feat: 기능 추가 과정중
This commit is contained in:
@@ -1,218 +0,0 @@
|
||||
// @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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,466 @@
|
||||
// @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);
|
||||
});
|
||||
});
|
||||
@@ -4,9 +4,9 @@ import { render, screen } from "@testing-library/react";
|
||||
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";
|
||||
import { createFailure } from "../../src/contracts/errors.js";
|
||||
import { deriveAsyncState } from "../../src/application/view-models/async-state.ts";
|
||||
import { AsyncSurface } from "../../src/presentation/components/async-surface.tsx";
|
||||
import { createFailure } from "../../src/contracts/errors.ts";
|
||||
|
||||
describe("async UI state matrix", () => {
|
||||
it.each([
|
||||
@@ -106,6 +106,42 @@ describe("async UI state matrix", () => {
|
||||
expect(screen.getByRole("alert")).not.toHaveTextContent("stack");
|
||||
});
|
||||
|
||||
it("routes terminal actions by failure semantics", async () => {
|
||||
const user = userEvent.setup();
|
||||
const retry = vi.fn();
|
||||
const action = vi.fn();
|
||||
const forbidden = deriveAsyncState({
|
||||
failure: createFailure("FORBIDDEN", "LIST", 0),
|
||||
});
|
||||
const view = render(
|
||||
<AsyncSurface
|
||||
state={forbidden}
|
||||
onAction={action}
|
||||
onRetry={retry}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "안전한 화면으로 이동" }),
|
||||
);
|
||||
expect(action).toHaveBeenCalledOnce();
|
||||
expect(retry).not.toHaveBeenCalled();
|
||||
|
||||
const retryable = deriveAsyncState({
|
||||
failure: createFailure("SERVER_FAILURE", "LIST", 0),
|
||||
});
|
||||
view.rerender(
|
||||
<AsyncSurface
|
||||
state={retryable}
|
||||
onAction={action}
|
||||
onRetry={retry}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "다시 시도" }));
|
||||
expect(retry).toHaveBeenCalledOnce();
|
||||
expect(action).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not retain a terminal error after usable data is restored", () => {
|
||||
const failed = deriveAsyncState({
|
||||
failure: createFailure("SERVER_FAILURE", "LIST", 0),
|
||||
+5
-4
@@ -3,10 +3,10 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SafeText } from "../../src/presentation/security/safe-text.jsx";
|
||||
import { assertSafeConfigNames } from "../../src/contracts/env.js";
|
||||
import { defineStorageKey } from "../../src/contracts/storage-keys.js";
|
||||
import { projectTelemetryEvent } from "../../src/contracts/telemetry.js";
|
||||
import { SafeText } from "../../src/presentation/security/safe-text.tsx";
|
||||
import { assertSafeConfigNames } from "../../src/contracts/env.ts";
|
||||
import { defineStorageKey } from "../../src/contracts/storage-keys.ts";
|
||||
import { projectTelemetryEvent } from "../../src/contracts/telemetry.ts";
|
||||
|
||||
describe("browser security boundary", () => {
|
||||
it("renders untrusted text without script or inline handler injection", () => {
|
||||
@@ -31,6 +31,7 @@ describe("browser security boundary", () => {
|
||||
backend: "sessionStorage",
|
||||
classification: "sensitive-forbidden",
|
||||
schemaVersion: 1,
|
||||
valueCodec: "none",
|
||||
ttl: "session",
|
||||
migration: "discard",
|
||||
quotaFallback: "feature-disable",
|
||||
@@ -6,8 +6,8 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ChunkRecoveryBoundary,
|
||||
isChunkLoadFailure,
|
||||
} from "../../src/presentation/boundaries/chunk-recovery-boundary.js";
|
||||
import { FeatureBoundary } from "../../src/presentation/boundaries/render-error-boundary.jsx";
|
||||
} from "../../src/presentation/boundaries/chunk-recovery-boundary.tsx";
|
||||
import { FeatureBoundary } from "../../src/presentation/boundaries/render-error-boundary.tsx";
|
||||
|
||||
function ChunkDefect(): never {
|
||||
throw new TypeError("Failed to fetch dynamically imported module");
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
Tabs,
|
||||
ToastProvider,
|
||||
useToast,
|
||||
} from "../../src/presentation/design-system/index.js";
|
||||
} from "../../src/presentation/design-system/index.ts";
|
||||
|
||||
describe("design-system platform interactions", () => {
|
||||
it("keeps decorative icons out of the accessibility tree and names icon actions", () => {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Button } from "../../src/presentation/components/ui/button.jsx";
|
||||
import { Card } from "../../src/presentation/components/ui/card.jsx";
|
||||
import { Button } from "../../src/presentation/components/ui/button.ts";
|
||||
import { Card } from "../../src/presentation/components/ui/card.ts";
|
||||
|
||||
describe("design-token fixture", () => {
|
||||
it("uses static semantic primitive classes", () => {
|
||||
@@ -7,8 +7,8 @@ import { createMemoryRouter, RouterProvider, useNavigate } from "react-router-do
|
||||
import { z } from "zod";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createFailure } from "../../src/contracts/errors.js";
|
||||
import { Button } from "../../src/presentation/components/ui/button.jsx";
|
||||
import { createFailure } from "../../src/contracts/errors.ts";
|
||||
import { Button } from "../../src/presentation/components/ui/button.ts";
|
||||
import {
|
||||
DirtyNavigationDialog,
|
||||
ErrorSummary,
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
FormField,
|
||||
useAppForm,
|
||||
useDirtyNavigationGuard,
|
||||
} from "../../src/presentation/forms/index.js";
|
||||
} from "../../src/presentation/forms/index.ts";
|
||||
|
||||
type Values = Readonly<Record<"name" | "note", string>>;
|
||||
|
||||
|
||||
@@ -10,12 +10,12 @@ import {
|
||||
Drawer,
|
||||
Pagination,
|
||||
Tabs,
|
||||
} from "../../src/presentation/design-system/index.js";
|
||||
} from "../../src/presentation/design-system/index.ts";
|
||||
import {
|
||||
LocaleProvider,
|
||||
useLocale,
|
||||
type SupportedLocale,
|
||||
} from "../../src/presentation/i18n/index.js";
|
||||
} from "../../src/presentation/i18n/index.ts";
|
||||
|
||||
function LocaleHarness() {
|
||||
const { direction, locale, message, setLocale } = useLocale();
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { PageHeader } from "../../src/presentation/components/page-header.tsx";
|
||||
|
||||
describe("page header focus ownership", () => {
|
||||
it("takes over focus handed off by the route main region", async () => {
|
||||
const { rerender } = render(
|
||||
<>
|
||||
<main id="main-content" tabIndex={-1} />
|
||||
<PageHeader title="Loading" />
|
||||
</>,
|
||||
);
|
||||
const main = screen.getByRole("main");
|
||||
main.focus();
|
||||
|
||||
rerender(
|
||||
<>
|
||||
<main id="main-content" tabIndex={-1} />
|
||||
<PageHeader title="Loaded" />
|
||||
</>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: "Loaded" }),
|
||||
).toHaveFocus(),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not steal focus from a user-controlled element", () => {
|
||||
const { rerender } = render(
|
||||
<>
|
||||
<button type="button">Menu</button>
|
||||
<PageHeader title="Loading" />
|
||||
</>,
|
||||
);
|
||||
const menu = screen.getByRole("button", { name: "Menu" });
|
||||
menu.focus();
|
||||
|
||||
rerender(
|
||||
<>
|
||||
<button type="button">Menu</button>
|
||||
<PageHeader title="Loaded" />
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(menu).toHaveFocus();
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
FormPage,
|
||||
StandardPage,
|
||||
StatusPage,
|
||||
} from "../../src/presentation/templates/index.js";
|
||||
} from "../../src/presentation/templates/index.ts";
|
||||
|
||||
describe("page template slot contracts", () => {
|
||||
it("renders StandardPage minimum and full landmarks with one h1", () => {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createFailure } from "../../src/contracts/errors.js";
|
||||
import { deriveAsyncState } from "../../src/application/view-models/async-state.js";
|
||||
import { AsyncSurface } from "../../src/presentation/components/async-surface.jsx";
|
||||
import { BootErrorShell } from "../../src/presentation/boundaries/boot-error-shell.jsx";
|
||||
import { FeatureBoundary } from "../../src/presentation/boundaries/render-error-boundary.jsx";
|
||||
import { createFailure } from "../../src/contracts/errors.ts";
|
||||
import { deriveAsyncState } from "../../src/application/view-models/async-state.ts";
|
||||
import { AsyncSurface } from "../../src/presentation/components/async-surface.tsx";
|
||||
import { BootErrorShell } from "../../src/presentation/boundaries/boot-error-shell.tsx";
|
||||
import { FeatureBoundary } from "../../src/presentation/boundaries/render-error-boundary.tsx";
|
||||
|
||||
/** @returns {import("react").ReactNode} */
|
||||
function Defect() {
|
||||
function Defect(): ReactNode {
|
||||
throw new Error("raw render stack");
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.js";
|
||||
import { ApplicationProvider } from "../../src/presentation/providers/application-provider.js";
|
||||
import { AppRouter } from "../../src/presentation/routes/app-router.jsx";
|
||||
import { createTestApplication } from "../helpers/create-test-application.js";
|
||||
import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.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 renderRouter() {
|
||||
return render(
|
||||
+2
-2
@@ -3,8 +3,8 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createRuntimeComposition } from "../../src/bootstrap/create-runtime-composition.js";
|
||||
import { RuntimeApplication } from "../../src/bootstrap/runtime-application.jsx";
|
||||
import { createRuntimeComposition } from "../../src/bootstrap/create-runtime-composition.ts";
|
||||
import { RuntimeApplication } from "../../src/bootstrap/runtime-application.tsx";
|
||||
|
||||
const runtimeConfig = {
|
||||
APP_ENV: "local",
|
||||
@@ -5,12 +5,12 @@ import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Alert } from "../../src/presentation/components/ui/alert.jsx";
|
||||
import { Badge } from "../../src/presentation/components/ui/badge.jsx";
|
||||
import { Button } from "../../src/presentation/components/ui/button.jsx";
|
||||
import { Card } from "../../src/presentation/components/ui/card.jsx";
|
||||
import { Dialog } from "../../src/presentation/components/ui/dialog.jsx";
|
||||
import { TextField } from "../../src/presentation/components/ui/text-field.jsx";
|
||||
import { Alert } from "../../src/presentation/components/ui/alert.ts";
|
||||
import { Badge } from "../../src/presentation/components/ui/badge.ts";
|
||||
import { Button } from "../../src/presentation/components/ui/button.ts";
|
||||
import { Card } from "../../src/presentation/components/ui/card.ts";
|
||||
import { Dialog } from "../../src/presentation/components/ui/dialog.ts";
|
||||
import { TextField } from "../../src/presentation/components/ui/text-field.ts";
|
||||
|
||||
describe("domain-neutral UI primitives", () => {
|
||||
it("connects field help and validation errors to the input", () => {
|
||||
Reference in New Issue
Block a user