WFDASH-WEBAPP/src/lib/server/quote-items-service.test.ts
Dave c4b53bcc4b feat(mcp): add walk-in quote creation and enhance customer filtering
- Introduced a new `quotes.create` tool for creating quotes from scratch without an inbound email, allowing operators to generate quotes directly from the dashboard.
- Updated `customers.list` to support filtering by representative using either a 15-char PB user ID or a case-insensitive name/email match.
- Enhanced API documentation in `docs/api-mcp-layer.md` to reflect the addition of the new quote creation tool and updated customer filtering capabilities.
- Regenerated documentation to ensure accuracy, increasing the total tool count from 125 to 126.
2026-08-15 11:50:48 +02:00

206 lines
6.6 KiB
TypeScript

/**
* Unit tests for `deriveSellingPriceIfOmitted` and the new
* `findRecentDuplicateQuoteItem` helper / `createQuoteItem` dedupe
* branch.
*
* `deriveSellingPriceIfOmitted` is a pure function — no PB / Next
* dependency — so we just import and run. The helper is consumed by
* `quote_items/[id]/route.ts` PATCH and `bulk_update_quote_items` so
* the same derivation logic applies to both edges.
*
* The dedupe tests stub a fake PocketBase client (just a
* `collection("quote_items").getList` callable) so we can drive
* both "no recent duplicate" and "recent duplicate found" outcomes
* without standing up a real PocketBase.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import {
deriveSellingPriceIfOmitted,
findRecentDuplicateQuoteItem,
IDEMPOTENCY_WINDOW_MS,
} from "./quote-items-service";
describe("deriveSellingPriceIfOmitted", () => {
it("derives selling_price when cost_price and markup are supplied", () => {
expect(
deriveSellingPriceIfOmitted({ cost_price: 2.39, markup: 30 }),
).toEqual({ cost_price: 2.39, markup: 30, selling_price: 3.11 });
});
it("rounds to 2 decimal places to avoid floating-point drift", () => {
// 1.005 * 1.25 = 1.25625 → 1.26 with Math.round, not 1.25
expect(
deriveSellingPriceIfOmitted({ cost_price: 1.005, markup: 25 }),
).toEqual({ cost_price: 1.005, markup: 25, selling_price: 1.26 });
});
it("prefers an explicit selling_price over the derived value", () => {
expect(
deriveSellingPriceIfOmitted({
cost_price: 2.39,
markup: 30,
selling_price: 4.0,
}),
).toEqual({ cost_price: 2.39, markup: 30, selling_price: 4.0 });
});
it("returns the patch unchanged when cost_price is missing", () => {
const patch = { markup: 30 };
expect(deriveSellingPriceIfOmitted(patch)).toBe(patch);
});
it("returns the patch unchanged when markup is missing", () => {
const patch = { cost_price: 2.39 };
expect(deriveSellingPriceIfOmitted(patch)).toBe(patch);
});
it("returns the patch unchanged when selling_price is already set", () => {
const patch = { cost_price: 2.39, markup: 30, selling_price: 0 };
expect(deriveSellingPriceIfOmitted(patch)).toBe(patch);
});
it("supports 0 markup (selling_price = cost_price)", () => {
expect(
deriveSellingPriceIfOmitted({ cost_price: 5.0, markup: 0 }),
).toEqual({ cost_price: 5.0, markup: 0, selling_price: 5.0 });
});
it("supports negative markup (clearance pricing)", () => {
expect(
deriveSellingPriceIfOmitted({ cost_price: 10, markup: -10 }),
).toEqual({ cost_price: 10, markup: -10, selling_price: 9.0 });
});
it("ignores non-finite numerics", () => {
const patch = { cost_price: NaN, markup: 30 };
expect(deriveSellingPriceIfOmitted(patch)).toBe(patch);
const patch2 = { cost_price: 1, markup: Infinity };
expect(deriveSellingPriceIfOmitted(patch2)).toBe(patch2);
});
it("preserves unrelated fields in the patch", () => {
expect(
deriveSellingPriceIfOmitted({
cost_price: 2,
markup: 50,
sku: "M16x50",
description: "Bolt",
notes: "From catalogue",
}),
).toEqual({
cost_price: 2,
markup: 50,
sku: "M16x50",
description: "Bolt",
notes: "From catalogue",
selling_price: 3.0,
});
});
});
describe("findRecentDuplicateQuoteItem", () => {
let getList: ReturnType<typeof vi.fn>;
beforeEach(() => {
getList = vi.fn();
});
function fakePb() {
return {
collection: () => ({ getList }),
} as unknown as Parameters<typeof findRecentDuplicateQuoteItem>[0];
}
it("returns null when no matching row exists", async () => {
getList.mockResolvedValue({ items: [], totalItems: 0, page: 1, perPage: 1 });
const result = await findRecentDuplicateQuoteItem(
fakePb(),
"q123",
"M16 bolt",
"M16-50",
);
expect(result).toBeNull();
// Filter must include the dedupe key + the time window.
expect(getList).toHaveBeenCalledWith(
1,
1,
expect.objectContaining({
filter: expect.stringMatching(
/^quote = "q123" && description = "M16 bolt" && sku = "M16-50" && created >= "\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z"$/,
),
}),
);
});
it("returns the existing row when a recent duplicate exists", async () => {
const existing = {
id: "qi-existing",
description: "M16 bolt",
sku: "M16-50",
quote: "q123",
};
getList.mockResolvedValue({ items: [existing], totalItems: 1, page: 1, perPage: 1 });
const result = await findRecentDuplicateQuoteItem(
fakePb(),
"q123",
"M16 bolt",
"M16-50",
);
expect(result).toEqual(existing);
});
it("escapes quotes in description to keep the PB filter parseable", async () => {
getList.mockResolvedValue({ items: [], totalItems: 0, page: 1, perPage: 1 });
await findRecentDuplicateQuoteItem(
fakePb(),
"q123",
'M16 bolt 1/2"',
"",
);
const filter = getList.mock.calls[0][2].filter as string;
// Description contains an embedded double-quote — must be escaped
// (PB filter syntax uses unescaped quotes as string delimiters).
expect(filter).toMatch(/description = "M16 bolt 1\/2\\""/);
});
it("returns null instead of throwing on a PB lookup error", async () => {
getList.mockRejectedValue(new Error("PB filter syntax glitch"));
const result = await findRecentDuplicateQuoteItem(
fakePb(),
"q123",
"M16 bolt",
"M16-50",
);
expect(result).toBeNull();
});
it("honors a custom window override", async () => {
getList.mockResolvedValue({ items: [], totalItems: 0, page: 1, perPage: 1 });
const before = Date.now();
await findRecentDuplicateQuoteItem(
fakePb(),
"q123",
"M16 bolt",
"M16-50",
5_000, // 5 seconds instead of the default 60
);
const after = Date.now();
const filter = getList.mock.calls[0][2].filter as string;
const sinceMatch = filter.match(/created >= "([^"]+)"/);
expect(sinceMatch).toBeTruthy();
if (sinceMatch) {
const sinceMs = Date.parse(sinceMatch[1]);
// `since` should be roughly 5s before the call. Allow ±100ms
// for clock granularity.
expect(sinceMs).toBeGreaterThanOrEqual(before - 5_000 - 100);
expect(sinceMs).toBeLessThanOrEqual(after - 5_000 + 100);
}
});
it("exports IDEMPOTENCY_WINDOW_MS as 60_000 (60-second window)", () => {
// Pin the window so a future "let's widen this" change has to
// also update this test and the docs that cite it.
expect(IDEMPOTENCY_WINDOW_MS).toBe(60_000);
});
});