import { beforeEach, describe, expect, it, vi } from "vitest"; import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { StockCard } from "./stock-card"; import { correctStock, fetchStock, withdrawStock } from "@/lib/stock/api"; import type { BatchStock, StockBatchPath } from "@/lib/stock/types"; vi.mock("@/lib/stock/api", () => ({ fetchStock: vi.fn(), withdrawStock: vi.fn(), correctStock: vi.fn(), })); function renderCard(canBook = true, unit = "Gläser", path: StockBatchPath = "bottling-batches") { const client = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); return render( , ); } function stock(remaining: number): BatchStock { return { summary: { initial: 120, soldInShop: 30, withdrawn: 10, corrected: 0, consumed: 0, remaining, }, movements: [ { id: "m1", type: "SHOP_SALE", quantity: -30, reason: null, notes: null, apiKeyId: "k1", createdAt: "2026-10-01T10:00:00.000Z", user: null, shopRequest: { externalId: "x", kind: "SALE" }, }, { id: "m2", type: "WITHDRAWAL", quantity: -10, reason: "HOFLADEN", notes: null, apiKeyId: null, createdAt: "2026-10-01T11:00:00.000Z", user: { id: "u1", name: "Nick" }, shopRequest: null, }, ], }; } describe("StockCard", () => { beforeEach(() => vi.mocked(fetchStock).mockReset()); it("separates bottled, sold, withdrawn and remaining", async () => { vi.mocked(fetchStock).mockResolvedValue(stock(80)); renderCard(); expect(await screen.findByText("Abgefüllt")).toBeInTheDocument(); expect(screen.getByText("120")).toBeInTheDocument(); expect(screen.getByText("Webshop verkauft")).toBeInTheDocument(); expect(screen.getByText("80")).toBeInTheDocument(); expect(screen.queryByText("Prüfen")).not.toBeInTheDocument(); }); it("lists shop sales as coming from the shop and withdrawals with their reason", async () => { vi.mocked(fetchStock).mockResolvedValue(stock(80)); renderCard(); expect(await screen.findByText("Webshop-Verkauf")).toBeInTheDocument(); expect(screen.getByText(/· Webshop$/)).toBeInTheDocument(); expect(screen.getByText("Entnahme · Hofladen")).toBeInTheDocument(); }); it("flags a negative remaining stock for review", async () => { vi.mocked(fetchStock).mockResolvedValue(stock(-2)); renderCard(); expect(await screen.findByText("Prüfen")).toBeInTheDocument(); }); it("shows the booking buttons only with permission", async () => { vi.mocked(fetchStock).mockResolvedValue(stock(80)); renderCard(false); await screen.findByText("Abgefüllt"); expect( screen.queryByRole("button", { name: /Entnehmen/ }), ).not.toBeInTheDocument(); expect( screen.queryByRole("button", { name: /Inventur/ }), ).not.toBeInTheDocument(); }); it("stock card withdraw and counted stock use NumberStepper", async () => { vi.mocked(fetchStock).mockResolvedValue(stock(80)); vi.mocked(withdrawStock).mockResolvedValue(undefined as never); vi.mocked(correctStock).mockResolvedValue(undefined as never); renderCard(); await userEvent.click(await screen.findByRole("button", { name: /Entnehmen/ })); let dialog = await screen.findByRole("dialog"); const quantity = within(dialog).getByLabelText("Anzahl (Gläser)"); expect(quantity).toHaveValue("1"); expect(quantity).toHaveAttribute("inputmode", "numeric"); await userEvent.click(within(dialog).getByRole("button", { name: /Eins mehr/ })); await userEvent.click(within(dialog).getByRole("button", { name: "Entnahme buchen" })); expect(vi.mocked(withdrawStock).mock.calls[0][2]).toMatchObject({ quantity: 2 }); await userEvent.click(await screen.findByRole("button", { name: /Inventur korrigieren/ })); dialog = await screen.findByRole("dialog"); const counted = within(dialog).getByLabelText("Gezählt (Gläser)"); expect(counted).toHaveValue("80"); await userEvent.click(within(dialog).getByRole("button", { name: /Eins weniger/ })); expect(within(dialog).getByText(/Differenz: -1/)).toBeInTheDocument(); }); it("keeps a typed field for amounts in grams", async () => { vi.mocked(fetchStock).mockResolvedValue(stock(500)); renderCard(true, "g", "raw-material-lots"); await userEvent.click(await screen.findByRole("button", { name: /Entnehmen/ })); const dialog = await screen.findByRole("dialog"); expect(within(dialog).getByLabelText("Anzahl (g)")).toHaveAttribute("type", "number"); expect(within(dialog).queryByRole("button", { name: /mehr$/ })).toBeNull(); }); it("does not book an empty withdrawal", async () => { vi.mocked(fetchStock).mockResolvedValue(stock(80)); renderCard(); await userEvent.click(await screen.findByRole("button", { name: /Entnehmen/ })); const dialog = await screen.findByRole("dialog"); await userEvent.clear(within(dialog).getByLabelText("Anzahl (Gläser)")); expect(within(dialog).getByRole("button", { name: "Entnahme buchen" })).toBeDisabled(); }); it("does not book a withdrawal of 0 or more than is left", async () => { vi.mocked(fetchStock).mockResolvedValue(stock(80)); renderCard(); await userEvent.click(await screen.findByRole("button", { name: /Entnehmen/ })); const dialog = await screen.findByRole("dialog"); const quantity = within(dialog).getByLabelText("Anzahl (Gläser)"); const book = within(dialog).getByRole("button", { name: "Entnahme buchen" }); await userEvent.clear(quantity); await userEvent.type(quantity, "0"); expect(book).toBeDisabled(); await userEvent.clear(quantity); await userEvent.type(quantity, "80"); expect(book).toBeEnabled(); }); });