feat(catalog): persist idempotent bulk operations and retryable deliveries
This commit is contained in:
1 parent
76e46d295c
commit
13665e0c3c
53 files changed
+1316
-76
No files matched your search
@@ -82,6 +82,7 @@ export function BulkOfferEditor({
|
||||
const [preview, setPreview] = useState<{
|
||||
input: BulkInput;
|
||||
data: Preview;
|
||||
requestKey: string;
|
||||
} | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const busyRef = useRef(false);
|
||||
@@ -149,7 +150,7 @@ export function BulkOfferEditor({
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
setPreview({ input, data: result.data });
|
||||
setPreview({ input, data: result.data, requestKey: crypto.randomUUID() });
|
||||
} catch {
|
||||
setError(t("requestFailed"));
|
||||
} finally {
|
||||
@@ -172,12 +173,14 @@ export function BulkOfferEditor({
|
||||
const result = await applyBulkOffers(
|
||||
preview.input,
|
||||
preview.data.fingerprint,
|
||||
preview.requestKey,
|
||||
);
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
const undoKey = crypto.randomUUID();
|
||||
toast.success(t("success", { count: result.data.changedCount }), {
|
||||
duration: 15000,
|
||||
description: t("undoHint"),
|
||||
@@ -188,7 +191,10 @@ export function BulkOfferEditor({
|
||||
if (busyRef.current || (beforeEdit && !beforeEdit())) return;
|
||||
busyRef.current = true;
|
||||
try {
|
||||
const restored = await undoBulkOffers(result.data.historyIds);
|
||||
const restored = await undoBulkOffers(
|
||||
result.data.historyIds,
|
||||
undoKey,
|
||||
);
|
||||
if (!restored.ok) {
|
||||
toast.error(restored.error);
|
||||
return;
|
||||
@@ -210,7 +216,6 @@ export function BulkOfferEditor({
|
||||
await onApplied();
|
||||
} catch {
|
||||
setError(t("requestFailed"));
|
||||
setPreview(null);
|
||||
} finally {
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
|
||||
@@ -57,10 +57,8 @@ it("gates edits before database and sends one update after commit", async () =>
|
||||
expect((await applyBulkOffers(input, "x")).ok).toBe(true);
|
||||
expect(state.permission).toHaveBeenCalledWith("edit");
|
||||
expect(state.export).toHaveBeenCalledTimes(1);
|
||||
expect(state.send).toHaveBeenCalledTimes(1);
|
||||
expect(state.apply.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
state.send.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(state.send).not.toHaveBeenCalled();
|
||||
expect(state.refresh).toHaveBeenCalled();
|
||||
});
|
||||
it("stops denied permission before export or data access", async () => {
|
||||
state.permission.mockRejectedValueOnce(Error("Denied"));
|
||||
@@ -76,7 +74,7 @@ it("does not notify hotel when a transaction fails", async () => {
|
||||
it("does not report a committed edit as failed on audit failure", async () => {
|
||||
state.audit.mockRejectedValueOnce(Error("log failed"));
|
||||
expect((await applyBulkOffers(input, "x")).ok).toBe(true);
|
||||
expect(state.send).toHaveBeenCalledTimes(1);
|
||||
expect(state.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads destination choices with view permission only", async () => {
|
||||
@@ -95,9 +93,9 @@ it("undo enforces edit access and exports only successful restores", async () =>
|
||||
state.undo.mockResolvedValueOnce({ changedCount: 2 });
|
||||
expect((await undoBulkOffers([10, 11])).ok).toBe(true);
|
||||
expect(state.permission).toHaveBeenCalledWith("edit");
|
||||
expect(state.undo).toHaveBeenCalledWith([10, 11], 1);
|
||||
expect(state.undo).toHaveBeenCalledWith([10, 11], 1, undefined);
|
||||
expect(state.export).toHaveBeenCalledTimes(1);
|
||||
expect(state.send).toHaveBeenCalledTimes(1);
|
||||
expect(state.send).not.toHaveBeenCalled();
|
||||
});
|
||||
it("undo denial stops before export and a conflict does not notify", async () => {
|
||||
state.permission.mockRejectedValueOnce(Error("Denied"));
|
||||
@@ -107,3 +105,10 @@ it("undo denial stops before export and a conflict does not notify", async () =>
|
||||
expect((await undoBulkOffers([10])).ok).toBe(false);
|
||||
expect(state.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes a stable operation key and does not synchronously notify after commit", async () => {
|
||||
const key = "123e4567-e89b-42d3-a456-426614174000";
|
||||
expect((await applyBulkOffers(input, "x", key)).ok).toBe(true);
|
||||
expect(state.apply).toHaveBeenCalledWith(input, "x", 1, key);
|
||||
expect(state.send).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -115,6 +115,17 @@ vi.mock("@/lib/db", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/features/operations/server", async () => {
|
||||
const { db } = await import("@/lib/db");
|
||||
return {
|
||||
runOperation: (
|
||||
_input: unknown,
|
||||
work: (tx: unknown, id: string) => Promise<unknown>,
|
||||
) => db.transaction((tx) => work(tx, "operation-fixture")),
|
||||
enqueueEffect: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
applyBulkOffersCommand,
|
||||
listBulkOfferDestinationsCommand,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -79,6 +79,17 @@ vi.mock("@/lib/db", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/features/operations/server", async () => {
|
||||
const { db } = await import("@/lib/db");
|
||||
return {
|
||||
runOperation: (
|
||||
_input: unknown,
|
||||
work: (tx: unknown, id: string) => Promise<unknown>,
|
||||
) => db.transaction((tx) => work(tx, "operation-fixture")),
|
||||
enqueueEffect: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { undoBulkOffersCommand } from "./bulk-offers";
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
Reference in new issue
Block a user