Files
at-container-registry/test/stripe-integration/manager_test.go
T
Evan JarrettandClaude Opus 5 2d30f6abb7 test/stripe-integration: give the suite a real database
The suite built its Manager with a nil database, which switches off every
`m.db != nil` branch in HandleWebhook: the idempotency check, the per-customer
ordering guard, and the processed-event record. It ran against real Stripe and
exercised none of the code those guards live in, so it read as far broader
coverage than it was.

It now opens a libsql file under t.TempDir (":memory:" is per-connection, so
the pool's second connection would see no tables), and two new tests cover the
branches that were dead: a redelivery is skipped, and a stale out-of-order
event is ignored. Both assert that the second delivery did not reach a handler
rather than counting rows -- RecordStripeEvent is an idempotent upsert, so
deleting either guard leaves the table identical. Both were mutation-verified
against the guard they cover.

Two fixes fall out of turning the database on:

buildEventPayload never set `created`, which unmarshals as 0. Harmless with no
database; with one, the ordering guard reads every later event for a customer
as older than what it already applied, so the second event silently becomes a
no-op. It is stamped now, with buildEventPayloadAt for the ordering test.

TestHandleWebhookAllSubscribedEvents was failing on this branch and nothing
caught it, because stripe-integration-test is not part of `make test`. It
posted events for the literal "cus_fake" and expected "No user DID found" --
which was true only while a failed customer.Get collapsed to an empty DID.
Since that became a retryable error, the fixture reached the error branch
instead. It now uses a real sandbox customer carrying no user_did, so the
assertion tests the branch it names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VwxF2N3HuZ8xSkx6nkirgB
2026-08-25 16:34:26 -05:00

740 lines
28 KiB
Go

//go:build billing && stripe_integration
package stripeintegration
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
stripe "github.com/stripe/stripe-go/v84"
"github.com/stripe/stripe-go/v84/customer"
"github.com/stripe/stripe-go/v84/webhook"
appdb "atcr.io/pkg/appview/db"
"atcr.io/pkg/billing"
)
// newManager builds a billing.Manager bound to the sandbox account.
// managedHolds is intentionally empty: the webhook-driven
// UpdateCrewTierOnAllHolds call becomes a no-op, which is what we want for
// hermetic Manager-level tests. The hold-side wiring is exercised separately
// by the harness E2E test in this same package.
func newManager(t *testing.T, env stripeEnv) *billing.Manager {
t.Helper()
m, _ := newManagerWithDB(t, env)
return m
}
// newManagerWithDB builds the same Manager and hands back its database.
//
// The database is the point. This suite used to pass nil, which switched off
// every `m.db != nil` branch in HandleWebhook — the idempotency check, the
// per-customer ordering guard, and the processed-event record. It ran against
// real Stripe and exercised none of the code those guards live in, so it read
// as much broader coverage than it was.
//
// A file under t.TempDir rather than ":memory:": libsql scopes an in-memory
// database to a single connection, so the pool's second connection sees no
// tables.
func newManagerWithDB(t *testing.T, env stripeEnv) (*billing.Manager, *sql.DB) {
t.Helper()
database, err := appdb.InitDB(filepath.Join(t.TempDir(), "ui.db"), appdb.LibsqlConfig{})
if err != nil {
t.Fatalf("init db: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
cfg := &billing.Config{
StripeSecretKey: env.SecretKey,
WebhookSecret: env.WebhookSecret,
Currency: "usd",
SuccessURL: "{base_url}/billing/success",
CancelURL: "{base_url}/billing/cancel",
Tiers: []billing.BillingTierConfig{
{Name: "free", Description: "Free tier", MaxWebhooks: 1},
{
Name: env.TierName,
Description: "Paid tier",
StripePriceMonthly: env.PriceMonthly,
StripePriceYearly: env.PriceYearly,
MaxWebhooks: 10,
WebhookAllTriggers: true,
SupporterBadge: true,
},
},
}
return billing.New(cfg, nil, "did:web:test-appview.local", nil, "http://test-appview.local", database), database
}
func TestManagerEnabled(t *testing.T) {
env := requireStripeEnv(t)
m := newManager(t, env)
if !m.Enabled() {
t.Fatalf("manager should be Enabled() with sandbox config + billing build tag")
}
}
// TestGetSubscriptionInfoNoCustomer covers the cold-cache path: a DID with
// no Stripe customer should resolve to the lowest-rank tier and still come
// back populated with live monthly/yearly prices fetched from Stripe.
func TestGetSubscriptionInfoNoCustomer(t *testing.T) {
env := requireStripeEnv(t)
m := newManager(t, env)
info, err := m.GetSubscriptionInfo(uniqueDID(t))
if err != nil {
t.Fatalf("GetSubscriptionInfo: %v", err)
}
if info.CurrentTier != "free" {
t.Errorf("CurrentTier = %q, want %q", info.CurrentTier, "free")
}
if info.TierRank != 0 {
t.Errorf("TierRank = %d, want 0", info.TierRank)
}
if len(info.Tiers) != 2 {
t.Fatalf("expected 2 tiers, got %d", len(info.Tiers))
}
if info.Tiers[1].PriceCentsMonthly <= 0 {
t.Errorf("paid tier monthly price not populated from Stripe: %+v", info.Tiers[1])
}
if info.Tiers[1].PriceCentsYearly <= 0 {
t.Errorf("paid tier yearly price not populated from Stripe: %+v", info.Tiers[1])
}
}
// TestCreateCheckoutSessionMonthly exercises the full checkout-session flow:
// customer creation as a side effect, line-item construction, and the URL
// shape Stripe returns. The created customer is cleaned up via metadata
// search at teardown.
func TestCreateCheckoutSessionMonthly(t *testing.T) {
env := requireStripeEnv(t)
m := newManager(t, env)
userDID := uniqueDID(t)
cleanupCustomerByDID(t, userDID)
req := httptest.NewRequest(http.MethodPost, "/billing/checkout", nil)
resp, err := m.CreateCheckoutSession(req, userDID, "stripe-test-handle", &billing.CheckoutSessionRequest{
Tier: env.TierName,
Interval: "monthly",
})
if err != nil {
t.Fatalf("CreateCheckoutSession: %v", err)
}
if !strings.HasPrefix(resp.CheckoutURL, "https://checkout.stripe.com/") {
t.Errorf("CheckoutURL = %q, want a checkout.stripe.com URL", resp.CheckoutURL)
}
if resp.SessionID == "" {
t.Error("SessionID empty")
}
}
// TestCreateCheckoutSessionYearly confirms the interval flag actually selects
// the yearly price when present (the manager falls back to monthly otherwise,
// which we don't want to silently mask).
func TestCreateCheckoutSessionYearly(t *testing.T) {
env := requireStripeEnv(t)
m := newManager(t, env)
userDID := uniqueDID(t)
cleanupCustomerByDID(t, userDID)
req := httptest.NewRequest(http.MethodPost, "/billing/checkout", nil)
resp, err := m.CreateCheckoutSession(req, userDID, "stripe-test", &billing.CheckoutSessionRequest{
Tier: env.TierName,
Interval: "yearly",
})
if err != nil {
t.Fatalf("CreateCheckoutSession(yearly): %v", err)
}
if !strings.HasPrefix(resp.CheckoutURL, "https://checkout.stripe.com/") {
t.Errorf("CheckoutURL = %q, want a checkout.stripe.com URL", resp.CheckoutURL)
}
}
func TestCreateCheckoutSessionUnknownTier(t *testing.T) {
env := requireStripeEnv(t)
m := newManager(t, env)
req := httptest.NewRequest(http.MethodPost, "/billing/checkout", nil)
_, err := m.CreateCheckoutSession(req, uniqueDID(t), "h", &billing.CheckoutSessionRequest{
Tier: "tier-does-not-exist",
})
if err == nil {
t.Fatal("expected error for unknown tier, got nil")
}
if !strings.Contains(err.Error(), "unknown tier") {
t.Errorf("error = %v, want one containing 'unknown tier'", err)
}
}
// TestGetBillingPortalURL exercises the billing portal session creation.
// The portal lookup goes through Stripe's customer search API, which is
// eventually consistent (typically ~minutes), so a freshly created customer
// often isn't findable yet. We poll up to the documented worst-case and
// surface the lag as a t.Skip rather than a failure — it isn't a bug in the
// code under test, just a property of the sandbox API.
//
// To make this test reliably pass in a tight loop, set
// STRIPE_TEST_EXISTING_CUSTOMER_DID to a DID whose customer already lives in
// the search index (e.g. one created by a prior run).
func TestGetBillingPortalURL(t *testing.T) {
env := requireStripeEnv(t)
m := newManager(t, env)
userDID := getenvDefault("STRIPE_TEST_EXISTING_CUSTOMER_DID", "")
if userDID == "" {
userDID = uniqueDID(t)
cleanupCustomerByDID(t, userDID)
// Create a customer so the portal call has something to look up.
// CreateCheckoutSession is the simplest public path that creates a
// customer; we discard the URL it returns.
req := httptest.NewRequest(http.MethodPost, "/billing/checkout", nil)
if _, err := m.CreateCheckoutSession(req, userDID, "portal-test", &billing.CheckoutSessionRequest{
Tier: env.TierName,
Interval: "monthly",
}); err != nil {
t.Fatalf("seed customer via CreateCheckoutSession: %v", err)
}
}
deadline := time.Now().Add(90 * time.Second)
var lastErr error
for time.Now().Before(deadline) {
resp, err := m.GetBillingPortalURL(userDID, "https://test-appview.local/settings/billing")
if err == nil {
if !strings.HasPrefix(resp.PortalURL, "https://billing.stripe.com/") {
t.Errorf("PortalURL = %q, want https://billing.stripe.com/...", resp.PortalURL)
}
return
}
lastErr = err
if strings.Contains(err.Error(), "configuration") || strings.Contains(err.Error(), "No configuration") {
t.Skipf("Stripe billing portal is not configured for the sandbox account. "+
"Set it up at https://dashboard.stripe.com/test/settings/billing/portal then re-run. (raw: %v)", err)
}
if !strings.Contains(err.Error(), "no billing account found") {
t.Fatalf("GetBillingPortalURL: unexpected error %v", err)
}
time.Sleep(3 * time.Second)
}
t.Skipf("Stripe customer search index did not surface the new customer within 90s "+
"(last error: %v). This is an eventual-consistency property of the search API, not a "+
"code-under-test bug. Set STRIPE_TEST_EXISTING_CUSTOMER_DID to a pre-existing DID to "+
"skip this wait.", lastErr)
}
// TestHandleWebhookValidSignature confirms the happy path: a payload signed
// with the configured webhook secret is accepted, parsed, and dispatched to
// the right branch (checkout.session.completed is logged-only today).
func TestHandleWebhookValidSignature(t *testing.T) {
env := requireStripeEnv(t)
m := newManager(t, env)
// handleCheckoutCompleted only logs, and never resolves the customer, so
// an ID that does not exist in the sandbox is fine here.
payload := buildEventPayload("evt_test_checkout", "checkout.session.completed", map[string]any{
"id": "cs_test_x",
"object": "checkout.session",
"customer": "cus_fake",
"subscription": "sub_fake",
})
req := signedWebhookRequest(t, payload, env.WebhookSecret, time.Now())
if err := m.HandleWebhook(req); err != nil {
t.Fatalf("HandleWebhook(valid signature): %v", err)
}
}
func TestHandleWebhookInvalidSignature(t *testing.T) {
env := requireStripeEnv(t)
m := newManager(t, env)
payload := buildEventPayload("evt_test", "checkout.session.completed", map[string]any{})
// Sign with a deliberately wrong secret so ConstructEvent rejects it.
req := signedWebhookRequest(t, payload, "whsec_wrong_secret_for_test", time.Now())
err := m.HandleWebhook(req)
if err == nil {
t.Fatal("expected signature verification to fail, got nil")
}
if !strings.Contains(err.Error(), "signature") {
t.Errorf("error = %v, want one mentioning 'signature'", err)
}
}
func TestHandleWebhookStaleTimestamp(t *testing.T) {
env := requireStripeEnv(t)
m := newManager(t, env)
payload := buildEventPayload("evt_test", "checkout.session.completed", map[string]any{})
// 10 minutes in the past — beyond Stripe's default 5-minute tolerance.
req := signedWebhookRequest(t, payload, env.WebhookSecret, time.Now().Add(-10*time.Minute))
err := m.HandleWebhook(req)
if err == nil {
t.Fatal("expected stale-timestamp rejection, got nil")
}
if !errors.Is(err, webhook.ErrTooOld) && !strings.Contains(err.Error(), "signature") {
t.Errorf("error = %v, want timestamp/signature failure", err)
}
}
// TestHandleWebhookSubscriptionCreated drives the subscription lifecycle
// branch with a hand-built payload. We can't easily create a real Stripe
// subscription in the sandbox without a payment method on the customer, so
// we fabricate an event whose shape matches what Stripe sends. The handler
// looks up the user_did from the customer metadata, so a real customer must
// exist behind the cus_... ID in the payload.
func TestHandleWebhookSubscriptionCreated(t *testing.T) {
env := requireStripeEnv(t)
// Set the global Stripe key so direct customer.New calls below
// authenticate. billing.New sets stripe.Key as a side effect, but
// belt-and-suspenders.
stripe.Key = env.SecretKey
m := newManager(t, env)
userDID := uniqueDID(t)
// Create the Stripe customer directly so we have its ID up front. The
// webhook handler will call getCustomerDID(custID), which hits Stripe's
// customer.Get (not search) — that's strongly consistent, so no lag
// concern here.
cust, err := customer.New(&stripe.CustomerParams{
Params: stripe.Params{Metadata: map[string]string{"user_did": userDID}},
})
if err != nil {
t.Fatalf("create customer: %v", err)
}
t.Cleanup(func() {
if _, err := customer.Del(cust.ID, nil); err != nil {
t.Logf("cleanup: delete customer %s: %v", cust.ID, err)
}
})
payload := buildEventPayload("evt_test_sub_created", "customer.subscription.created", map[string]any{
"id": "sub_test_fake_" + cust.ID,
"object": "subscription",
"status": "active",
"customer": cust.ID,
"items": map[string]any{
"object": "list",
"data": []map[string]any{{
"id": "si_test_fake",
"object": "subscription_item",
"price": map[string]any{
"id": env.PriceMonthly,
"object": "price",
"recurring": map[string]any{
"interval": "month",
},
},
}},
},
})
whReq := signedWebhookRequest(t, payload, env.WebhookSecret, time.Now())
if err := m.HandleWebhook(whReq); err != nil {
t.Fatalf("HandleWebhook(subscription.created): %v", err)
}
// With managedHolds=[], the dispatched UpdateCrewTierOnAllHolds goroutine
// is a no-op. We've already verified the parse+dispatch path succeeded
// (no error returned and no signature failure). The hold-side push is
// covered by TestWebhookEndpoint in webhook_test.go which boots a real
// appview + harness.
}
// TestHandleWebhookAllSubscribedEvents iterates billing.SubscribedEvents and
// confirms each one is dispatched to a real handler (not silently dropped to
// the default branch). For each event type we send a minimally-shaped but
// validly-signed payload through HandleWebhook and check that the captured
// slog output contains the handler's distinctive log line — and does NOT
// contain "Ignoring Stripe event", which would mean a switch case was
// removed without updating the SubscribedEvents list.
//
// Most events use a fake customer ID; the subscription.* handlers short-
// circuit on the resulting empty user_did and emit "No user DID found"
// rather than running through to the tier-push branch, which is fine for
// this test — we're verifying dispatch, not downstream side effects. The
// revert-to-free branch is covered separately by
// TestHandleWebhookSubscriptionDeleted.
func TestHandleWebhookAllSubscribedEvents(t *testing.T) {
env := requireStripeEnv(t)
stripe.Key = env.SecretKey
m := newManager(t, env)
cap := newLogCapture(t)
// A customer that resolves but carries no user_did. The subscription
// handlers are then expected to stop at "No user DID found", which is a
// conclusion rather than a failure. This used to be the literal string
// "cus_fake"; once a failed customer.Get became a retryable error rather
// than an empty DID, that reached the error branch instead and the whole
// dispatch assertion stopped holding.
customerID := newBareCustomer(t)
created := time.Now().Unix()
// Distinctive substring each handler emits. If a switch case is dropped,
// the event hits the default branch ("Ignoring Stripe event") and these
// substrings won't appear in the captured output.
expectedLog := map[string]string{
billing.EventCheckoutSessionCompleted: "Checkout completed",
billing.EventSubscriptionCreated: "No user DID found",
billing.EventSubscriptionUpdated: "No user DID found",
billing.EventSubscriptionDeleted: "No user DID found",
billing.EventSubscriptionPaused: "No user DID found",
billing.EventSubscriptionResumed: "No user DID found",
billing.EventInvoicePaymentFailed: "Stripe invoice payment failed",
billing.EventChargeDisputeCreated: "Stripe chargeback opened",
}
for _, eventType := range billing.SubscribedEvents {
t.Run(eventType, func(t *testing.T) {
cap.reset()
// Distinct, increasing created values: these subtests share one
// customer, and equal-or-older timestamps would let the ordering
// guard drop later events before they reach their handler.
created++
payload := buildEventPayloadAt("evt_test_"+eventType, eventType, created,
payloadFor(eventType, env.PriceMonthly, customerID))
req := signedWebhookRequest(t, payload, env.WebhookSecret, time.Now())
if err := m.HandleWebhook(req); err != nil {
t.Fatalf("HandleWebhook(%s): %v", eventType, err)
}
wantSubstr, ok := expectedLog[eventType]
if !ok {
t.Fatalf("test bug: no expected log substring registered for %q. "+
"Add it to expectedLog when adding a new SubscribedEvents entry.", eventType)
}
if !cap.contains(wantSubstr) {
t.Errorf("event %q did not produce expected log line %q.\nCaptured output:\n%s",
eventType, wantSubstr, cap.String())
}
if cap.contains("Ignoring Stripe event") {
t.Errorf("event %q hit the default switch branch — it is in SubscribedEvents "+
"but HandleWebhook has no case for it.\nCaptured output:\n%s",
eventType, cap.String())
}
})
}
}
// TestHandleWebhookSubscriptionDeleted covers the revert-to-free branch
// inside handleSubscriptionChange that the .created test doesn't reach: when
// Stripe sends a canceled subscription, the user must drop to tier rank 0.
// We seed a real Stripe customer (so getCustomerDID returns non-empty and
// the handler proceeds past its early return) and verify the tier-update log
// reports tierName=free / tierRank=0.
func TestHandleWebhookSubscriptionDeleted(t *testing.T) {
env := requireStripeEnv(t)
stripe.Key = env.SecretKey
m := newManager(t, env)
cap := newLogCapture(t)
userDID := uniqueDID(t)
cust, err := customer.New(&stripe.CustomerParams{
Params: stripe.Params{Metadata: map[string]string{"user_did": userDID}},
})
if err != nil {
t.Fatalf("create customer: %v", err)
}
t.Cleanup(func() {
if _, err := customer.Del(cust.ID, nil); err != nil {
t.Logf("cleanup: delete customer %s: %v", cust.ID, err)
}
})
payload := buildEventPayload("evt_test_sub_deleted", billing.EventSubscriptionDeleted, map[string]any{
"id": "sub_test_deleted_" + cust.ID,
"object": "subscription",
"status": "canceled",
"customer": cust.ID,
// items is intentionally absent: the canceled branch resolves the
// tier from rank 0 directly and never reads items[0].price.id.
})
req := signedWebhookRequest(t, payload, env.WebhookSecret, time.Now())
if err := m.HandleWebhook(req); err != nil {
t.Fatalf("HandleWebhook(subscription.deleted): %v", err)
}
if !cap.contains("Pushing tier update to managed holds") {
t.Fatalf("expected tier-update log line, got:\n%s", cap.String())
}
if !cap.contains(`tierName=free`) {
t.Errorf("expected tierName=free in tier-update log, got:\n%s", cap.String())
}
if !cap.contains(`tierRank=0`) {
t.Errorf("expected tierRank=0 in tier-update log, got:\n%s", cap.String())
}
}
// TestTierResolutionByPriceID is a config-only roundtrip: it makes sure the
// price IDs we feed in actually map back to the configured tier name. A
// mis-pasted env value here makes the rest of the suite mysteriously fail,
// so failing fast on the mapping itself produces better error messages.
func TestTierResolutionByPriceID(t *testing.T) {
env := requireStripeEnv(t)
// Cfg goes through the Manager so the test exercises the same code path
// production does (Manager.New → tier slice → GetTierByPriceID).
cfg := &billing.Config{
Tiers: []billing.BillingTierConfig{
{Name: "free"},
{
Name: env.TierName,
StripePriceMonthly: env.PriceMonthly,
StripePriceYearly: env.PriceYearly,
},
},
}
name, rank := cfg.GetTierByPriceID(env.PriceMonthly)
if name != env.TierName || rank != 1 {
t.Errorf("GetTierByPriceID(monthly) = (%q, %d), want (%q, 1)", name, rank, env.TierName)
}
name, rank = cfg.GetTierByPriceID(env.PriceYearly)
if name != env.TierName || rank != 1 {
t.Errorf("GetTierByPriceID(yearly) = (%q, %d), want (%q, 1)", name, rank, env.TierName)
}
if name, rank := cfg.GetTierByPriceID("price_unknown_xxx"); name != "" || rank != -1 {
t.Errorf("GetTierByPriceID(unknown) = (%q, %d), want (\"\", -1)", name, rank)
}
}
// TestHandleWebhook_RedeliveryIsSkipped covers the idempotency guard against
// the real Stripe backend and a real database — the pair this suite could not
// reach while its Manager was built with nil.
//
// The assertion that carries the weight is the second one: that redelivery did
// not reach a handler. A row count cannot show this, because RecordStripeEvent
// is an idempotent upsert and deleting the guard entirely leaves the table
// looking exactly the same.
func TestHandleWebhook_RedeliveryIsSkipped(t *testing.T) {
env := requireStripeEnv(t)
stripe.Key = env.SecretKey
m, database := newManagerWithDB(t, env)
cap := newLogCapture(t)
customerID := newBareCustomer(t)
payload := buildEventPayloadAt("evt_test_redelivery", billing.EventSubscriptionUpdated,
time.Now().Unix(), payloadFor(billing.EventSubscriptionUpdated, env.PriceMonthly, customerID))
req := signedWebhookRequest(t, payload, env.WebhookSecret, time.Now())
if err := m.HandleWebhook(req); err != nil {
t.Fatalf("first delivery: %v", err)
}
if !cap.contains("No user DID found") {
t.Fatalf("first delivery did not reach the subscription handler:\n%s", cap.String())
}
cap.reset()
req = signedWebhookRequest(t, payload, env.WebhookSecret, time.Now())
if err := m.HandleWebhook(req); err != nil {
t.Fatalf("redelivery: %v", err)
}
if !cap.contains("Ignoring already-processed Stripe event") {
t.Errorf("redelivery was not skipped:\n%s", cap.String())
}
if cap.contains("No user DID found") {
t.Errorf("redelivery reached the subscription handler again:\n%s", cap.String())
}
if n := processedEventCount(t, database, "evt_test_redelivery"); n != 1 {
t.Errorf("stripe_processed_events holds %d rows for the event, want 1", n)
}
}
// TestHandleWebhook_StaleEventIsIgnored covers the per-customer ordering guard.
//
// Stripe does not promise delivery order, so a stale "active" arriving after a
// cancellation could otherwise re-grant a tier the customer no longer holds.
// The two events here are identical but for created, so a tier push on the
// second one can only mean the guard did not fire.
func TestHandleWebhook_StaleEventIsIgnored(t *testing.T) {
env := requireStripeEnv(t)
stripe.Key = env.SecretKey
m := newManager(t, env)
cap := newLogCapture(t)
// A user_did here, unlike the redelivery test: it carries the handler past
// the DID lookup to the tier push, which is the loudest available signal
// for "the guard let this through".
userDID := uniqueDID(t)
cust, err := customer.New(&stripe.CustomerParams{
Params: stripe.Params{Metadata: map[string]string{"user_did": userDID}},
})
if err != nil {
t.Fatalf("create customer: %v", err)
}
t.Cleanup(func() {
if _, err := customer.Del(cust.ID, nil); err != nil {
t.Logf("cleanup: delete customer %s: %v", cust.ID, err)
}
})
now := time.Now().Unix()
data := payloadFor(billing.EventSubscriptionUpdated, env.PriceMonthly, cust.ID)
req := signedWebhookRequest(t,
buildEventPayloadAt("evt_test_order_fresh", billing.EventSubscriptionUpdated, now, data),
env.WebhookSecret, time.Now())
if err := m.HandleWebhook(req); err != nil {
t.Fatalf("fresh event: %v", err)
}
if !cap.contains("Pushing tier update to managed holds") {
t.Fatalf("fresh event did not apply a tier:\n%s", cap.String())
}
cap.reset()
req = signedWebhookRequest(t,
buildEventPayloadAt("evt_test_order_stale", billing.EventSubscriptionUpdated, now-3600, data),
env.WebhookSecret, time.Now())
if err := m.HandleWebhook(req); err != nil {
t.Fatalf("stale event: %v", err)
}
if !cap.contains("Ignoring stale out-of-order subscription event") {
t.Errorf("stale event was not recognised as out of order:\n%s", cap.String())
}
if cap.contains("Pushing tier update to managed holds") {
t.Errorf("stale event applied a tier:\n%s", cap.String())
}
}
// --- helpers ---------------------------------------------------------------
// buildEventPayload assembles a Stripe Event envelope with api_version set
// to the version stripe-go expects, so ConstructEvent doesn't reject it for
// version mismatch. The handler reads event.Type, event.Data.Raw, and not
// much else, so the surrounding fields are minimal.
func buildEventPayload(id, eventType string, dataObject map[string]any) []byte {
return buildEventPayloadAt(id, eventType, time.Now().Unix(), dataObject)
}
// buildEventPayloadAt is buildEventPayload with an explicit created timestamp,
// for the per-customer ordering guard — the only way to build an event that is
// unseen and yet older than one already applied.
//
// created must be set even when a test does not care about ordering. Left
// absent it unmarshals as 0, and the guard reads any later event for the same
// customer as older than what it has already applied, so the second event for
// a customer silently becomes a no-op.
func buildEventPayloadAt(id, eventType string, created int64, dataObject map[string]any) []byte {
envelope := map[string]any{
"id": id,
"object": "event",
"api_version": stripe.APIVersion,
"created": created,
"type": eventType,
"data": map[string]any{"object": dataObject},
}
b, err := json.Marshal(envelope)
if err != nil {
// Marshal of a known-good map literal can't fail in practice.
panic("buildEventPayload: " + err.Error())
}
return b
}
// payloadFor returns a minimal but well-shaped data.object for the given
// event type — just enough that the handler's json.Unmarshal succeeds and
// the function runs to its first observable log line. priceID is the price
// ID stamped into subscription payloads (unused for non-subscription events),
// and customerID must name a customer that really exists in the sandbox.
func payloadFor(eventType, priceID, customerID string) map[string]any {
switch eventType {
case "checkout.session.completed":
return map[string]any{
"id": "cs_test_x",
"object": "checkout.session",
"customer": customerID,
"subscription": "sub_fake",
}
case "customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
"customer.subscription.paused",
"customer.subscription.resumed":
return map[string]any{
"id": "sub_test_fake",
"object": "subscription",
"status": "active",
"customer": customerID,
"items": map[string]any{
"object": "list",
"data": []map[string]any{{
"id": "si_test_fake",
"object": "subscription_item",
"price": map[string]any{
"id": priceID,
"object": "price",
"recurring": map[string]any{"interval": "month"},
},
}},
},
}
case "invoice.payment_failed":
return map[string]any{
"id": "in_test_fake",
"object": "invoice",
"customer": customerID,
"amount_due": 100,
"currency": "usd",
"attempt_count": 1,
}
case "charge.dispute.created":
return map[string]any{
"id": "dp_test_fake",
"object": "dispute",
"amount": 100,
"currency": "usd",
"reason": "fraudulent",
"status": "warning_needs_response",
// charge is required to be present, even minimally, so the
// handler's dispute.Charge.Customer dereference doesn't NPE.
"charge": map[string]any{
"id": "ch_test_fake",
"object": "charge",
"customer": customerID,
},
}
}
// Unknown event type — return an empty object. The test will then notice
// the missing expectedLog entry and fail with a helpful message.
return map[string]any{}
}
// signedWebhookRequest builds an http.Request with a Stripe-signed body.
// Tests use it for both the happy path (correct secret + recent timestamp)
// and the rejection paths (wrong secret or stale timestamp).
func signedWebhookRequest(t *testing.T, payload []byte, secret string, ts time.Time) *http.Request {
t.Helper()
signed := webhook.GenerateTestSignedPayload(&webhook.UnsignedPayload{
Payload: payload,
Secret: secret,
Timestamp: ts,
})
req := httptest.NewRequest(http.MethodPost, "/api/stripe/webhook", strings.NewReader(string(signed.Payload)))
req.Header.Set("Stripe-Signature", signed.Header)
req.Header.Set("Content-Type", "application/json")
return req
}
// processedEventCount reports how many rows stripe_processed_events holds for
// one event ID.
func processedEventCount(t *testing.T, database *sql.DB, eventID string) int {
t.Helper()
var n int
if err := database.QueryRow(
"SELECT COUNT(*) FROM stripe_processed_events WHERE event_id = ?", eventID,
).Scan(&n); err != nil {
t.Fatalf("count processed events: %v", err)
}
return n
}