//go:build billing && testmode package billing import ( "net/http" "strings" "testing" "time" "github.com/bluesky-social/indigo/atproto/atcrypto" ) // TestHandleSubscriptionChange_HoldFanoutFailureIsRetryable closes the loop the // other tests in this file only cover one half of. // // The tier is resolved, the customer is known, and the only thing that fails is // the push to the managed hold. That has to reach Stripe as a 5xx and leave // stripe_processed_events empty: a hold that is briefly down otherwise costs // the customer their tier permanently, which is the same shape of loss as the // customer-lookup hole above, one layer further out. func TestHandleSubscriptionChange_HoldFanoutFailureIsRetryable(t *testing.T) { const secret = "whsec_fanout_test" m, database := newTestManager(t, secret) // The customer resolves cleanly — this test is about what happens after. stripeAPIReturning(t, http.StatusOK, `{"id":"cus_fanout","object":"customer","metadata":{"user_did":"did:plc:fanoutuser"}}`) _, holdDID := managedHoldServer(t, func(w http.ResponseWriter, r *http.Request) { http.Error(w, "hold is down", http.StatusServiceUnavailable) }) m.managedHolds = []string{holdDID} priv, err := atcrypto.GeneratePrivateKeyP256() if err != nil { t.Fatalf("generate key: %v", err) } m.privateKey = priv err = postWebhook(t, m, secret, signedSubscriptionEvent(t, secret, "evt_fanout_fail", "cus_fanout", time.Now().Unix())) if err == nil { t.Fatal("a hold that cannot be updated must fail the webhook so Stripe redelivers") } if !strings.Contains(err.Error(), "push tier to managed holds") { t.Errorf("error does not identify the fan-out as the cause: %v", err) } if n := processedCount(t, database); n != 0 { t.Errorf("stripe_processed_events holds %d rows; a failed event must stay redeliverable", n) } }