fix(stock-quick-draft): race-guard search, surface API errors, deterministic test timing

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-07-30 22:35:35 +02:00
parent b099e2b42e
commit 275d17b76a
2 changed files with 81 additions and 41 deletions

View file

@ -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(
<QuickDraftModal open onOpenChange={() => {}} />,
);
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(<QuickDraftModal open onOpenChange={() => {}} />);
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(<QuickDraftModal open onOpenChange={() => {}} />);
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<typeof vi.fn>)
.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(<QuickDraftModal open onOpenChange={() => {}} />);
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<typeof vi.fn>).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();
});
});
});

View file

@ -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 {