fix: preserve reconciliation authorities
This commit is contained in:
@@ -33,6 +33,7 @@ function FormHarness(props: Readonly<{
|
||||
| Readonly<{ ok: true; value: string }>
|
||||
| Readonly<{ ok: false; error: ReturnType<typeof createFailure> }>
|
||||
>;
|
||||
resetOnSuccess?: boolean;
|
||||
}>) {
|
||||
const form = useAppForm({
|
||||
schema,
|
||||
@@ -45,6 +46,7 @@ function FormHarness(props: Readonly<{
|
||||
};
|
||||
},
|
||||
submit: props.submit,
|
||||
resetOnSuccess: props.resetOnSuccess,
|
||||
});
|
||||
return (
|
||||
<Form pending={form.pending} onSubmit={(event) => void form.submitForm(event)}>
|
||||
@@ -63,6 +65,10 @@ function FormHarness(props: Readonly<{
|
||||
<Button onClick={() => form.reset()} disabled={!form.dirty}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button onClick={() => form.settleApplied()}>Confirm applied</Button>
|
||||
<Button onClick={() => form.settleNotApplied()}>
|
||||
Confirm not applied
|
||||
</Button>
|
||||
<output data-testid="dirty">{String(form.dirty)}</output>
|
||||
<output data-testid="result">{form.result}</output>
|
||||
</Form>
|
||||
@@ -152,6 +158,81 @@ describe("local form facade", () => {
|
||||
expect(window.location.href).not.toContain(secretLike);
|
||||
expect(JSON.stringify(localStorage)).not.toContain(secretLike);
|
||||
});
|
||||
|
||||
it("blocks a second submit while the prior effect remains unknown", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submit = vi.fn(async () => ({
|
||||
ok: false as const,
|
||||
error: createFailure("SERVER_FAILURE", "CREATE_ENTITY", 0, {
|
||||
effect: "MAYBE_APPLIED",
|
||||
}),
|
||||
}));
|
||||
render(<FormHarness submit={submit} resetOnSuccess={false} />);
|
||||
const name = screen.getByRole("textbox", { name: /Name/ });
|
||||
await user.type(name, "Alpha");
|
||||
await user.click(screen.getByRole("button", { name: "Submit" }));
|
||||
expect(await screen.findByTestId("result")).toHaveTextContent(
|
||||
"effect-unknown",
|
||||
);
|
||||
|
||||
await user.clear(name);
|
||||
await user.type(name, "Beta");
|
||||
await user.click(screen.getByRole("button", { name: "Submit" }));
|
||||
|
||||
expect(submit).toHaveBeenCalledOnce();
|
||||
expect(screen.getByTestId("result")).toHaveTextContent("effect-unknown");
|
||||
});
|
||||
|
||||
it("settles the submitted unknown snapshot without accepting later edits", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submit = vi.fn(async () => ({
|
||||
ok: false as const,
|
||||
error: createFailure("SERVER_FAILURE", "CREATE_ENTITY", 0, {
|
||||
effect: "MAYBE_APPLIED",
|
||||
}),
|
||||
}));
|
||||
render(<FormHarness submit={submit} resetOnSuccess={false} />);
|
||||
const name = screen.getByRole("textbox", { name: /Name/ });
|
||||
await user.type(name, "Alpha");
|
||||
await user.click(screen.getByRole("button", { name: "Submit" }));
|
||||
await screen.findByText("effect-unknown");
|
||||
await user.clear(name);
|
||||
await user.type(name, "Beta");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Confirm applied" }));
|
||||
|
||||
expect(name).toHaveValue("Beta");
|
||||
expect(screen.getByTestId("result")).toHaveTextContent("success");
|
||||
expect(screen.getByTestId("dirty")).toHaveTextContent("true");
|
||||
await user.clear(name);
|
||||
await user.type(name, "Alpha");
|
||||
expect(screen.getByTestId("dirty")).toHaveTextContent("false");
|
||||
});
|
||||
|
||||
it("releases an unknown submission only after explicit not-applied settlement", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submit = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: false as const,
|
||||
error: createFailure("SERVER_FAILURE", "CREATE_ENTITY", 0, {
|
||||
effect: "MAYBE_APPLIED",
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true as const, value: "saved" });
|
||||
render(<FormHarness submit={submit} resetOnSuccess={false} />);
|
||||
await user.type(screen.getByRole("textbox", { name: /Name/ }), "Alpha");
|
||||
await user.click(screen.getByRole("button", { name: "Submit" }));
|
||||
await screen.findByText("effect-unknown");
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Confirm not applied" }),
|
||||
);
|
||||
expect(screen.getByTestId("result")).toHaveTextContent("idle");
|
||||
await user.click(screen.getByRole("button", { name: "Submit" }));
|
||||
|
||||
expect(submit).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dirty navigation guard", () => {
|
||||
|
||||
@@ -15,7 +15,12 @@ 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 {
|
||||
REFERENCE_FEATURE_ID,
|
||||
REFERENCE_RESOURCE_INVALIDATION_TOPIC,
|
||||
REFERENCE_RESOURCE_QUERY_NAMESPACE,
|
||||
} from "../../../src/features/reference-feature/contracts/reference-feature-contract.ts";
|
||||
import { INVALIDATION_REGISTRY } from "../../../src/features/installed-feature-contracts.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";
|
||||
@@ -68,7 +73,7 @@ function renderReference(
|
||||
});
|
||||
},
|
||||
});
|
||||
return render(
|
||||
return Object.assign(render(
|
||||
<MutationIntentProvider factory={mutationIntentFactory}>
|
||||
<QueryClientProvider client={client}>
|
||||
<ServerStateScopeProvider runtime={serverStateScope}>
|
||||
@@ -85,7 +90,7 @@ function renderReference(
|
||||
</ServerStateScopeProvider>
|
||||
</QueryClientProvider>
|
||||
</MutationIntentProvider>,
|
||||
);
|
||||
), { client });
|
||||
}
|
||||
|
||||
function inputWith(
|
||||
@@ -114,6 +119,44 @@ function inputWith(
|
||||
}
|
||||
|
||||
describe("reference feature page states", () => {
|
||||
it("mounts list and detail keys under the installed governed namespace", async () => {
|
||||
expect(REFERENCE_RESOURCE_QUERY_NAMESPACE).toEqual({
|
||||
namespaceId: "reference-resource",
|
||||
namespaceVersion: 1,
|
||||
});
|
||||
const installedEdge = INVALIDATION_REGISTRY.edges.find(
|
||||
(edge) => edge.topicId === REFERENCE_RESOURCE_INVALIDATION_TOPIC,
|
||||
);
|
||||
expect(installedEdge?.namespace).toEqual(
|
||||
REFERENCE_RESOURCE_QUERY_NAMESPACE,
|
||||
);
|
||||
|
||||
const list = renderReference(inputWith());
|
||||
await screen.findByRole("heading", { name: "표시할 항목이 없습니다." });
|
||||
const listKey = list.client.getQueryCache().getAll()[0]?.queryKey;
|
||||
expect(listKey?.slice(0, 4)).toEqual([
|
||||
"query",
|
||||
2,
|
||||
REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceId,
|
||||
REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceVersion,
|
||||
]);
|
||||
list.unmount();
|
||||
|
||||
const detail = renderReference(
|
||||
inputWith(),
|
||||
"/examples/reference-resources/reference-1",
|
||||
);
|
||||
await screen.findByText("Detail");
|
||||
const detailKey = detail.client.getQueryCache().getAll()[0]?.queryKey;
|
||||
expect(detailKey?.slice(0, 4)).toEqual([
|
||||
"query",
|
||||
2,
|
||||
installedEdge?.namespace.namespaceId,
|
||||
installedEdge?.namespace.namespaceVersion,
|
||||
]);
|
||||
detail.unmount();
|
||||
});
|
||||
|
||||
it("renders loading, success and empty states through the installed route", async () => {
|
||||
let resolveList:
|
||||
| ((result: ReferenceResult<readonly ReferenceResourceView[]>) => void)
|
||||
|
||||
@@ -25,6 +25,82 @@ function scope() {
|
||||
}
|
||||
|
||||
describe("revision-safe optimistic layer runtime", () => {
|
||||
it.each(["ROLLBACK", "RECONCILE"] as const)(
|
||||
"rejects a composite-invalid candidate without losing prior %s authority",
|
||||
(authority) => {
|
||||
const client = new QueryClient();
|
||||
const key = ["query", "composite-candidate", authority];
|
||||
client.setQueryData(key, ["base"]);
|
||||
const selectedScope = scope();
|
||||
const runtime = createOptimisticLayerRuntime(client);
|
||||
const first = runtime.begin(
|
||||
key,
|
||||
"first",
|
||||
(previous, input) => [...(previous as string[]), input],
|
||||
selectedScope.snapshot,
|
||||
);
|
||||
if (authority === "RECONCILE") first?.markUncertain();
|
||||
|
||||
const second = runtime.begin(
|
||||
key,
|
||||
"second",
|
||||
(previous, input) => {
|
||||
const values = previous as string[];
|
||||
if (values.includes("first")) {
|
||||
throw new Error("candidate composes only over the base");
|
||||
}
|
||||
return [...values, input];
|
||||
},
|
||||
selectedScope.snapshot,
|
||||
);
|
||||
|
||||
expect(second).toBeNull();
|
||||
expect(client.getQueryData(key)).toEqual(["base", "first"]);
|
||||
if (authority === "ROLLBACK") {
|
||||
first?.rollback();
|
||||
} else {
|
||||
first?.reconcile("NOT_APPLIED");
|
||||
}
|
||||
expect(client.getQueryData(key)).toEqual(["base"]);
|
||||
},
|
||||
);
|
||||
|
||||
it("writes a prevalidated candidate without invoking its updater twice", () => {
|
||||
const client = new QueryClient();
|
||||
const key = ["query", "single-candidate-invocation"];
|
||||
client.setQueryData(key, ["base"]);
|
||||
const selectedScope = scope();
|
||||
const runtime = createOptimisticLayerRuntime(client);
|
||||
const first = runtime.begin(
|
||||
key,
|
||||
"first",
|
||||
(previous, input) => [...(previous as string[]), input],
|
||||
selectedScope.snapshot,
|
||||
);
|
||||
let candidateCalls = 0;
|
||||
|
||||
const second = runtime.begin(
|
||||
key,
|
||||
"second",
|
||||
(previous, input) => {
|
||||
candidateCalls += 1;
|
||||
if (candidateCalls > 1) {
|
||||
throw new Error("candidate updater must not be replayed on admission");
|
||||
}
|
||||
return [...(previous as string[]), input];
|
||||
},
|
||||
selectedScope.snapshot,
|
||||
);
|
||||
|
||||
expect(second).not.toBeNull();
|
||||
expect(candidateCalls).toBe(1);
|
||||
expect(client.getQueryData(key)).toEqual(["base", "first", "second"]);
|
||||
second?.rollback();
|
||||
expect(client.getQueryData(key)).toEqual(["base", "first"]);
|
||||
first?.rollback();
|
||||
expect(client.getQueryData(key)).toEqual(["base"]);
|
||||
});
|
||||
|
||||
it("removes only the failed layer when commands settle out of order", () => {
|
||||
const client = new QueryClient();
|
||||
const key = ["query", "resources"];
|
||||
|
||||
Reference in New Issue
Block a user