appview/holdclient: cover the tier fan-out itself, and its failure path

The existing tests covered updateCrewTierWithRetry and UpdateCrewTierOnHold.
UpdateCrewTierOnAllHolds -- the function the Stripe webhook actually calls, and
whose error decides whether a paid upgrade is retried or dropped -- had none.

Three cases: the joined error names every failing hold and not the one that
succeeded; a hold that accepts and never answers does not starve the holds
after it (mutation-verified by making the fan-out serial, which leaves the
healthy hold contacted zero times); and a context deadline aborts the retry
loop rather than running to tierUpdateMaxAttempts.

That last one records a real mismatch rather than an intent. Three attempts at
a 5s client timeout need ~15s, and the webhook allows the whole fan-out 10s, so
under a hang the budget funds two attempts and never three -- confirmed against
a blackholed hold on the dev stack, which failed at exactly 10.0s with a bare
context error rather than the "after N attempts" wrapper. If either constant or
the deadline moves, that test is where the arithmetic gets re-checked.

Also covers the other half in pkg/billing: a fan-out failure 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 -- the same shape of
loss as the customer-lookup hole, one layer further out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VwxF2N3HuZ8xSkx6nkirgB
This commit is contained in:
Evan Jarrett
2026-08-25 16:34:26 -05:00
co-authored by Claude Opus 5
parent 4dd473bbf1
commit fa6473a896
2 changed files with 195 additions and 0 deletions
+47
View File
@@ -15,6 +15,8 @@ import (
"time"
appdb "atcr.io/pkg/appview/db"
"atcr.io/pkg/atproto"
"github.com/bluesky-social/indigo/atproto/atcrypto"
"github.com/stripe/stripe-go/v84"
"github.com/stripe/stripe-go/v84/webhook"
)
@@ -285,3 +287,48 @@ func TestHandleSubscriptionChange_NilCustomerDoesNotPanic(t *testing.T) {
t.Errorf("HandleWebhook error = %v, want nil for an event with no customer", err)
}
}
// 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) {
atproto.SetTestMode(true)
t.Cleanup(func() { atproto.SetTestMode(false) })
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"}}`)
hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "hold is down", http.StatusServiceUnavailable)
}))
defer hold.Close()
m.managedHolds = []string{"did:web:" + strings.ReplaceAll(
strings.TrimPrefix(hold.URL, "http://"), ":", "%3A")}
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)
}
}