diff --git a/src/components/order-item-context-panel.test.tsx b/src/components/order-item-context-panel.test.tsx new file mode 100644 index 0000000..e5089bc --- /dev/null +++ b/src/components/order-item-context-panel.test.tsx @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { OrderItemContextPanel } from "@/components/order-item-context-panel"; +import type { OrderItemContextResponse } from "@/lib/server/order-item-context-service"; + +afterEach(cleanup); + +const fullContext: OrderItemContextResponse = { + generated_at: "2026-08-07T10:00:00Z", + match: { sku: "DRL-6MM", match_kind: "sku_exact", match_confidence: 1 }, + last_quote: { + quote_item_id: "qi1", quote_id: "q1", quote_number: "Q-26080701", + customer_name: "Acme", company: "Acme Ltd", status: "approved", + cost_price: 100, selling_price: 150, markup_percent: 50, days_ago: 5, + }, + last_order: { + order_item_id: "oi1", order_id: "o1", order_number: "ORD-26080702", + company: "Acme Ltd", cost_price: 110, selling_price: 150, + markup_percent: 36.4, item_status: "delivered", updated_at: "2026-08-01T00:00:00Z", days_ago: 6, + }, + last_invoiced: { + order_item_id: "oi2", order_id: "o2", order_number: "ORD-26080703", + supplier_invoice_number: "INV-99", customer_invoice_number: "C-7", + invoiced_date: "2026-08-02T00:00:00Z", cost_price: 105, quantity: 10, qty_invoiced: 10, days_ago: 5, + }, + stock: { + stock_item_id: "s1", name: "Drill 6mm", sku: "DRL-6MM", + current_qty: 240, min_qty: 20, selling_price: 140, markup_percent: 40, status: "in_stock", + }, + suppliers: [], + cheapest: null, +}; + +describe("OrderItemContextPanel", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("renders all 4 rows when context is fully populated", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + status: 200, + json: async () => fullContext, + } as Response); + + render( {}} />); + await waitFor(() => { + expect(screen.getByText(/Last quote/)).toBeInTheDocument(); + expect(screen.getByText(/Last order/)).toBeInTheDocument(); + expect(screen.getByText(/Last invoice/)).toBeInTheDocument(); + expect(screen.getByText(/Stock/)).toBeInTheDocument(); + }); + }); + + it("renders the dashed empty-state card when context is all-null", async () => { + const empty: OrderItemContextResponse = { + ...fullContext, + last_quote: null, last_order: null, last_invoiced: null, stock: null, suppliers: [], + }; + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + status: 200, + json: async () => empty, + } as Response); + + render( {}} />); + await waitFor(() => { + expect(screen.getByText(/No context for this item yet/)).toBeInTheDocument(); + }); + }); + + it("fires onApply with the row's cost and source when Apply is clicked", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + status: 200, + json: async () => fullContext, + } as Response); + const onApply = vi.fn(); + + render(); + await waitFor(() => screen.getByText(/Last quote/)); + // Walk up two levels: span (Last quote) → label div → row div (which contains the Apply button). + const quoteRow = screen.getByText(/Last quote/).parentElement!.parentElement!; + const applyBtn = quoteRow.querySelector("button")!; + fireEvent.click(applyBtn); + + expect(onApply).toHaveBeenCalledWith(expect.objectContaining({ + source: "last_quote", + cost_price: 100, + source_label: "Q-26080701", + })); + }); + + it("marks the invoice row's selling cell as dimmed", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + status: 200, + json: async () => fullContext, + } as Response); + + render( {}} />); + await waitFor(() => screen.getByText(/Last invoice/)); + // The dimmed class is applied to the selling cell on the invoice row. + const dimmed = document.querySelector(".text-muted-foreground\\/50"); + expect(dimmed).not.toBeNull(); + }); +});