test(quote-pdf-service): expand PB mock to support expand + FormData uploads

- getOne: resolve singular relation fields via the standard
  customer/company/user -> collections/companies/users mappings
  and hydrate them under `expand.<relation>`, including one
  level of nested expansion.
- create: accept FormData so generateQuotePdf's PDF upload
  path is exercised end-to-end; blob entries become a
  one-element filename array under their key to mirror PB's
  file-field shape.
- create: pin the mock-generated id INTO the row so getList's
  fields projection returns a real id to callers (services
  reading the returned id, e.g. auto-claim lookup, were
  receiving `id: undefined`).
- auto-claim tests: add generic type params to getOne calls
  so .status / .claimed_by are typed against the mock.
This commit is contained in:
Dave 2026-09-08 18:55:46 +02:00
parent 213a0e5a08
commit 230150fc88

View file

@ -14,6 +14,16 @@ const { pbMock, clearStore } = vi.hoisted(() => {
return store[collection]; return store[collection];
}; };
// Map a singular relation field name to its backing collection.
// Tries `name`, then `name + "s"`, then `name.replace(/y$/, "ies")`.
// Matches PB's convention for the three relations the PDF
// service expands (`customer` → `customers`, `company` →
// `companies`, `user` → `users`).
const lookupCollection = (
map: Record<string, Map<string, Record<string, unknown>>>,
relName: string,
) => map[relName] ?? map[`${relName}s`] ?? map[relName.replace(/y$/, "ies")];
const stripOuterParens = (s: string): string => { const stripOuterParens = (s: string): string => {
const trimmed = s.trim(); const trimmed = s.trim();
if (!trimmed.startsWith("(") || !trimmed.endsWith(")")) return s; if (!trimmed.startsWith("(") || !trimmed.endsWith(")")) return s;
@ -124,14 +134,56 @@ const { pbMock, clearStore } = vi.hoisted(() => {
const row = coll.get(id); const row = coll.get(id);
if (!row) throw new Error(`404: ${name}/${id} not found`); if (!row) throw new Error(`404: ${name}/${id} not found`);
// Mock the `fields` projection (only the requested fields). // Mock the `fields` projection (only the requested fields).
if (opts?.fields) { const base: Record<string, unknown> = opts?.fields
const projected: Record<string, unknown> = {}; ? (() => {
for (const f of opts.fields.split(",")) { const projected: Record<string, unknown> = {};
if (f in row) projected[f] = row[f]; for (const f of opts.fields.split(",")) {
if (f in row) projected[f] = row[f];
}
return projected;
})()
: { ...row };
// Mock the `expand` resolution. PB inlines the related
// records under `expand.<relation>` as a hydrated object.
if (opts?.expand) {
const expand: Record<string, unknown> = {};
for (const rel of opts.expand.split(",")) {
const target = rel.split(".")[0];
const relId = base[target];
if (typeof relId !== "string" || !relId) {
expand[rel] = null;
continue;
}
// Resolve relation → collection. PB stores relations
// as singular field names (`customer`) backed by a
// plural collection (`customers`); companies follow
// the `y → ies` rule. Mirror that here.
const targetColl = lookupCollection(store, target);
const hit = targetColl?.get(relId);
if (!hit) {
expand[rel] = null;
continue;
}
if (rel.includes(".")) {
const [, nested] = rel.split(".");
const nestedId = (hit as Record<string, unknown>)[nested];
if (typeof nestedId === "string" && nestedId) {
const nestedColl = lookupCollection(store, nested);
const nestedHit = nestedColl?.get(nestedId);
expand[rel] = {
...hit,
expand: { [nested]: nestedHit ? { ...nestedHit, id: nestedId } : null },
};
} else {
expand[rel] = { ...hit, expand: { [nested]: null } };
}
} else {
expand[rel] = { ...hit, id: relId };
}
} }
return { ...projected, id, collectionId: `pbc_${name}`, collectionName: name } as T; return { ...base, id, expand, collectionId: `pbc_${name}`, collectionName: name } as T;
} }
return { ...row, id, collectionId: `pbc_${name}`, collectionName: name } as T; return { ...base, id, collectionId: `pbc_${name}`, collectionName: name } as T;
}, },
async getList<T>( async getList<T>(
page: number, page: number,
@ -198,12 +250,35 @@ const { pbMock, clearStore } = vi.hoisted(() => {
} }
return res.items[0]; return res.items[0];
}, },
async create(data: Record<string, unknown>) { async create(data: Record<string, unknown> | FormData) {
const id = nextId(); const id = nextId();
const now = new Date().toISOString(); const now = new Date().toISOString();
// New PB rows default to `is_processed: false` for quote_items // FormData path — used by `generateQuotePdf` when it
// — matches the production schema default. Operators flip this // uploads the freshly-rendered PDF. Mirrors the
// to true when they save the pricing modal. // FormData handling in `stock-purchases-service.test.ts`:
// every form field becomes a row column, blob entries
// become a one-element array under their key so PB's
// file-field shape (`file: ["<filename>.pdf"]`) is
// preserved end-to-end.
if (typeof FormData !== "undefined" && data instanceof FormData) {
const merged: Record<string, unknown> = {
created: now,
updated: now,
};
for (const [key, value] of data.entries()) {
if (typeof Blob !== "undefined" && value instanceof Blob) {
const filename =
(value as Blob & { name?: string }).name || "upload.pdf";
merged[key] = [filename];
} else {
merged[key] = value;
}
}
merged.id = id;
coll.set(id, merged);
return { ...merged, id };
}
// Plain-object path (the common case for tests).
const merged: Record<string, unknown> = { const merged: Record<string, unknown> = {
cost_price: 0, cost_price: 0,
selling_price: 0, selling_price: 0,
@ -211,10 +286,17 @@ const { pbMock, clearStore } = vi.hoisted(() => {
is_processed: false, is_processed: false,
moved_to_quote: null, moved_to_quote: null,
supplier: null, supplier: null,
...data, ...(data as Record<string, unknown>),
created: data.created || now, created: (data as Record<string, unknown>).created || now,
updated: now, updated: now,
}; };
// Pin the mock-generated id INTO the row so `getList`'s
// fields projection (`return { ...projected, id: r.id }`)
// can see it. Without this, `r.id` is undefined and the
// mock leaks an `id: undefined` to callers — services
// that read the returned id (e.g. auto-claim lookup)
// then see no id at all.
merged.id = id;
coll.set(id, merged); coll.set(id, merged);
return { ...merged, id }; return { ...merged, id };
}, },
@ -657,7 +739,7 @@ describe("generateQuotePdf auto-claim", () => {
expect(outcome.claimedBy).toBe(user.id); expect(outcome.claimedBy).toBe(user.id);
// The claim must land atomically with the status flip in the // The claim must land atomically with the status flip in the
// SAME PB write — verify the post-update quote has both. // SAME PB write — verify the post-update quote has both.
const after = await pbMock.collection("quotes").getOne(quote.id); const after = await pbMock.collection("quotes").getOne<{ status: string; claimed_by: string }>(quote.id);
expect(after.status).toBe("approval_pending"); expect(after.status).toBe("approval_pending");
expect(after.claimed_by).toBe(user.id); expect(after.claimed_by).toBe(user.id);
}); });
@ -690,7 +772,7 @@ describe("generateQuotePdf auto-claim", () => {
expect(outcome.kind).toBe("ok"); expect(outcome.kind).toBe("ok");
if (outcome.kind !== "ok") throw new Error("expected ok outcome"); if (outcome.kind !== "ok") throw new Error("expected ok outcome");
expect(outcome.claimedBy).toBe(user.id); expect(outcome.claimedBy).toBe(user.id);
const after = await pbMock.collection("quotes").getOne(quote.id); const after = await pbMock.collection("quotes").getOne<{ status: string; claimed_by: string }>(quote.id);
// Status stays put on the regen path; claim lands alongside. // Status stays put on the regen path; claim lands alongside.
expect(after.status).toBe("approval_pending"); expect(after.status).toBe("approval_pending");
expect(after.claimed_by).toBe(user.id); expect(after.claimed_by).toBe(user.id);
@ -734,7 +816,7 @@ describe("generateQuotePdf auto-claim", () => {
// Outcome surfaces the (unchanged) claim. // Outcome surfaces the (unchanged) claim.
expect(outcome.claimedBy).toBe("existing-user-id"); expect(outcome.claimedBy).toBe("existing-user-id");
// Caller's id is not written; the pre-existing claim wins. // Caller's id is not written; the pre-existing claim wins.
const after = await pbMock.collection("quotes").getOne(quote.id); const after = await pbMock.collection("quotes").getOne<{ claimed_by: string }>(quote.id);
expect(after.claimed_by).toBe("existing-user-id"); expect(after.claimed_by).toBe("existing-user-id");
expect(after.claimed_by).not.toBe(caller.id); expect(after.claimed_by).not.toBe(caller.id);
}); });
@ -771,7 +853,7 @@ describe("generateQuotePdf auto-claim", () => {
// claimedBy is null (no claim) — the terminal-status skip wins // claimedBy is null (no claim) — the terminal-status skip wins
// over the actorEmail resolution. // over the actorEmail resolution.
expect(outcome.claimedBy).toBeNull(); expect(outcome.claimedBy).toBeNull();
const after = await pbMock.collection("quotes").getOne(quote.id); const after = await pbMock.collection("quotes").getOne<{ status: string; claimed_by: string }>(quote.id);
expect(after.claimed_by).toBeFalsy(); expect(after.claimed_by).toBeFalsy();
// Status is also left alone (terminal path skips the timestamp // Status is also left alone (terminal path skips the timestamp
// bump entirely — see comment in service). // bump entirely — see comment in service).
@ -806,7 +888,7 @@ describe("generateQuotePdf auto-claim", () => {
expect(outcome.kind).toBe("ok"); expect(outcome.kind).toBe("ok");
if (outcome.kind !== "ok") throw new Error("expected ok outcome"); if (outcome.kind !== "ok") throw new Error("expected ok outcome");
expect(outcome.claimedBy).toBeNull(); expect(outcome.claimedBy).toBeNull();
const after = await pbMock.collection("quotes").getOne(quote.id); const after = await pbMock.collection("quotes").getOne<{ claimed_by: string; status: string }>(quote.id);
expect(after.claimed_by).toBeFalsy(); expect(after.claimed_by).toBeFalsy();
// Status still flipped — the PDF render is the whole point. // Status still flipped — the PDF render is the whole point.
expect(after.status).toBe("approval_pending"); expect(after.status).toBe("approval_pending");
@ -836,7 +918,7 @@ describe("generateQuotePdf auto-claim", () => {
expect(outcome.kind).toBe("ok"); expect(outcome.kind).toBe("ok");
if (outcome.kind !== "ok") throw new Error("expected ok outcome"); if (outcome.kind !== "ok") throw new Error("expected ok outcome");
expect(outcome.claimedBy).toBeNull(); expect(outcome.claimedBy).toBeNull();
const after = await pbMock.collection("quotes").getOne(quote.id); const after = await pbMock.collection("quotes").getOne<{ claimed_by: string; status: string }>(quote.id);
expect(after.claimed_by).toBeFalsy(); expect(after.claimed_by).toBeFalsy();
}); });
}); });