From 097ada09f8bd84a168a2952b259784a0675aa1c5 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 27 Aug 2026 00:39:16 +0200 Subject: [PATCH] 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 --- src/app/(app)/quotes/approvals/page.test.tsx | 261 +++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 src/app/(app)/quotes/approvals/page.test.tsx diff --git a/src/app/(app)/quotes/approvals/page.test.tsx b/src/app/(app)/quotes/approvals/page.test.tsx new file mode 100644 index 0000000..849767a --- /dev/null +++ b/src/app/(app)/quotes/approvals/page.test.tsx @@ -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; + quote: { id: string }; + }) => { + captureModalProps(props); + if (!props.open) return null; + return ( +
+ {props.submitLabel ?? ""} + {props.quote.id} + +
+ ); + }, +})); + +// 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 = {}) => ({ + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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") + ); + }); + }); +}); \ No newline at end of file