Files
at-container-registry/pkg/appview/holdclient/tier_update_testmode_test.go
T
Evan JarrettandClaude Fable 5.1 0080957a21 remove the runtime test_mode switch; the testmode build tag is the only one
server.test_mode survived the build-tag refactor only to feed five
behavioral branches: the registry's fall-back to the default hold when
the user's hold is unreachable, backfill warning suppression for
external holds, the appview listener close on shutdown, the hold's
relay-crawl skip, and the hold's appview-issuer tolerance. Every one of
them is a "this is a local development build" decision, which is what
the tag already says, and local development has to build with the tag
or nothing resolves. So they read atproto.TestModeBuild now, and the
flag, SetTestMode, IsTestMode, the middleware option, the backfill
constructor parameter, the never-read field on RemoteHoldAuthorizer,
the example and template YAML lines, and the docker-compose env vars
are gone. The registry keeps the fallback as a field seeded from the
constant so the production-path tests can pin it off under the tag.

The 24 SetTestMode calls in tests were dead already: stripping them and
running the affected packages tagged changed nothing.

Tests that resolve a loopback did:web used to t.Fatal naming the tag,
which left a bare `go test ./...` permanently red in five packages.
They now live under `//go:build testmode`: whole-file constraints where
every test needs it, and sibling *_testmode_test.go files holding the
moved tests plus their fixtures where a file mixed. The harness carries
the constraint too, with its package doc in an untagged doc.go so the
package still exists without it. An untagged run compiles those tests
out and passes; make test keeps the tag and runs everything.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwYzaG3Yy7uA8FbZ5qk3tQ
2026-09-11 11:09:44 -05:00

151 lines
5.3 KiB
Go

//go:build testmode
package holdclient
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"atcr.io/pkg/testpds"
"github.com/bluesky-social/indigo/atproto/atcrypto"
)
// holdServer starts an httptest server standing in for a hold and returns it
// with the did:web that ResolveHoldDIDToURL maps back to it. The server serves
// its own DID document at /.well-known/did.json (always promptly, whatever the
// hold handler does), and handler gets every other request. Resolving a
// loopback did:web is what a `-tags testmode` build provides, which this
// file's build constraint guarantees.
func holdServer(t *testing.T, handler http.HandlerFunc) (*httptest.Server, string) {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/did.json", func(w http.ResponseWriter, r *http.Request) {
base := "http://" + r.Host
testpds.HoldDIDDocumentHandler(testpds.DIDWebForURL(base), base)(w, r)
})
mux.Handle("/", handler)
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv, testpds.DIDWebForURL(srv.URL)
}
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) {
_, okDID := holdServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
_, bad1DID := holdServer(t, func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "down", http.StatusServiceUnavailable)
})
_, bad2DID := holdServer(t, func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "broken", http.StatusInternalServerError)
})
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) {
release := make(chan struct{})
_, slowDID := holdServer(t, func(w http.ResponseWriter, r *http.Request) {
<-release
})
defer close(release)
var healthyHits atomic.Int32
_, healthyDID := holdServer(t, func(w http.ResponseWriter, r *http.Request) {
healthyHits.Add(1)
w.WriteHeader(http.StatusOK)
})
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{slowDID, healthyDID},
"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) {
release := make(chan struct{})
var attempts atomic.Int32
_, hungDID := holdServer(t, func(w http.ResponseWriter, r *http.Request) {
attempts.Add(1)
<-release
})
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{hungDID},
"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)
}
}