WFDASH-WEBAPP/src/components/quote-form.test.tsx
SJCoetz 2d0861a2e7 test(quote-form): wire fetch mocks for new /api/v1/quotes/next-number call
The form's deprecated generateQuoteNumber() was swapped for
fetch('/api/v1/quotes/next-number'). Three tests' setups pointed
fetchMock only at the upload response — and one asserted
fetchMock was never called (it now always is, for next-number).

- 'uploads per-item files' + 'shows a partial failure': chain two
  mockResolvedValueOnce responses (next-number, then upload).
- 'allows a company-only selection': one mockResolvedValueOnce for
  next-number.
- 'stops later uploads when a quote item create fails': add the
  next-number mock and replace 'not.toHaveBeenCalled()' with
  'toHaveBeenCalledTimes(1)' plus an explicit assertion on the
  URL — item-create branch throws before the upload fetch, so
  the original negative assertion was always-true and is now wrong.

The generateQuoteNumberMock in the hoisted block is now dead (the
form no longer imports it). Left in place to keep this commit
narrow; safe to delete in a future PR alongside the deprecated
function in src/lib/pb-helpers.ts (which now has zero callers).
2026-07-26 22:33:37 +02:00

296 lines
10 KiB
TypeScript

import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QuoteForm } from "./quote-form";
const {
createMock,
getAllCustomersMock,
getAllCompaniesMock,
getAllSuppliersMock,
generateQuoteNumberMock,
toastErrorMock,
toastSuccessMock,
fetchMock,
} = vi.hoisted(() => ({
createMock: vi.fn(),
getAllCustomersMock: vi.fn(),
getAllCompaniesMock: vi.fn(),
getAllSuppliersMock: vi.fn(),
generateQuoteNumberMock: vi.fn(),
toastErrorMock: vi.fn(),
toastSuccessMock: vi.fn(),
fetchMock: vi.fn(),
}));
vi.stubGlobal("fetch", fetchMock);
vi.mock("@/lib/pocketbase", () => ({
__esModule: true,
default: () => ({
collection: (name: string) => ({
create: (payload: unknown) => createMock(name, payload),
}),
}),
}));
vi.mock("@/lib/pb-helpers", () => ({
getAllCustomers: () => getAllCustomersMock(),
getAllCompanies: () => getAllCompaniesMock(),
getAllSuppliers: () => getAllSuppliersMock(),
generateQuoteNumber: () => generateQuoteNumberMock(),
}));
vi.mock("sonner", () => ({
toast: {
error: toastErrorMock,
success: toastSuccessMock,
},
}));
describe("QuoteForm", () => {
afterEach(() => {
cleanup();
});
beforeEach(() => {
class ResizeObserverMock {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal("ResizeObserver", ResizeObserverMock);
window.HTMLElement.prototype.scrollIntoView = vi.fn();
Object.defineProperty(globalThis, "crypto", {
value: { randomUUID: () => "draft-item-1" },
configurable: true,
});
createMock.mockReset();
getAllCustomersMock.mockReset();
getAllCompaniesMock.mockReset();
getAllSuppliersMock.mockReset();
generateQuoteNumberMock.mockReset();
toastErrorMock.mockReset();
toastSuccessMock.mockReset();
fetchMock.mockReset();
getAllCompaniesMock.mockResolvedValue([
{ id: "co-1", company: "SJ COETZ" },
{ id: "co-2", company: "Walk-in Company" },
]);
getAllCustomersMock.mockResolvedValue([
{ id: "cu-1", customer: "SJ BOOKKEEPING", company: "co-1" },
]);
getAllSuppliersMock.mockResolvedValue([]);
generateQuoteNumberMock.mockResolvedValue("Q-2026-07-001");
});
it("uses a combined customer/company picker and auto-links the company", async () => {
render(<QuoteForm open onOpenChange={vi.fn()} onSaved={vi.fn()} />);
fireEvent.click(await screen.findByRole("button", { name: /select customer or company/i }));
fireEvent.click(screen.getByRole("option", { name: /sj bookkeeping/i }));
expect(screen.getByText("SJ COETZ")).toBeInTheDocument();
expect(screen.getByText("SJ BOOKKEEPING")).toBeInTheDocument();
});
it("renders item cards without supplier, cost, or sell fields", async () => {
render(<QuoteForm open onOpenChange={vi.fn()} onSaved={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: /add item/i }));
expect(await screen.findByLabelText(/item description/i)).toBeInTheDocument();
expect(screen.getByLabelText(/item notes/i)).toBeInTheDocument();
expect(screen.queryByPlaceholderText("Select...")).not.toBeInTheDocument();
expect(screen.queryByText("Cost")).not.toBeInTheDocument();
expect(screen.queryByText("Sell")).not.toBeInTheDocument();
});
it("uploads per-item files after creating the quote and quote items", async () => {
createMock
.mockResolvedValueOnce({ id: "quote-1" })
.mockResolvedValueOnce({ id: "item-1" });
// The form now hits /api/v1/quotes/next-number before PB create,
// and /api/v1/quotes/items/<id>/attachments/upload after. Queue
// both responses so the first fetch call gets the next-number
// shape and the second gets the upload shape.
fetchMock
.mockResolvedValueOnce({
ok: true,
json: async () => ({ quote_number: "Q-2026-07-001" }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ id: "att-1" }),
});
render(<QuoteForm open onOpenChange={vi.fn()} onSaved={vi.fn()} />);
fireEvent.click(await screen.findByRole("button", { name: /select customer or company/i }));
fireEvent.click(screen.getByRole("option", { name: /sj bookkeeping/i }));
fireEvent.click(screen.getByRole("button", { name: /add item/i }));
fireEvent.change(screen.getByLabelText(/item description/i), {
target: { value: "Need pricing on magnetic drill" },
});
await waitFor(() => {
expect(screen.getByLabelText(/item description/i)).toHaveValue("Need pricing on magnetic drill");
});
const file = new File(["voice"], "voice-note.m4a", { type: "audio/mp4" });
fireEvent.change(screen.getByLabelText(/upload attachments/i), {
target: { files: [file] },
});
fireEvent.click(screen.getByRole("button", { name: /create quote/i }));
await waitFor(() => {
expect(createMock).toHaveBeenNthCalledWith(
1,
"quotes",
expect.objectContaining({
quote_number: "Q-2026-07-001",
customer: "cu-1",
company: "co-1",
}),
);
expect(createMock).toHaveBeenNthCalledWith(
2,
"quote_items",
expect.objectContaining({
quote: "quote-1",
description: "Need pricing on magnetic drill",
quantity: 1,
}),
);
expect(fetchMock).toHaveBeenCalledWith(
"/api/v1/quotes/items/item-1/attachments/upload",
expect.objectContaining({ method: "POST", body: expect.any(FormData) }),
);
});
});
it("allows a company-only selection when creating a quote", async () => {
createMock.mockResolvedValueOnce({ id: "quote-2" }).mockResolvedValueOnce({ id: "item-2" });
// The form hits /api/v1/quotes/next-number before PB create.
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ quote_number: "Q-2026-07-001" }),
});
const onSaved = vi.fn();
const onOpenChange = vi.fn();
render(<QuoteForm open onOpenChange={onOpenChange} onSaved={onSaved} />);
fireEvent.click(await screen.findByRole("button", { name: /select customer or company/i }));
fireEvent.click(screen.getByRole("option", { name: /walk-in company/i }));
fireEvent.click(screen.getByRole("button", { name: /add item/i }));
fireEvent.change(screen.getByLabelText(/item description/i), {
target: { value: "Site visit and measure up" },
});
await waitFor(() => {
expect(screen.getByLabelText(/item description/i)).toHaveValue("Site visit and measure up");
});
fireEvent.click(screen.getByRole("button", { name: /create quote/i }));
await waitFor(() => {
expect(createMock).toHaveBeenNthCalledWith(
1,
"quotes",
expect.objectContaining({
customer: null,
company: "co-2",
}),
);
expect(toastSuccessMock).toHaveBeenCalledWith("Quote Q-2026-07-001 created");
expect(onSaved).toHaveBeenCalled();
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});
it("stops later uploads when a quote item create fails", async () => {
createMock
.mockResolvedValueOnce({ id: "quote-1" })
.mockRejectedValueOnce(new Error("item create failed"));
// The form hits /api/v1/quotes/next-number before PB create.
// The item-create then fails, so the upload fetch is NOT called.
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ quote_number: "Q-2026-07-001" }),
});
render(<QuoteForm open onOpenChange={vi.fn()} onSaved={vi.fn()} />);
fireEvent.click(await screen.findByRole("button", { name: /select customer or company/i }));
fireEvent.click(screen.getByRole("option", { name: /sj bookkeeping/i }));
fireEvent.click(screen.getByRole("button", { name: /add item/i }));
fireEvent.change(screen.getByLabelText(/item description/i), {
target: { value: "Broken create path" },
});
await waitFor(() => {
expect(screen.getByLabelText(/item description/i)).toHaveValue("Broken create path");
});
fireEvent.change(screen.getByLabelText(/upload attachments/i), {
target: { files: [new File(["img"], "photo.jpg", { type: "image/jpeg" })] },
});
fireEvent.click(screen.getByRole("button", { name: /create quote/i }));
await waitFor(() => {
// Next-number WAS called before PB create; the upload
// fetch was NOT called because the item-create branch
// threw before reaching it.
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(toastErrorMock).toHaveBeenCalledWith(
expect.stringMatching(/partially created/i),
);
});
});
it("shows a partial failure message when attachment upload fails", async () => {
createMock
.mockResolvedValueOnce({ id: "quote-1" })
.mockResolvedValueOnce({ id: "item-1" });
// First fetch: next-number (success). Second fetch: upload (500).
fetchMock
.mockResolvedValueOnce({
ok: true,
json: async () => ({ quote_number: "Q-2026-07-001" }),
})
.mockResolvedValueOnce({
ok: false,
status: 500,
json: async () => ({ error: "Upload failed" }),
});
render(<QuoteForm open onOpenChange={vi.fn()} onSaved={vi.fn()} />);
fireEvent.click(await screen.findByRole("button", { name: /select customer or company/i }));
fireEvent.click(screen.getByRole("option", { name: /sj bookkeeping/i }));
fireEvent.click(screen.getByRole("button", { name: /add item/i }));
fireEvent.change(screen.getByLabelText(/item description/i), {
target: { value: "Needs photo attached" },
});
await waitFor(() => {
expect(screen.getByLabelText(/item description/i)).toHaveValue("Needs photo attached");
});
fireEvent.change(screen.getByLabelText(/upload attachments/i), {
target: { files: [new File(["img"], "photo.jpg", { type: "image/jpeg" })] },
});
fireEvent.click(screen.getByRole("button", { name: /create quote/i }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(
"/api/v1/quotes/items/item-1/attachments/upload",
expect.objectContaining({ method: "POST", body: expect.any(FormData) }),
);
expect(toastErrorMock).toHaveBeenCalledWith(
expect.stringMatching(/attachments failed/i),
);
});
});
});