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:
parent
213a0e5a08
commit
230150fc88
1 changed files with 100 additions and 18 deletions
|
|
@ -14,6 +14,16 @@ const { pbMock, clearStore } = vi.hoisted(() => {
|
|||
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 trimmed = s.trim();
|
||||
if (!trimmed.startsWith("(") || !trimmed.endsWith(")")) return s;
|
||||
|
|
@ -124,14 +134,56 @@ const { pbMock, clearStore } = vi.hoisted(() => {
|
|||
const row = coll.get(id);
|
||||
if (!row) throw new Error(`404: ${name}/${id} not found`);
|
||||
// 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(",")) {
|
||||
if (f in row) projected[f] = row[f];
|
||||
}
|
||||
return { ...projected, id, collectionId: `pbc_${name}`, collectionName: name } as T;
|
||||
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;
|
||||
}
|
||||
return { ...row, id, collectionId: `pbc_${name}`, collectionName: name } as T;
|
||||
// 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 { ...base, id, expand, collectionId: `pbc_${name}`, collectionName: name } as T;
|
||||
}
|
||||
return { ...base, id, collectionId: `pbc_${name}`, collectionName: name } as T;
|
||||
},
|
||||
async getList<T>(
|
||||
page: number,
|
||||
|
|
@ -198,12 +250,35 @@ const { pbMock, clearStore } = vi.hoisted(() => {
|
|||
}
|
||||
return res.items[0];
|
||||
},
|
||||
async create(data: Record<string, unknown>) {
|
||||
async create(data: Record<string, unknown> | FormData) {
|
||||
const id = nextId();
|
||||
const now = new Date().toISOString();
|
||||
// New PB rows default to `is_processed: false` for quote_items
|
||||
// — matches the production schema default. Operators flip this
|
||||
// to true when they save the pricing modal.
|
||||
// FormData path — used by `generateQuotePdf` when it
|
||||
// uploads the freshly-rendered PDF. Mirrors the
|
||||
// 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> = {
|
||||
cost_price: 0,
|
||||
selling_price: 0,
|
||||
|
|
@ -211,10 +286,17 @@ const { pbMock, clearStore } = vi.hoisted(() => {
|
|||
is_processed: false,
|
||||
moved_to_quote: null,
|
||||
supplier: null,
|
||||
...data,
|
||||
created: data.created || now,
|
||||
...(data as Record<string, unknown>),
|
||||
created: (data as Record<string, unknown>).created || 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);
|
||||
return { ...merged, id };
|
||||
},
|
||||
|
|
@ -657,7 +739,7 @@ describe("generateQuotePdf auto-claim", () => {
|
|||
expect(outcome.claimedBy).toBe(user.id);
|
||||
// The claim must land atomically with the status flip in the
|
||||
// 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.claimed_by).toBe(user.id);
|
||||
});
|
||||
|
|
@ -690,7 +772,7 @@ describe("generateQuotePdf auto-claim", () => {
|
|||
expect(outcome.kind).toBe("ok");
|
||||
if (outcome.kind !== "ok") throw new Error("expected ok outcome");
|
||||
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.
|
||||
expect(after.status).toBe("approval_pending");
|
||||
expect(after.claimed_by).toBe(user.id);
|
||||
|
|
@ -734,7 +816,7 @@ describe("generateQuotePdf auto-claim", () => {
|
|||
// Outcome surfaces the (unchanged) claim.
|
||||
expect(outcome.claimedBy).toBe("existing-user-id");
|
||||
// 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).not.toBe(caller.id);
|
||||
});
|
||||
|
|
@ -771,7 +853,7 @@ describe("generateQuotePdf auto-claim", () => {
|
|||
// claimedBy is null (no claim) — the terminal-status skip wins
|
||||
// over the actorEmail resolution.
|
||||
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();
|
||||
// Status is also left alone (terminal path skips the timestamp
|
||||
// bump entirely — see comment in service).
|
||||
|
|
@ -806,7 +888,7 @@ describe("generateQuotePdf auto-claim", () => {
|
|||
expect(outcome.kind).toBe("ok");
|
||||
if (outcome.kind !== "ok") throw new Error("expected ok outcome");
|
||||
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();
|
||||
// Status still flipped — the PDF render is the whole point.
|
||||
expect(after.status).toBe("approval_pending");
|
||||
|
|
@ -836,7 +918,7 @@ describe("generateQuotePdf auto-claim", () => {
|
|||
expect(outcome.kind).toBe("ok");
|
||||
if (outcome.kind !== "ok") throw new Error("expected ok outcome");
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue