mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-27 20:54:20 +00:00
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:
co-authored by
Claude Opus 5
parent
4dd473bbf1
commit
fa6473a896
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user