WFDASH-WEBAPP/src/lib/server/stock-count-service.test.ts

175 lines
12 KiB
TypeScript

import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import type { StockCountReading, StockCountRow } from "@/lib/stock-count";
const getOne = vi.fn();
const getFullList = vi.fn();
const create = vi.fn();
const update = vi.fn();
const send = vi.fn();
const getChat = vi.fn();
const reader = vi.fn();
const renderPdf = vi.fn();
const movement = vi.fn();
const admin = vi.fn();
vi.mock("./pocketbase-admin", () => ({ getAdminPocketBase: () => admin() }));
vi.mock("./giellie-chat-store", () => ({ getChat: (...args: unknown[]) => getChat(...args) }));
vi.mock("./stock-count-read", () => ({ readStockCountPhoto: (...args: unknown[]) => reader(...args) }));
vi.mock("@/lib/pdf/stock-count-document", () => ({ renderStockCountPdf: (...args: unknown[]) => renderPdf(...args) }));
vi.mock("./stock-movement-service", () => ({ applyStockMovement: (...args: unknown[]) => movement(...args) }));
import { getStockCount, createStockCount, previewStockCount, readStockCount, stockCountSummary, stockCountPdf, clearWorkingCountIfComplete } from "./stock-count-service";
const id = "abcde12345fghij";
const chatId = "abcdefghijklmno";
const actor = { id: "user00000000001", name: "Dave" };
const row: StockCountRow = { row_ref: "00001", id: "item00000000001", sku: "M12-60", description: "Hex bolt M12 x60", unit: "ea", supplier: "Bolts", current_qty: 4, revision: 3 };
const count = (over: Record<string, unknown> = {}) => ({ id, owner: actor.id, chat: chatId, reference: "SC123", scope: "Bolts", status: "ready", revision: 1, review_revision: 4, manifest: [row], parts: [{ part: 1, row_refs: [row.row_ref], filename: "sheet.pdf" }], pdfs: ["sheet.pdf"], created: "2026-11-06T08:00:00Z", ...over });
const reading = (over: Partial<StockCountReading> = {}): StockCountReading => ({ row_ref: row.row_ref, sku: row.sku, count: 8, status: "counted", ...over });
const item = (over: Record<string, unknown> = {}) => ({ id: row.id, sku: row.sku, name: row.description, description: row.description, unit: row.unit, current_qty: 4, stock_revision: 3, is_active: true, ...over });
beforeEach(() => {
vi.resetAllMocks();
admin.mockResolvedValue({ collection: (name: string) => ({ getOne: (record: string) => getOne(name, record), getFullList: (options: unknown) => getFullList(name, options), create: (body: unknown) => create(name, body), update: (record: string, body: unknown) => update(name, record, body) }), send, files: { getURL: (_record: unknown, filename: string) => `https://pb.test/${filename}` }, authStore: { token: "admin-token" } });
getOne.mockImplementation(async (name) => name === "stock_counts" ? count() : item());
send.mockImplementation(async (_url, options) => options?.body?.action === "invalidate" ? count({ review_revision: 4 }) : { id: "plan00000000001" });
getChat.mockResolvedValue({ attachments: ["source_1.jpg"] });
create.mockResolvedValue({ id: "read00000000001" });
reader.mockResolvedValue({ reference: "SC123-R1", part: 1, readings: [reading()] });
});
afterEach(() => vi.unstubAllGlobals());
describe("stock count ownership and identity", () => {
it("denies another owner before reading or writing stock", async () => {
getOne.mockResolvedValue(count({ owner: "other" }));
await expect(getStockCount(id, actor)).rejects.toMatchObject({ status: 404 });
await expect(previewStockCount(id, [reading()], actor)).rejects.toMatchObject({ status: 404 });
expect(send).not.toHaveBeenCalled();
});
it("returns stable authenticated part routes and its original chat", () => {
expect(stockCountSummary(count())).toMatchObject({ id, reference: "SC123-R1", chat_id: chatId, parts: [{ part: 1, filename: "sheet.pdf", url: `/api/v1/stock-counts/${id}/pdf/1` }] });
});
});
describe("count review eligibility", () => {
it("rejects a photo from a sheet re-split during extraction", async()=>{
getOne.mockImplementation(async(name:string)=>name==='stock_counts'?count({revision:2}):item());
await expect(previewStockCount(id,[reading()],actor,chatId,1,1)).rejects.toMatchObject({status:409});
expect(send).not.toHaveBeenCalled();
});
it("rejects a slow extraction after a newer correction and never invalidates it",async()=>{
getOne.mockImplementation(async(name:string)=>name==='stock_counts'?count({review_revision:3}):item());
await expect(previewStockCount(id,[reading()],actor,chatId,1,2)).rejects.toMatchObject({status:409});
expect(send).not.toHaveBeenCalled();
});
it("keeps blanks untouched but includes an explicitly counted zero", async () => {
const empty = await previewStockCount(id, [reading({ status: "blank", count: null })], actor);
expect(empty).toMatchObject({ plan_id: null, rows: [], skipped: [{ status: "blank" }] });
const zero = await previewStockCount(id, [reading({ count: 0 })], actor);
expect(zero.rows).toEqual([expect.objectContaining({ id: row.id, before: 4, after: 0, delta: -4, revision: 3 })]);
expect(send.mock.calls.filter(([, options]) => options?.body?.action === "plan")).toHaveLength(1);
});
it("invalidates an older review even when the replacement contains only blank counts", async () => {
const result = await previewStockCount(id, [reading({ status: "blank", count: null })], actor, chatId);
expect(result.plan_id).toBeNull();
expect(send).toHaveBeenCalledWith("/api/wfdash/stock-count-command", {
method: "POST", body: { action: "invalidate", data: { id, owner: actor.id } },
});
expect(send.mock.calls.filter(([, options]) => options?.body?.action === "plan")).toHaveLength(0);
});
it("binds a replacement plan to the invalidation transaction's review revision", async () => {
const result = await previewStockCount(id, [reading()], actor, chatId);
expect(result.plan_id).toBe("plan00000000001");
expect(send.mock.calls.map(([, options]) => options?.body?.action)).toEqual(["invalidate", "plan"]);
expect(send).toHaveBeenLastCalledWith("/api/wfdash/stock-count-command", expect.objectContaining({
body: expect.objectContaining({ action: "plan", data: expect.objectContaining({ count: id, count_revision: 1, review_revision: 4 }) }),
}));
});
it.each([-1, 1.5, NaN])("holds an invalid physical count %s", async (value) => {
const result = await previewStockCount(id, [reading({ count: value })], actor);
expect(result.rows).toEqual([]);
expect(result.skipped).toHaveLength(1);
expect(send.mock.calls.filter(([, options]) => options?.body?.action === "plan")).toHaveLength(0);
});
it("holds a wrong SKU instead of trusting the row reference alone", async () => {
const result = await previewStockCount(id, [reading({ sku: "M12-40" })], actor);
expect(result.rows).toEqual([]);
expect(send.mock.calls.filter(([, options]) => options?.body?.action === "plan")).toHaveLength(0);
});
it("coalesces identical repeated rows and holds conflicting quantities", async () => {
expect((await previewStockCount(id, [reading(), reading()], actor)).rows).toHaveLength(1);
send.mockClear();
expect((await previewStockCount(id, [reading(), reading({ count: 9 })], actor)).rows).toEqual([]);
expect(send.mock.calls.filter(([, options]) => options?.body?.action === "plan")).toHaveLength(0);
});
it("holds a conflicting SKU on any repeated occurrence", async () => {
const result = await previewStockCount(id, [reading(), reading({ sku: "M12-40" })], actor);
expect(result.rows).toEqual([]);
expect(send.mock.calls.filter(([, options]) => options?.body?.action === "plan")).toHaveLength(0);
});
it("holds stock movement even when the quantity has returned to the printed value", async () => {
getOne.mockImplementation(async (name) => name === "stock_counts" ? count() : item({ current_qty: 4, stock_revision: 5 }));
const result = await previewStockCount(id, [reading()], actor);
expect(result).toMatchObject({ plan_id: null, rows: [], skipped: [{ status: "conflict", printed: 4, current: 4, count: 8 }] });
expect(send.mock.calls.filter(([, options]) => options?.body?.action === "plan")).toHaveLength(0);
});
it("holds a catalogue unit change instead of applying an ea count as boxes", async () => {
getOne.mockImplementation(async (name) => name === "stock_counts" ? count() : item({ unit: "box" }));
const result = await previewStockCount(id, [reading()], actor);
expect(result.rows).toEqual([]);
expect(send.mock.calls.filter(([, options]) => options?.body?.action === "plan")).toHaveLength(0);
});
it("refuses a superseded or incomplete sheet review", async () => {
getOne.mockResolvedValue(count({ status: "building" }));
await expect(previewStockCount(id, [reading()], actor)).rejects.toMatchObject({ status: 409 });
expect(send.mock.calls.filter(([, options]) => options?.body?.action === "plan")).toHaveLength(0);
});
});
describe("registered photo sources", () => {
it("rejects an unknown source or another chat before fetching the photo", async () => {
const fetcher = vi.fn(); vi.stubGlobal("fetch", fetcher);
await expect(readStockCount(id, 1, ["arbitrary.jpg"], actor, chatId)).rejects.toMatchObject({ status: 403 });
await expect(readStockCount(id, 1, ["source_1.jpg"], actor, "otherchat000001")).rejects.toMatchObject({ status: 403 });
expect(fetcher).not.toHaveBeenCalled();
expect(reader).not.toHaveBeenCalled();
});
it("revokes the previous approval before reading a replacement photo, even if vision fails", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("PHOTO", { headers: { "Content-Type": "image/jpeg" } })));
reader.mockImplementation(async () => {
expect(send).toHaveBeenCalledWith("/api/wfdash/stock-count-command", {
method: "POST", body: { action: "invalidate", data: { id, owner: actor.id } },
});
throw new Error("The new photo was unclear.");
});
await expect(readStockCount(id, 1, ["source_1.jpg"], actor, chatId)).rejects.toThrow(/unclear/);
expect(send.mock.calls.map(([, options]) => options?.body?.action)).toEqual(["invalidate"]);
expect(create).not.toHaveBeenCalled();
});
it("reads a registered source once and saves the structured results", async () => {
const fetcher = vi.fn().mockResolvedValue(new Response("PHOTO", { headers: { "Content-Type": "image/jpeg" } }));
vi.stubGlobal("fetch", fetcher);
const result = await readStockCount(id, 1, ["source_1.jpg", "source_1.jpg"], actor, chatId);
expect(reader).toHaveBeenCalledTimes(1);
expect(reader).toHaveBeenCalledWith(expect.objectContaining({ reference: "SC123-R1", part: 1, rows: [row], contentType: "image/jpeg" }));
expect(create).toHaveBeenCalledWith("stock_count_reads", expect.objectContaining({ owner: actor.id, count: id, readings: [reading()] }));
expect(result.readings).toEqual([reading()]);
});
});
describe("download-only count PDFs",()=>{
it("creates a temporary working count without rendering or uploading a PDF",async()=>{
getFullList.mockResolvedValue([item({selling_price:100})]);
create.mockResolvedValue(count());
update.mockImplementation(async(_name,_id,patch)=>({...count(),...patch}));
await createStockCount({parts:1},actor,'https://0.0.0.0:80');
expect(renderPdf).not.toHaveBeenCalled();
expect(update.mock.calls.every(([, ,body])=>!(body instanceof FormData))).toBe(true);
expect(getFullList.mock.calls[0][1].filter).toBe('is_active = true && selling_price > 0');
});
it("renders the requested part in memory at download time and never stores bytes",async()=>{
renderPdf.mockResolvedValue(Buffer.from('%PDF-test'));
const result=await stockCountPdf(id,1,actor);
expect(Buffer.from(result.bytes).toString()).toBe('%PDF-test');
expect(renderPdf).toHaveBeenCalledWith(expect.objectContaining({reference:'SC123-R1',part:1,rows:[row]}));
expect(create).not.toHaveBeenCalled();expect(update).not.toHaveBeenCalled();
});
});
describe("temporary count lifecycle",()=>{
it("keeps the working count until every row has an applied observation",async()=>{getFullList.mockResolvedValue([]);await clearWorkingCountIfComplete(id,actor);expect(update).not.toHaveBeenCalled();});
it("clears the working sheet after every row has been applied",async()=>{getFullList.mockResolvedValue([{observation_key:`${id}_${row.row_ref}`}]);await clearWorkingCountIfComplete(id,actor);expect(update).toHaveBeenCalledWith('stock_counts',id,{status:'completed',manifest:[],parts:[]});});
});