175 lines
12 KiB
TypeScript
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:[]});});
|
|
});
|