diff --git a/lib/provisioning-mint.ts b/lib/provisioning-mint.ts index 3546e02..e5bd965 100644 --- a/lib/provisioning-mint.ts +++ b/lib/provisioning-mint.ts @@ -110,3 +110,39 @@ export async function mintForProposal(input: MintForProposalInput): Promise( + base: T, + mint: MintForProposalResult, +): T & Partial> { + return { + ...base, + ...(mint.stripeAccount ? { stripeAccount: mint.stripeAccount } : {}), + ...(mint.paymentsLinkUrl ? { paymentsLinkUrl: mint.paymentsLinkUrl } : {}), + ...(mint.note ? { stripeAccountNote: mint.note } : {}), + }; +} + +/** The three fields a mint can contribute, named once. */ +type MintFields = { + stripeAccount: string; + paymentsLinkUrl: string; + stripeAccountNote: string; +}; diff --git a/lib/provisioning.ts b/lib/provisioning.ts index 6de0486..f42d467 100644 --- a/lib/provisioning.ts +++ b/lib/provisioning.ts @@ -8,7 +8,7 @@ import { buildProvisioningPrBody } from "@/lib/provisioning-pr-body"; import { tenantPartnerBrand } from "@/lib/partner-brand-lookup"; -import { mintForProposal } from "@/lib/provisioning-mint"; +import { applyMintToProvisionInput, mintForProposal } from "@/lib/provisioning-mint"; import { buildTenantRegistryEntry, tenantDomain, @@ -119,13 +119,14 @@ export async function openProvisioningPr( modules: input.modules, url: `https://${tenantDomain(input)}`, }); - const withAccount: TenantProvisionInput = { - ...input, - partnerBrand, - ...(mint.stripeAccount ? { stripeAccount: mint.stripeAccount } : {}), - ...(mint.paymentsLinkUrl ? { paymentsLinkUrl: mint.paymentsLinkUrl } : {}), - ...(mint.note ? { stripeAccountNote: mint.note } : {}), - }; + // The fold is `applyMintToProvisionInput` (lib/provisioning-mint.ts) rather than an + // inline spread, so the mapping from the mint's three outputs to the three fields is + // unit-testable — this function needs a GitHub token to reach, so nothing inline here + // can be covered. + const withAccount: TenantProvisionInput = applyMintToProvisionInput( + { ...input, partnerBrand }, + mint, + ); // `deferred` is returned to the caller rather than only rendered into the PR body: a // deferral means a customer is being BILLED for a module their tenant will not have diff --git a/tests/unit/provisioning-mint-glue.test.ts b/tests/unit/provisioning-mint-glue.test.ts new file mode 100644 index 0000000..4c9d066 --- /dev/null +++ b/tests/unit/provisioning-mint-glue.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { applyMintToProvisionInput } from "@/lib/provisioning-mint"; + +// The fold between the mint and the registry entry. Both SIDES were well covered — +// `mintForProposal` by tests/e2e/connect-mint.spec.ts against the real Stripe test API, and +// `buildTenantRegistryEntry` by tests/unit/provisioning-registry.test.ts — while the five lines +// that decide WHICH output lands in WHICH field sat inside `openProvisioningPr`, which cannot be +// reached without a PROVISION_GITHUB_TOKEN. That is why this was extracted. +// +// The failure it guards is silent and expensive: `stripe_account:` receiving a URL is a registry +// entry that provisions a tenant pointed at an account that does not exist, and nothing downstream +// would notice — `provision-tenant.sh` only checks the pairing, not the shape. + +const base = { slug: "bistro-nova", name: "Bistro Nova" }; + +describe("applyMintToProvisionInput", () => { + it("puts each of the mint's three outputs in its own field", () => { + const out = applyMintToProvisionInput(base, { + stripeAccount: "acct_123", + paymentsLinkUrl: "https://sofrapiwas.com/onboarding/payments/tok", + note: "a note", + }); + // Asserted field by field rather than with one toEqual: a swap is the failure being + // guarded, and toEqual on a whole object reports "not equal" without naming which pair + // moved. + expect(out.stripeAccount).toBe("acct_123"); + expect(out.paymentsLinkUrl).toBe("https://sofrapiwas.com/onboarding/payments/tok"); + expect(out.stripeAccountNote).toBe("a note"); + }); + + it("keeps the base input intact", () => { + const out = applyMintToProvisionInput(base, { stripeAccount: "acct_123" }); + expect(out.slug).toBe("bistro-nova"); + expect(out.name).toBe("Bistro Nova"); + }); + + it("OMITS a key it has no value for, rather than setting it undefined", () => { + // Not pedantry. `buildTenantRegistryEntry` distinguishes an absent key from one present + // and undefined, so `{ stripeAccount: undefined }` can emit a blank `stripe_account:` line + // that provision-tenant.sh reads as configured-but-empty. + const out = applyMintToProvisionInput(base, { note: "mint failed" }); + expect("stripeAccount" in out).toBe(false); + expect("paymentsLinkUrl" in out).toBe(false); + expect(out.stripeAccountNote).toBe("mint failed"); + }); + + it("adds nothing at all when the mint returns {} (the tenant bought no paired module)", () => { + const out = applyMintToProvisionInput(base, {}); + expect(Object.keys(out).sort()).toEqual(["name", "slug"]); + }); + + it("carries an account and a note together", () => { + // Reachable in principle: an account minted, and a note about something else. + const out = applyMintToProvisionInput(base, { stripeAccount: "acct_9", note: "heads up" }); + expect(out.stripeAccount).toBe("acct_9"); + expect(out.stripeAccountNote).toBe("heads up"); + }); +});