Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 153 additions & 2 deletions frontend/src/components/WebhookLogs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ import WebhookLogs from "./WebhookLogs";

vi.mock("react-hot-toast", () => ({
__esModule: true,
default: {
default: Object.assign(vi.fn(), {
success: vi.fn(),
error: vi.fn(),
},
}),
}));

vi.mock("@/components/ui/Button", () => ({
Expand Down Expand Up @@ -168,3 +168,154 @@ describe("WebhookLogs", () => {
expect(preventDefaultSpy).toHaveBeenCalled();
});
});

describe("WebhookLogs optimistic retry rollback", () => {
const FAILED_A = {
id: "fail-a",
payment_id: "p1",
status_code: 500,
event: "payment.failed",
url: "https://example.com/wh",
request_payload: {},
request_headers: null,
response_body: "error",
timestamp: "2026-04-25T10:00:00.000Z",
};
const FAILED_B = { ...FAILED_A, id: "fail-b", event: "payment.expired", status_code: 404 };

const fetchMock = () => globalThis.fetch as unknown as ReturnType<typeof vi.fn>;

async function renderWithFailedLogs() {
fetchMock().mockResolvedValueOnce({
ok: true,
json: async () => ({ logs: [FAILED_A, FAILED_B] }),
});
render(<WebhookLogs />);
await waitFor(() => expect(screen.getByText("payment.failed")).toBeInTheDocument());
}

function rowFor(event: string) {
return screen.getByRole("button", { name: new RegExp(event.replace(".", "\.")) });
}

beforeEach(() => {
vi.resetAllMocks();
globalThis.fetch = vi.fn();
});

it("shows the optimistic pending state while the retry request is in flight", async () => {
await renderWithFailedLogs();

let resolveRetry: (value: unknown) => void = () => {};
fetchMock().mockReturnValueOnce(new Promise((resolve) => (resolveRetry = resolve)));

fireEvent.click(screen.getByLabelText("Select webhook log fail-a"));
fireEvent.click(screen.getByRole("button", { name: "Retry Selected" }));

expect(rowFor("payment.failed")).toHaveAttribute("data-retry-state", "pending");
expect(rowFor("payment.failed")).toHaveAttribute("aria-busy", "true");
expect(screen.getByText("Retrying…")).toBeInTheDocument();
expect(screen.getByLabelText("Select webhook log fail-a")).not.toBeChecked();
expect(screen.getByLabelText("Select webhook log fail-a")).toBeDisabled();

resolveRetry({
ok: true,
json: async () => ({ queued_count: 1, skipped_count: 0, queued_log_ids: ["fail-a"] }),
});

await waitFor(() =>
expect(rowFor("payment.failed")).toHaveAttribute("data-retry-state", "queued"),
);
expect(screen.getByText("Retry queued")).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});

it("rolls back row state and restores the selection on network failure", async () => {
await renderWithFailedLogs();
fetchMock().mockRejectedValueOnce(new TypeError("Failed to fetch"));

fireEvent.click(screen.getByLabelText("Select webhook log fail-a"));
fireEvent.click(screen.getByLabelText("Select webhook log fail-b"));
fireEvent.click(screen.getByRole("button", { name: "Retry Selected" }));

const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent(/network error/i);
expect(alert).toHaveTextContent(/selection has been restored/i);

expect(screen.queryByText("Retrying…")).not.toBeInTheDocument();
expect(screen.getByLabelText("Select webhook log fail-a")).toBeChecked();
expect(screen.getByLabelText("Select webhook log fail-b")).toBeChecked();
expect(screen.getByLabelText("Select webhook log fail-a")).toBeEnabled();
expect(rowFor("payment.failed")).toHaveAttribute("data-retry-state", "rolled-back");
expect(screen.getAllByText("Retry not queued")).toHaveLength(2);
expect(screen.getByRole("status")).toHaveTextContent(/retry failed/i);
});

it("rolls back on a non-2xx response using the server error message", async () => {
await renderWithFailedLogs();
fetchMock().mockResolvedValueOnce({
ok: false,
json: async () => ({ error: "Rate limit exceeded" }),
});

fireEvent.click(screen.getByLabelText("Select webhook log fail-a"));
fireEvent.click(screen.getByRole("button", { name: "Retry Selected" }));

expect(await screen.findByRole("alert")).toHaveTextContent("Rate limit exceeded");
expect(screen.getByLabelText("Select webhook log fail-a")).toBeChecked();
expect(screen.getByLabelText("Select webhook log fail-b")).not.toBeChecked();
});

it("rolls back only the rows the server skipped", async () => {
await renderWithFailedLogs();
fetchMock().mockResolvedValueOnce({
ok: true,
json: async () => ({ queued_count: 1, skipped_count: 1, queued_log_ids: ["fail-a"] }),
});

fireEvent.click(screen.getByLabelText("Select all failed webhook logs"));
fireEvent.click(screen.getByRole("button", { name: "Retry Selected" }));

await waitFor(() =>
expect(rowFor("payment.failed")).toHaveAttribute("data-retry-state", "queued"),
);
expect(rowFor("payment.expired")).toHaveAttribute("data-retry-state", "rolled-back");
expect(screen.getByLabelText("Select webhook log fail-b")).toBeEnabled();
expect(screen.getByLabelText("Select webhook log fail-a")).toBeDisabled();
});

it("'Try again' re-sends the rolled-back logs and clears the alert on success", async () => {
await renderWithFailedLogs();
fetchMock().mockRejectedValueOnce(new TypeError("Failed to fetch"));

fireEvent.click(screen.getByLabelText("Select webhook log fail-b"));
fireEvent.click(screen.getByRole("button", { name: "Retry Selected" }));
await screen.findByRole("alert");

fetchMock().mockResolvedValueOnce({
ok: true,
json: async () => ({ queued_count: 1, skipped_count: 0, queued_log_ids: ["fail-b"] }),
});
fireEvent.click(screen.getByRole("button", { name: "Try again" }));

await waitFor(() =>
expect(rowFor("payment.expired")).toHaveAttribute("data-retry-state", "queued"),
);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();

const lastCall = fetchMock().mock.calls.at(-1)!;
expect(JSON.parse(lastCall[1].body)).toEqual({ log_ids: ["fail-b"] });
});

it("the rollback alert can be dismissed", async () => {
await renderWithFailedLogs();
fetchMock().mockRejectedValueOnce(new TypeError("Failed to fetch"));

fireEvent.click(screen.getByLabelText("Select webhook log fail-a"));
fireEvent.click(screen.getByRole("button", { name: "Retry Selected" }));
await screen.findByRole("alert");

fireEvent.click(screen.getByRole("button", { name: "Dismiss retry error" }));
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
});
Loading
Loading