diff --git a/src/lib/server/quote-pdf-service.test.ts b/src/lib/server/quote-pdf-service.test.ts index e94c10f..49ab439 100644 --- a/src/lib/server/quote-pdf-service.test.ts +++ b/src/lib/server/quote-pdf-service.test.ts @@ -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>>, + 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 projected: Record = {}; - for (const f of opts.fields.split(",")) { - if (f in row) projected[f] = row[f]; + const base: Record = opts?.fields + ? (() => { + const projected: Record = {}; + 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.` as a hydrated object. + if (opts?.expand) { + const expand: Record = {}; + 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)[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( page: number, @@ -198,12 +250,35 @@ const { pbMock, clearStore } = vi.hoisted(() => { } return res.items[0]; }, - async create(data: Record) { + async create(data: Record | 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: [".pdf"]`) is + // preserved end-to-end. + if (typeof FormData !== "undefined" && data instanceof FormData) { + const merged: Record = { + 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 = { 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), + created: (data as Record).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(); }); });