Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions lib/provisioning-mint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,39 @@ export async function mintForProposal(input: MintForProposalInput): Promise<Mint
return { note: `creating the Stripe connected account failed — ${detail}` };
}
}

/**
* Fold a mint result into the provision input the registry entry and the PR body are
* both built from.
*
* EXTRACTED so it can be tested. It used to be an inline spread inside
* `openProvisioningPr`, which is unreachable without a `PROVISION_GITHUB_TOKEN` — so the
* one place that decides WHICH of the mint's three outputs lands in WHICH field had no
* test, while everything on either side of it (mintForProposal, buildTenantRegistryEntry)
* was covered. A swap here 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.
*
* The conditional spreads are deliberate and not cosmetic. `buildTenantRegistryEntry`
* distinguishes an ABSENT key from one present-and-undefined, so `{ stripeAccount:
* undefined }` is not the same input as `{}` — the first can emit an empty
* `stripe_account:` line into the registry, which `provision-tenant.sh` would then read
* as a configured-but-blank account.
*/
export function applyMintToProvisionInput<T extends object>(
base: T,
mint: MintForProposalResult,
): T & Partial<Pick<MintFields, "stripeAccount" | "paymentsLinkUrl" | "stripeAccountNote">> {
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;
};
17 changes: 9 additions & 8 deletions lib/provisioning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
58 changes: 58 additions & 0 deletions tests/unit/provisioning-mint-glue.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading