WFDASH-WEBAPP/src/components/ai-fill-quote-button.test.tsx
2026-10-16 19:48:19 +02:00

98 lines
3.9 KiB
TypeScript

import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn() }));
vi.mock("sonner", () => ({ toast }));
vi.mock("@/lib/pocketbase", () => ({
default: () => ({ authStore: { token: "tok" } }),
}));
import { AiFillQuoteButton } from "./ai-fill-quote-button";
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
toast.success.mockReset();
toast.error.mockReset();
toast.info.mockReset();
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
const json = (status: number, body: unknown) =>
Promise.resolve(new Response(JSON.stringify(body), { status }));
describe("AiFillQuoteButton", () => {
it("POSTs the run route with the bearer token and reports the result", async () => {
fetchMock.mockReturnValue(
json(200, { quoteId: "q1", filled: 3, unmatched: 2, skipped: 0, error: null }),
);
const onDone = vi.fn();
render(<AiFillQuoteButton quoteId="q1" onDone={onDone} />);
fireEvent.click(screen.getByRole("button", { name: /let ai fill this quote/i }));
await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1));
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("/api/v1/quotes/q1/assistant/run");
expect(init.method).toBe("POST");
expect(init.headers.Authorization).toBe("Bearer tok");
expect(toast.success).toHaveBeenCalledWith("AI filled 3 lines · 2 without a match");
});
it("uses the singular for one line and omits the unmatched suffix", async () => {
fetchMock.mockReturnValue(json(200, { filled: 1, unmatched: 0 }));
render(<AiFillQuoteButton quoteId="q1" onDone={() => {}} />);
fireEvent.click(screen.getByRole("button"));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("AI filled 1 line"));
});
it("shows an info toast when there was nothing to fill", async () => {
fetchMock.mockReturnValue(json(200, { filled: 0, unmatched: 0 }));
const onDone = vi.fn();
render(<AiFillQuoteButton quoteId="q1" onDone={onDone} />);
fireEvent.click(screen.getByRole("button"));
await waitFor(() => expect(toast.info).toHaveBeenCalledWith("AI had no open lines to fill"));
expect(toast.success).not.toHaveBeenCalled();
expect(onDone).toHaveBeenCalledTimes(1);
});
it("toasts the error on a 200 that carries an error, and still refreshes", async () => {
fetchMock.mockReturnValue(json(200, { filled: 0, unmatched: 0, error: "AI is switched off" }));
const onDone = vi.fn();
render(<AiFillQuoteButton quoteId="q1" onDone={onDone} />);
fireEvent.click(screen.getByRole("button"));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith("AI is switched off"));
expect(toast.success).not.toHaveBeenCalled();
expect(onDone).toHaveBeenCalledTimes(1);
});
it("toasts the error text on a 409", async () => {
fetchMock.mockReturnValue(json(409, { error: "AI is already working on this quote" }));
const onDone = vi.fn();
render(<AiFillQuoteButton quoteId="q1" onDone={onDone} />);
fireEvent.click(screen.getByRole("button"));
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith("AI is already working on this quote"),
);
expect(onDone).not.toHaveBeenCalled();
});
it("is disabled while the request runs", async () => {
let resolve!: (r: Response) => void;
fetchMock.mockReturnValue(new Promise<Response>((r) => (resolve = r)));
render(<AiFillQuoteButton quoteId="q1" onDone={() => {}} />);
const btn = screen.getByRole("button");
fireEvent.click(btn);
await waitFor(() => expect(btn).toBeDisabled());
fireEvent.click(btn);
expect(fetchMock).toHaveBeenCalledTimes(1);
resolve(new Response(JSON.stringify({ filled: 0, unmatched: 0 }), { status: 200 }));
await waitFor(() => expect(btn).not.toBeDisabled());
});
});