- 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.
206 lines
6.6 KiB
TypeScript
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);
|
|
});
|
|
});
|