mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-28 05:04:15 +00:00
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
233 lines
8.0 KiB
Go
233 lines
8.0 KiB
Go
package holdclient
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"atcr.io/pkg/atproto"
|
|
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
|
)
|
|
|
|
func TestUpdateCrewTierWithRetry_SucceedsAfterTransientFailures(t *testing.T) {
|
|
priv, err := atcrypto.GeneratePrivateKeyP256()
|
|
if err != nil {
|
|
t.Fatalf("generate key: %v", err)
|
|
}
|
|
|
|
var calls atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Fail the first two attempts, succeed on the third.
|
|
if calls.Add(1) < 3 {
|
|
http.Error(w, "temporarily unavailable", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"tierName":"bosun"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
err = updateCrewTierWithRetry(context.Background(), "did:web:hold", srv.URL, "did:plc:user", 1, priv, "did:web:appview")
|
|
if err != nil {
|
|
t.Fatalf("expected success after retries, got %v", err)
|
|
}
|
|
if got := calls.Load(); got != 3 {
|
|
t.Errorf("expected 3 attempts, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestUpdateCrewTierWithRetry_FailsAfterMaxAttempts(t *testing.T) {
|
|
priv, err := atcrypto.GeneratePrivateKeyP256()
|
|
if err != nil {
|
|
t.Fatalf("generate key: %v", err)
|
|
}
|
|
|
|
var calls atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
calls.Add(1)
|
|
http.Error(w, "down", http.StatusServiceUnavailable)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
err = updateCrewTierWithRetry(context.Background(), "did:web:hold", srv.URL, "did:plc:user", 1, priv, "did:web:appview")
|
|
if err == nil {
|
|
t.Fatal("expected error after exhausting retries")
|
|
}
|
|
if got := calls.Load(); got != int32(tierUpdateMaxAttempts) {
|
|
t.Errorf("expected %d attempts, got %d", tierUpdateMaxAttempts, got)
|
|
}
|
|
}
|
|
|
|
// Ensure the URL builder matches the expected hold endpoint, guarding against
|
|
// accidental path drift (the retry test relies on hitting the test server).
|
|
func TestUpdateCrewTierOnHold_PostsToEndpoint(t *testing.T) {
|
|
priv, err := atcrypto.GeneratePrivateKeyP256()
|
|
if err != nil {
|
|
t.Fatalf("generate key: %v", err)
|
|
}
|
|
|
|
var gotPath string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
if err := UpdateCrewTierOnHold(context.Background(), "did:web:hold", srv.URL, "did:plc:user", 0, priv, "did:web:appview"); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if gotPath != atproto.HoldUpdateCrewTier {
|
|
t.Errorf("posted to %q, want %q", gotPath, atproto.HoldUpdateCrewTier)
|
|
}
|
|
}
|
|
|
|
// The tests above cover the two helpers. UpdateCrewTierOnAllHolds — the
|
|
// function the Stripe webhook actually calls, and the one whose error decides
|
|
// whether a paid upgrade is retried or dropped — had no test at all.
|
|
|
|
// didFor turns an httptest server URL into a did:web that ResolveHoldDIDToURL
|
|
// maps straight back to it. The port makes DIDWebToURL choose http, and test
|
|
// mode is what lets a did:web that no directory can resolve fall back to being
|
|
// decoded from the DID itself.
|
|
func didFor(t *testing.T, serverURL string) string {
|
|
t.Helper()
|
|
host := strings.TrimPrefix(serverURL, "http://")
|
|
return "did:web:" + strings.ReplaceAll(host, ":", "%3A")
|
|
}
|
|
|
|
func testKey(t *testing.T) *atcrypto.PrivateKeyP256 {
|
|
t.Helper()
|
|
priv, err := atcrypto.GeneratePrivateKeyP256()
|
|
if err != nil {
|
|
t.Fatalf("generate key: %v", err)
|
|
}
|
|
return priv
|
|
}
|
|
|
|
// TestUpdateCrewTierOnAllHolds_JoinedErrorNamesEveryFailingHold: the caller
|
|
// 5xxs the Stripe webhook on any non-nil return, and the operator's only
|
|
// account of which holds are behind is this error. One failing hold must not
|
|
// mask another.
|
|
func TestUpdateCrewTierOnAllHolds_JoinedErrorNamesEveryFailingHold(t *testing.T) {
|
|
atproto.SetTestMode(true)
|
|
t.Cleanup(func() { atproto.SetTestMode(false) })
|
|
|
|
ok := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer ok.Close()
|
|
bad1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, "down", http.StatusServiceUnavailable)
|
|
}))
|
|
defer bad1.Close()
|
|
bad2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, "broken", http.StatusInternalServerError)
|
|
}))
|
|
defer bad2.Close()
|
|
|
|
okDID, bad1DID, bad2DID := didFor(t, ok.URL), didFor(t, bad1.URL), didFor(t, bad2.URL)
|
|
|
|
err := UpdateCrewTierOnAllHolds(context.Background(),
|
|
[]string{okDID, bad1DID, bad2DID}, "did:plc:user", 1, testKey(t), "did:web:appview")
|
|
if err == nil {
|
|
t.Fatal("expected an error when two of three holds fail")
|
|
}
|
|
for _, did := range []string{bad1DID, bad2DID} {
|
|
if !strings.Contains(err.Error(), did) {
|
|
t.Errorf("joined error does not name failing hold %s: %v", did, err)
|
|
}
|
|
}
|
|
if strings.Contains(err.Error(), okDID) {
|
|
t.Errorf("joined error names the hold that succeeded (%s): %v", okDID, err)
|
|
}
|
|
}
|
|
|
|
// TestUpdateCrewTierOnAllHolds_SlowHoldDoesNotStarveOthers pins the concurrency
|
|
// the function's doc claims.
|
|
//
|
|
// Contacted serially, one hold that burns the whole deadline means the holds
|
|
// after it are never contacted at all — and since the webhook retries in the
|
|
// same order, a persistently slow first hold would mean later holds are never
|
|
// updated on any delivery. The assertion is that the healthy hold is reached
|
|
// even though the slow one is listed first and never answers.
|
|
func TestUpdateCrewTierOnAllHolds_SlowHoldDoesNotStarveOthers(t *testing.T) {
|
|
atproto.SetTestMode(true)
|
|
t.Cleanup(func() { atproto.SetTestMode(false) })
|
|
|
|
release := make(chan struct{})
|
|
slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
<-release
|
|
}))
|
|
defer slow.Close()
|
|
defer close(release)
|
|
|
|
var healthyHits atomic.Int32
|
|
healthy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
healthyHits.Add(1)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer healthy.Close()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
// Slow hold first: serial contact would spend the entire budget on it.
|
|
err := UpdateCrewTierOnAllHolds(ctx,
|
|
[]string{didFor(t, slow.URL), didFor(t, healthy.URL)},
|
|
"did:plc:user", 1, testKey(t), "did:web:appview")
|
|
|
|
if err == nil {
|
|
t.Error("expected an error naming the slow hold")
|
|
}
|
|
if got := healthyHits.Load(); got != 1 {
|
|
t.Errorf("healthy hold contacted %d times, want 1 — it was starved by the slow hold", got)
|
|
}
|
|
}
|
|
|
|
// TestUpdateCrewTierOnAllHolds_DeadlineCutsRetriesShort documents a real
|
|
// mismatch rather than asserting an intent.
|
|
//
|
|
// tierUpdateMaxAttempts is 3 and each attempt is bounded by a 5s client
|
|
// timeout, so three attempts against a hold that accepts and never answers
|
|
// need ~15s. The Stripe webhook allows the whole fan-out 10s. Under a hang the
|
|
// budget therefore funds two attempts, never three, and the caller gets the
|
|
// context error rather than the "after N attempts" wrapper. If the deadline or
|
|
// either constant changes, this test is where the arithmetic gets re-checked.
|
|
func TestUpdateCrewTierOnAllHolds_DeadlineCutsRetriesShort(t *testing.T) {
|
|
atproto.SetTestMode(true)
|
|
t.Cleanup(func() { atproto.SetTestMode(false) })
|
|
|
|
release := make(chan struct{})
|
|
var attempts atomic.Int32
|
|
hung := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
attempts.Add(1)
|
|
<-release
|
|
}))
|
|
defer hung.Close()
|
|
defer close(release)
|
|
|
|
// Deadline deliberately shorter than tierUpdateMaxAttempts would need.
|
|
ctx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond)
|
|
defer cancel()
|
|
|
|
start := time.Now()
|
|
err := UpdateCrewTierOnAllHolds(ctx, []string{didFor(t, hung.URL)},
|
|
"did:plc:user", 1, testKey(t), "did:web:appview")
|
|
elapsed := time.Since(start)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected an error from a hold that never answers")
|
|
}
|
|
if elapsed > 3*time.Second {
|
|
t.Errorf("fan-out took %v; the context deadline did not abort the retry loop", elapsed)
|
|
}
|
|
if got := attempts.Load(); got >= int32(tierUpdateMaxAttempts) {
|
|
t.Errorf("hung hold was attempted %d times under a deadline that cannot fund %d",
|
|
got, tierUpdateMaxAttempts)
|
|
}
|
|
}
|