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(); 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(); 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//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(); 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(); 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(); 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(); 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), ); }); }); });