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).
296 lines
10 KiB
TypeScript
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),
|
|
);
|
|
});
|
|
});
|
|
});
|