152 lines
4.9 KiB
TypeScript
152 lines
4.9 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { QuoteItem, QuoteItemAiFill } from "@/lib/types";
|
|
import { reviewerFromAuthRecord, withAiReview } from "./quote-assistant-save";
|
|
|
|
const NOW = new Date("2026-10-16T12:00:00.000Z");
|
|
const reviewer = { id: "u1", name: "Rep Rita" };
|
|
|
|
function fill(over: Partial<QuoteItemAiFill> = {}): QuoteItemAiFill {
|
|
return {
|
|
status: "filled",
|
|
stock_item_id: "s-1",
|
|
stock_item_name: "Bolt",
|
|
stock_item_sku: "B",
|
|
cost_price: 10,
|
|
selling_price: 15,
|
|
markup: 50,
|
|
supplier_id: "sup1",
|
|
confidence: "high",
|
|
reason: "",
|
|
doubts: [],
|
|
candidates: [],
|
|
description_snapshot: "bolt",
|
|
model: "m",
|
|
attempted_at: NOW.toISOString(),
|
|
...over,
|
|
};
|
|
}
|
|
|
|
const aiItem = { ai_fill: fill(), pricing_source: "ai_pos" } as unknown as QuoteItem;
|
|
const plainItem = { ai_fill: null, pricing_source: "" } as unknown as QuoteItem;
|
|
const saved = { cost_price: 10, selling_price: 15, supplier: "sup1" };
|
|
|
|
describe("withAiReview", () => {
|
|
it("keeps ai_pos and records the review on a pending AI line", () => {
|
|
const out = withAiReview(
|
|
aiItem,
|
|
{ cost_price: 10, selling_price: 15, pricing_source: "manual" },
|
|
saved,
|
|
reviewer,
|
|
NOW,
|
|
{ markProcessedWhenPriced: false },
|
|
);
|
|
expect(out.pricing_source).toBe("ai_pos");
|
|
const f = out.ai_fill as QuoteItemAiFill;
|
|
expect(f.reviewed_at).toBe(NOW.toISOString());
|
|
expect(f.reviewed_by).toBe("Rep Rita");
|
|
expect(f.accepted_unchanged).toBe(true);
|
|
expect(out.is_processed).toBeUndefined();
|
|
});
|
|
|
|
it("adds is_processed on a pending AI line when priced and asked to", () => {
|
|
const out = withAiReview(aiItem, { cost_price: 10, selling_price: 15 }, saved, reviewer, NOW, {
|
|
markProcessedWhenPriced: true,
|
|
});
|
|
expect(out.is_processed).toBe(true);
|
|
});
|
|
|
|
it("does not add is_processed on a pending AI line with a zero price", () => {
|
|
const out = withAiReview(
|
|
aiItem,
|
|
{ cost_price: 10, selling_price: 0 },
|
|
{ ...saved, selling_price: 0 },
|
|
reviewer,
|
|
NOW,
|
|
{ markProcessedWhenPriced: true },
|
|
);
|
|
expect(out.is_processed).toBeUndefined();
|
|
});
|
|
|
|
it("never adds is_processed or ai_fill to a non-AI line", () => {
|
|
const patch = { cost_price: 10, selling_price: 15 };
|
|
const out = withAiReview(plainItem, patch, saved, reviewer, NOW, {
|
|
markProcessedWhenPriced: true,
|
|
});
|
|
expect(out).toEqual(patch);
|
|
expect("is_processed" in out).toBe(false);
|
|
expect("ai_fill" in out).toBe(false);
|
|
});
|
|
|
|
it("maps pricing_source through nextPricingSourceOnSave on a non-AI line", () => {
|
|
const out = withAiReview(
|
|
plainItem,
|
|
{ cost_price: 1, pricing_source: "manual" },
|
|
saved,
|
|
reviewer,
|
|
NOW,
|
|
{ markProcessedWhenPriced: false },
|
|
);
|
|
expect(out.pricing_source).toBe("manual");
|
|
const cat = withAiReview(
|
|
{ ...plainItem, pricing_source: "catalogue" } as QuoteItem,
|
|
{ pricing_source: "manual" },
|
|
saved,
|
|
reviewer,
|
|
NOW,
|
|
{ markProcessedWhenPriced: false },
|
|
);
|
|
expect(cat.pricing_source).toBe("catalogue");
|
|
});
|
|
|
|
it("does not add pricing_source when the patch has none and the line is not AI", () => {
|
|
const out = withAiReview(plainItem, { cost_price: 1 }, saved, reviewer, NOW, {
|
|
markProcessedWhenPriced: false,
|
|
});
|
|
expect("pricing_source" in out).toBe(false);
|
|
});
|
|
|
|
it("keeps an explicit catalogue source but still records the review", () => {
|
|
const out = withAiReview(
|
|
aiItem,
|
|
{ pricing_source: "catalogue", cost_price: 12 },
|
|
{ ...saved, cost_price: 12 },
|
|
reviewer,
|
|
NOW,
|
|
{ markProcessedWhenPriced: false },
|
|
);
|
|
expect(out.pricing_source).toBe("catalogue");
|
|
expect((out.ai_fill as QuoteItemAiFill).accepted_unchanged).toBe(false);
|
|
});
|
|
|
|
it("does not touch a reviewed AI line", () => {
|
|
const reviewed = {
|
|
ai_fill: fill({ reviewed_at: NOW.toISOString() }),
|
|
pricing_source: "ai_pos",
|
|
} as unknown as QuoteItem;
|
|
const out = withAiReview(reviewed, { cost_price: 1 }, saved, reviewer, NOW, {
|
|
markProcessedWhenPriced: true,
|
|
});
|
|
expect(out).toEqual({ cost_price: 1 });
|
|
});
|
|
|
|
it("never removes keys from the patch and does not mutate it", () => {
|
|
const patch = { a: 1, b: "x", pricing_source: "manual" };
|
|
const copy = { ...patch };
|
|
const out = withAiReview(aiItem, patch, saved, reviewer, NOW, {
|
|
markProcessedWhenPriced: true,
|
|
});
|
|
for (const k of Object.keys(copy)) expect(k in out).toBe(true);
|
|
expect(patch).toEqual(copy);
|
|
});
|
|
});
|
|
|
|
describe("reviewerFromAuthRecord", () => {
|
|
it("prefers name, falls back to email, then empty", () => {
|
|
expect(reviewerFromAuthRecord({ id: "u1", name: "Rita", email: "r@x.co" })).toEqual({
|
|
id: "u1",
|
|
name: "Rita",
|
|
});
|
|
expect(reviewerFromAuthRecord({ id: "u1", name: "", email: "r@x.co" }).name).toBe("r@x.co");
|
|
expect(reviewerFromAuthRecord(null)).toEqual({ id: undefined, name: "" });
|
|
});
|
|
});
|