test(approvals): scaffold Approve & send from list tests

Component tests cover the row-button -> SendQuoteModal ->
review-approve submit flow including success, invalid_status,
and mail-failed error branches. All 7 fail; implementation in
follow-up commits.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Dave 2026-08-27 00:39:16 +02:00
parent 5a9e0c8a70
commit 097ada09f8

View file

@ -0,0 +1,261 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import ApprovalsPage from "./page";
// ── Mocks ────────────────────────────────────────────────────────────
// Hoisted so the vi.mock factories can close over them.
const {
getListMock,
refreshMock,
pushMock,
captureModalProps,
triggerSend,
} = vi.hoisted(() => ({
getListMock: vi.fn(),
refreshMock: vi.fn(),
pushMock: vi.fn(),
captureModalProps: vi.fn(),
triggerSend: vi.fn(),
}));
// Stub the PocketBase client so we can drive list contents from the test.
vi.mock("@/lib/pocketbase", () => ({
__esModule: true,
default: () => ({
collection: (name: string) => {
if (name === "quotes") {
return { getList: (...args: unknown[]) => getListMock(...args) };
}
if (name === "quote_items") {
return { getFullList: async () => [] };
}
return { getFullList: async () => [], getList: async () => ({ items: [] }) };
},
}),
}));
// Stub next/navigation so we can assert on refresh/push.
vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: refreshMock, push: pushMock }),
}));
// Replace SendQuoteModal with a thin stub. The test grabs the props the
// page passed in via captureModalProps; triggerSend becomes the onSend
// prop, so tests can fire the modal submit callback directly.
vi.mock("@/components/quotes/send-quote-modal", () => ({
SendQuoteModal: (props: {
open: boolean;
submitLabel?: string;
onSend: (p: unknown) => Promise<void>;
quote: { id: string };
}) => {
captureModalProps(props);
if (!props.open) return null;
return (
<div data-testid="send-quote-modal">
<span data-testid="modal-submit-label">{props.submitLabel ?? ""}</span>
<span data-testid="modal-quote-id">{props.quote.id}</span>
<button
type="button"
onClick={() =>
triggerSend({
to: "buyer@example.com",
cc: "",
bcc: "",
subject: "Quote Q-26082701",
message: "",
selectedAttachmentIds: [],
})
}
>
Submit modal
</button>
</div>
);
},
}));
// Stub sonner globally for this file (test-setup.ts doesn't mock it).
// `toast` is callable AND carries .success / .error / .info / .warning
// for the typed variants the production code uses.
vi.mock("sonner", () => ({
toast: Object.assign(vi.fn(), {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
}),
}));
// fetch is used by the modal stub's submit handler (and by the rep-email
// lookup). The page itself only does `fetch("/api/v1/quotes/[id]/review-approve")`.
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const baseQuote = (overrides: Record<string, unknown> = {}) => ({
id: "q-full-1",
quote_number: "Q-26082701",
status: "approval_pending",
rfq_number: "RFQ-100",
description: "Replacement quote",
customer: "cust-1",
company: "co-1",
created: "2026-08-27T08:00:00Z",
notes: "",
expand: {
customer: { customer: "Acme Co", rep: "", company: "co-1" },
company: { company: "Acme Holdings" },
},
...overrides,
});
beforeEach(() => {
vi.clearAllMocks();
// Default list: one full-pending, one partial-pending. Tests can
// override getListMock.mockResolvedValueOnce(...) to narrow.
getListMock.mockResolvedValue({
items: [
baseQuote(),
baseQuote({
id: "q-part-1",
quote_number: "Q-26082702",
status: "partial_approval_pending",
}),
],
});
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ quote: baseQuote(), email: {} }),
});
});
describe("ApprovalsPage — Approve & send from list", () => {
it("renders an 'Approve & send' button on a full-pending row", async () => {
render(<ApprovalsPage />);
await waitFor(() => {
expect(screen.getAllByRole("button", { name: /approve & send/i }).length).toBeGreaterThan(0);
});
});
it("clicking 'Approve & send' on a full row opens SendQuoteModal with submitLabel 'Approve & send'", async () => {
render(<ApprovalsPage />);
const buttons = await screen.findAllByRole("button", { name: /approve & send/i });
fireEvent.click(buttons[0]);
await waitFor(() => {
expect(captureModalProps).toHaveBeenCalled();
const last = captureModalProps.mock.calls.at(-1)![0];
expect(last.open).toBe(true);
expect(last.quote.id).toBe("q-full-1");
expect(last.submitLabel).toBe("Approve & send");
});
});
it("submitting the modal posts to review-approve with action 'approve' and email payload", async () => {
render(<ApprovalsPage />);
const buttons = await screen.findAllByRole("button", { name: /approve & send/i });
fireEvent.click(buttons[0]);
await waitFor(() => expect(captureModalProps).toHaveBeenCalled());
// The modal stub's "Submit modal" button fires triggerSend which is
// wired up as the page's onSend callback below.
const submitBtn = await screen.findByRole("button", { name: /submit modal/i });
fireEvent.click(submitBtn);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(
"/api/v1/quotes/q-full-1/review-approve",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
body: expect.stringContaining('"action":"approve"'),
})
);
});
});
it("on 200 success, refreshes the list and toasts 'Approved — email sent...'", async () => {
render(<ApprovalsPage />);
const buttons = await screen.findAllByRole("button", { name: /approve & send/i });
fireEvent.click(buttons[0]);
const submitBtn = await screen.findByRole("button", { name: /submit modal/i });
fireEvent.click(submitBtn);
await waitFor(() => {
expect(refreshMock).toHaveBeenCalled();
});
// toast.success is from sonner — verify by checking sonner was called.
const { toast } = await import("sonner");
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith(
expect.stringContaining("Approved — email sent")
);
});
});
it("on 422 invalid_status, refreshes and toasts 'This quote was already actioned — refreshing.'", async () => {
fetchMock.mockResolvedValueOnce({
ok: false,
status: 422,
json: async () => ({ error: "Already approved", code: "invalid_status" }),
});
render(<ApprovalsPage />);
const buttons = await screen.findAllByRole("button", { name: /approve & send/i });
fireEvent.click(buttons[0]);
const submitBtn = await screen.findByRole("button", { name: /submit modal/i });
fireEvent.click(submitBtn);
await waitFor(() => {
expect(refreshMock).toHaveBeenCalled();
});
const { toast } = await import("sonner");
await waitFor(() => {
expect(toast).toHaveBeenCalledWith(
expect.stringContaining("already actioned"),
expect.anything()
);
});
});
it("on 422 mail-failed, toasts 'Saved — but email send failed...'", async () => {
fetchMock.mockResolvedValueOnce({
ok: false,
status: 422,
json: async () => ({
error: "SMTP error",
email: { failure: { code: "smtp", message: "Connection refused" } },
}),
});
render(<ApprovalsPage />);
const buttons = await screen.findAllByRole("button", { name: /approve & send/i });
fireEvent.click(buttons[0]);
const submitBtn = await screen.findByRole("button", { name: /submit modal/i });
fireEvent.click(submitBtn);
const { toast } = await import("sonner");
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(
expect.stringContaining("Saved — but email send failed")
);
});
});
it("clicking 'Approve & send' on a partial row invokes review-approve and toasts the partial message", async () => {
render(<ApprovalsPage />);
const buttons = await screen.findAllByRole("button", { name: /approve & send/i });
// The second row is the partial one.
fireEvent.click(buttons[1]);
await waitFor(() => expect(captureModalProps).toHaveBeenCalled());
const last = captureModalProps.mock.calls.at(-1)![0];
expect(last.quote.id).toBe("q-part-1");
const submitBtn = await screen.findByRole("button", { name: /submit modal/i });
fireEvent.click(submitBtn);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(
"/api/v1/quotes/q-part-1/review-approve",
expect.objectContaining({ method: "POST" })
);
});
const { toast } = await import("sonner");
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith(
expect.stringContaining("Partial approved — email sent")
);
});
});
});