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:
parent
b099e2b42e
commit
275d17b76a
2 changed files with 81 additions and 41 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue