From 275d17b76a07e9baf07340d506a494bfa2bb8d95 Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Thu, 30 Jul 2026 22:35:35 +0200 Subject: [PATCH] fix(stock-quick-draft): race-guard search, surface API errors, deterministic test timing Co-authored-by: Orca --- .../stock-orders/quick-draft-modal.test.tsx | 77 +++++++++++-------- .../stock-orders/quick-draft-modal.tsx | 45 +++++++++-- 2 files changed, 81 insertions(+), 41 deletions(-) diff --git a/src/components/stock-orders/quick-draft-modal.test.tsx b/src/components/stock-orders/quick-draft-modal.test.tsx index 8afa3ad..8b5b374 100644 --- a/src/components/stock-orders/quick-draft-modal.test.tsx +++ b/src/components/stock-orders/quick-draft-modal.test.tsx @@ -1,6 +1,5 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { fireEvent, render, screen, act } from "@testing-library/react"; import { QuickDraftModal } from "./quick-draft-modal"; // The modal uses ModalShell.Dialog which depends on @base-ui/react. @@ -37,22 +36,21 @@ describe("QuickDraftModal — header", () => { expect((input as HTMLInputElement).value).toMatch(/^Stock order entry — /); }); - it("lets the buyer rename the reference", async () => { - const user = userEvent.setup(); + it("lets the buyer rename the reference", () => { render( {}} />, ); const input = screen.getByTestId( "stock-order-reference", ) as HTMLInputElement; - await user.clear(input); - await user.type(input, "Jul 2026 run"); + fireEvent.change(input, { target: { value: "Jul 2026 run" } }); expect(input.value).toBe("Jul 2026 run"); }); }); describe("QuickDraftModal — search", () => { beforeEach(() => { + vi.useFakeTimers(); global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ @@ -70,32 +68,37 @@ describe("QuickDraftModal — search", () => { }); }); + afterEach(() => { + vi.useRealTimers(); + }); + it("debounces search and calls the search endpoint", async () => { - const user = userEvent.setup(); render( {}} />); - const input = screen.getByTestId("quick-draft-search"); - await user.type(input, "cutting"); - // Wait for debounce (200ms in implementation + a margin). - await new Promise((r) => setTimeout(r, 250)); + const input = screen.getByTestId("quick-draft-search") as HTMLInputElement; + fireEvent.change(input, { target: { value: "cutting" } }); + // Advance past the 200ms debounce so the fetch resolves under fake timers. + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); expect(global.fetch).toHaveBeenCalledWith( expect.stringContaining("/api/v1/stock-quick-draft/search"), - expect.objectContaining({}), ); }); it("renders a search result", async () => { - const user = userEvent.setup(); render( {}} />); - await user.type(screen.getByTestId("quick-draft-search"), "cutting"); - await new Promise((r) => setTimeout(r, 250)); - expect(await screen.findByText(/115mm Cutting Disc/)).toBeInTheDocument(); + fireEvent.change(screen.getByTestId("quick-draft-search"), { + target: { value: "cutting" }, + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + expect(screen.getByText(/115mm Cutting Disc/)).toBeInTheDocument(); }); it("fetches supplier history when a result is clicked", async () => { - const user = userEvent.setup(); - global.fetch = vi - .fn() - .mockImplementationOnce(async () => ({ + (global.fetch as ReturnType) + .mockResolvedValueOnce({ ok: true, json: async () => ({ ok: true, @@ -103,8 +106,8 @@ describe("QuickDraftModal — search", () => { { id: "item-1", sku: "STK-CD-115", name: "115mm Cutting Disc" }, ], }), - })) - .mockImplementationOnce(async () => ({ + }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ ok: true, @@ -118,17 +121,25 @@ describe("QuickDraftModal — search", () => { }, ], }), - })); + }); render( {}} />); - await user.type(screen.getByTestId("quick-draft-search"), "cutting"); - await new Promise((r) => setTimeout(r, 250)); - await user.click(await screen.findByText(/115mm Cutting Disc/)); + fireEvent.change(screen.getByTestId("quick-draft-search"), { + target: { value: "cutting" }, + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + fireEvent.click(screen.getByText(/115mm Cutting Disc/)); + // Let the supplier-history fetch resolve so the history rows render. + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); - expect(global.fetch).toHaveBeenCalledTimes(2); - const secondUrl = (global.fetch as ReturnType).mock.calls[1][0]; - expect(secondUrl).toContain("/supplier-history"); - expect(await screen.findByText(/Steel & Pipe Co/)).toBeInTheDocument(); - expect(await screen.findByText(/R 18\.50/)).toBeInTheDocument(); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("/supplier-history"), + ); + expect(screen.getByText(/Steel & Pipe Co/)).toBeInTheDocument(); + expect(screen.getByText(/R 18\.50/)).toBeInTheDocument(); }); -}); \ No newline at end of file +}); diff --git a/src/components/stock-orders/quick-draft-modal.tsx b/src/components/stock-orders/quick-draft-modal.tsx index 2d2bc88..b417632 100644 --- a/src/components/stock-orders/quick-draft-modal.tsx +++ b/src/components/stock-orders/quick-draft-modal.tsx @@ -60,21 +60,39 @@ export function QuickDraftModal({ open, onOpenChange }: QuickDraftModalProps) { setSearchResults([]); return; } + let cancelled = false; const handle = setTimeout(async () => { try { const res = await fetch( `/api/v1/stock-quick-draft/search?q=${encodeURIComponent( searchQuery.trim(), )}&limit=10`, - {}, ); - const data = (await res.json()) as { ok: boolean; items?: StockItemSearchResult[] }; - if (data.ok && data.items) setSearchResults(data.items); + if (!res.ok) { + if (!cancelled) toast.error(`Search failed (HTTP ${res.status})`); + return; + } + const data = (await res.json()) as { + ok: boolean; + message?: string; + items?: StockItemSearchResult[]; + }; + if (cancelled) return; + if (!data.ok) { + toast.error(data.message ?? "Search failed"); + return; + } + if (data.items) setSearchResults(data.items); } catch (err) { - toast.error(err instanceof Error ? err.message : "Search failed"); + if (!cancelled) { + toast.error(err instanceof Error ? err.message : "Search failed"); + } } }, 200); - return () => clearTimeout(handle); + return () => { + cancelled = true; + clearTimeout(handle); + }; }, [searchQuery]); const onPickItem = async (item: StockItemSearchResult) => { @@ -84,10 +102,21 @@ export function QuickDraftModal({ open, onOpenChange }: QuickDraftModalProps) { try { const res = await fetch( `/api/v1/stock-quick-draft/supplier-history?stockItemId=${encodeURIComponent(item.id)}`, - {}, ); - const data = (await res.json()) as { ok: boolean; history?: SupplierHistoryEntry[] }; - if (data.ok && data.history) setHistory(data.history); + if (!res.ok) { + toast.error(`Failed to load supplier history (HTTP ${res.status})`); + return; + } + const data = (await res.json()) as { + ok: boolean; + message?: string; + history?: SupplierHistoryEntry[]; + }; + if (!data.ok) { + toast.error(data.message ?? "Failed to load supplier history"); + return; + } + if (data.history) setHistory(data.history); } catch (err) { toast.error(err instanceof Error ? err.message : "History fetch failed"); } finally {