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:
parent
5a9e0c8a70
commit
097ada09f8
1 changed files with 261 additions and 0 deletions
261
src/app/(app)/quotes/approvals/page.test.tsx
Normal file
261
src/app/(app)/quotes/approvals/page.test.tsx
Normal 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")
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue