mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-26 12:14:17 +00:00
TestCarstoreConcurrentWritesTwoOpeners failed in CI on 2026-09-13 with
"database is locked" while the code was correct. It hammered the file with
five concurrent writers and asserted that no lock error surfaced within the
5 s busy_timeout, which on a loaded runner with every package testing in
parallel is a statement about the disk, not the code. The property it guards
(busy_timeout applied to every connection of every pool on a hold database,
c44a874) is now tested directly: one connection holds the write lock via
BEGIN IMMEDIATE, a second writer is shown to block rather than fail, and to
succeed once the lock is released. Both topologies are covered (the shared
OpenHoldDB pool, and a second opener on the same file, in both directions),
and a control shows a pool without busy_timeout fails immediately under the
same lock, so the passing tests are known to observe the mechanism.
The failure was also buried under the INFO lines every hold and PDS test
emits while booting. internal/testlog.Quiet swaps the default slog handler
for a discard handler unless the run is verbose or ATCR_TEST_LOGS is set,
and every package that produced that output now calls it from TestMain.
`go test` only shows a package's output when it fails, so this changes
nothing for passing runs and leaves a failing one readable.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hho5da4daoCoPBJ9tCrL7s
298 lines
8.1 KiB
Go
298 lines
8.1 KiB
Go
package pds
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"atcr.io/internal/testlog"
|
|
|
|
"atcr.io/pkg/atproto"
|
|
"atcr.io/pkg/auth/oauth"
|
|
"atcr.io/pkg/s3"
|
|
bsky "github.com/bluesky-social/indigo/api/bsky"
|
|
)
|
|
|
|
// Shared test resources (used across all test files in package)
|
|
var (
|
|
sharedTestKeyPath string
|
|
sharedTestKey []byte
|
|
sharedPDS *HoldPDS // Shared bootstrapped PDS for read-only tests
|
|
sharedHandler *XRPCHandler // Shared handler for read-only tests
|
|
sharedCtx context.Context // Shared context
|
|
)
|
|
|
|
func TestStatusPost(t *testing.T) {
|
|
// Create temporary directory for test
|
|
tmpDir := t.TempDir()
|
|
// Use in-memory database for speed
|
|
dbPath := ":memory:"
|
|
keyPath := filepath.Join(tmpDir, "test.key")
|
|
|
|
// Copy shared signing key
|
|
if err := os.WriteFile(keyPath, sharedTestKey, 0600); err != nil {
|
|
t.Fatalf("Failed to copy shared signing key: %v", err)
|
|
}
|
|
|
|
// Create test PDS
|
|
ctx := context.Background()
|
|
did := "did:web:test.example.com"
|
|
publicURL := "https://test.example.com"
|
|
|
|
holdPDS, err := NewHoldPDS(ctx, did, publicURL, "https://atcr.io", dbPath, keyPath, true)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create test PDS: %v", err)
|
|
}
|
|
|
|
// Initialize empty repo (required before creating records)
|
|
err = holdPDS.repomgr.InitNewActor(ctx, holdPDS.uid, "", did, "", "", "")
|
|
if err != nil {
|
|
t.Fatalf("Failed to initialize repo: %v", err)
|
|
}
|
|
|
|
// Create handler for XRPC endpoints
|
|
handler := NewXRPCHandler(holdPDS, s3.S3Service{}, nil, &mockPDSClient{}, nil)
|
|
|
|
// Helper function to list posts via XRPC
|
|
listPosts := func() ([]map[string]any, error) {
|
|
req := makeXRPCGetRequest(atproto.RepoListRecords, map[string]string{
|
|
"repo": did,
|
|
"collection": atproto.BskyPostCollection,
|
|
"limit": "100",
|
|
// Default order (reverse=false) is newest first (DESC by rkey)
|
|
})
|
|
w := httptest.NewRecorder()
|
|
handler.HandleListRecords(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
return nil, fmt.Errorf("unexpected status code: %d, body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var result map[string]any
|
|
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
|
|
records, ok := result["records"].([]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("expected records array, got %T", result["records"])
|
|
}
|
|
|
|
posts := make([]map[string]any, len(records))
|
|
for i, rec := range records {
|
|
post, ok := rec.(map[string]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("expected record map, got %T", rec)
|
|
}
|
|
posts[i] = post
|
|
}
|
|
return posts, nil
|
|
}
|
|
|
|
t.Run("CreateStatusPost", func(t *testing.T) {
|
|
// Set status to online (creates new post)
|
|
err := holdPDS.SetStatus(ctx, "online")
|
|
if err != nil {
|
|
t.Fatalf("Failed to set status to online: %v", err)
|
|
}
|
|
|
|
// List posts
|
|
posts, err := listPosts()
|
|
if err != nil {
|
|
t.Fatalf("Failed to list posts: %v", err)
|
|
}
|
|
|
|
if len(posts) == 0 {
|
|
t.Fatal("Expected at least one status post, got 0")
|
|
}
|
|
|
|
// Get the latest post
|
|
post := posts[0]
|
|
|
|
value, ok := post["value"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("Expected value map, got %T", post["value"])
|
|
}
|
|
|
|
text, ok := value["text"].(string)
|
|
if !ok {
|
|
t.Fatalf("Expected text string, got %T", value["text"])
|
|
}
|
|
|
|
if text != "🟢 Current status: online" {
|
|
t.Errorf("Expected text '🟢 Current status: online', got '%s'", text)
|
|
}
|
|
|
|
// Verify TID-based rkey (extract from URI)
|
|
uri, ok := post["uri"].(string)
|
|
if !ok {
|
|
t.Fatalf("Expected uri string, got %T", post["uri"])
|
|
}
|
|
// URI format: at://did:web:test.example.com/app.bsky.feed.post/3m3c4...
|
|
// We just check that it contains the collection
|
|
if !contains(uri, atproto.BskyPostCollection) {
|
|
t.Errorf("Expected URI to contain collection %s, got %s", atproto.BskyPostCollection, uri)
|
|
}
|
|
})
|
|
|
|
t.Run("CreateMultiplePosts", func(t *testing.T) {
|
|
// Create multiple status posts
|
|
err := holdPDS.SetStatus(ctx, "offline")
|
|
if err != nil {
|
|
t.Fatalf("Failed to set status to offline: %v", err)
|
|
}
|
|
|
|
// Wait a moment to ensure different timestamp
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
err = holdPDS.SetStatus(ctx, "online")
|
|
if err != nil {
|
|
t.Fatalf("Failed to set status to online again: %v", err)
|
|
}
|
|
|
|
// List all posts - should have at least 3 now (1 from previous test + 2 from this test)
|
|
posts, err := listPosts()
|
|
if err != nil {
|
|
t.Fatalf("Failed to list posts: %v", err)
|
|
}
|
|
|
|
if len(posts) < 3 {
|
|
t.Errorf("Expected at least 3 status posts, got %d", len(posts))
|
|
}
|
|
|
|
// Verify each post has a unique URI
|
|
uris := make(map[string]bool)
|
|
for _, post := range posts {
|
|
uri, ok := post["uri"].(string)
|
|
if !ok {
|
|
t.Errorf("Expected uri string, got %T", post["uri"])
|
|
continue
|
|
}
|
|
if uris[uri] {
|
|
t.Errorf("Duplicate URI found: %s", uri)
|
|
}
|
|
uris[uri] = true
|
|
}
|
|
|
|
// Verify the latest post is online
|
|
latestPost := posts[0]
|
|
value, ok := latestPost["value"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("Expected value map, got %T", latestPost["value"])
|
|
}
|
|
text, ok := value["text"].(string)
|
|
if !ok {
|
|
t.Fatalf("Expected text string, got %T", value["text"])
|
|
}
|
|
if text != "🟢 Current status: online" {
|
|
t.Errorf("Expected latest post text '🟢 Current status: online', got '%s'", text)
|
|
}
|
|
})
|
|
|
|
t.Run("OfflineStatus", func(t *testing.T) {
|
|
// Create offline status post
|
|
err := holdPDS.SetStatus(ctx, "offline")
|
|
if err != nil {
|
|
t.Fatalf("Failed to set status to offline: %v", err)
|
|
}
|
|
|
|
// Get the latest post
|
|
posts, err := listPosts()
|
|
if err != nil {
|
|
t.Fatalf("Failed to list posts: %v", err)
|
|
}
|
|
|
|
if len(posts) == 0 {
|
|
t.Fatal("Expected at least one status post, got 0")
|
|
}
|
|
|
|
latestPost := posts[0]
|
|
value, ok := latestPost["value"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("Expected value map, got %T", latestPost["value"])
|
|
}
|
|
text, ok := value["text"].(string)
|
|
if !ok {
|
|
t.Fatalf("Expected text string, got %T", value["text"])
|
|
}
|
|
|
|
if text != "🔴 Current status: offline" {
|
|
t.Errorf("Expected text '🔴 Current status: offline', got '%s'", text)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Helper function to check if a string contains a substring
|
|
func contains(s, substr string) bool {
|
|
return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && findSubstring(s, substr))
|
|
}
|
|
|
|
func findSubstring(s, substr string) bool {
|
|
for i := range len(s) - len(substr) + 1 {
|
|
if s[i:i+len(substr)] == substr {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func init() {
|
|
// Register FeedPost type for testing
|
|
// This is normally done by the bsky package init(), but we need to ensure it's done
|
|
// The actual registration happens in the bsky package, so this is a no-op
|
|
// but kept for clarity
|
|
_ = &bsky.FeedPost{}
|
|
}
|
|
|
|
// Cleanup function to remove test files
|
|
func TestMain(m *testing.M) {
|
|
testlog.Quiet() // see internal/testlog; -v or ATCR_TEST_LOGS=1 restores logs
|
|
|
|
// Create a temporary directory for shared test key
|
|
tmpDir, err := os.MkdirTemp("", "pds-test-shared-*")
|
|
if err != nil {
|
|
panic(fmt.Sprintf("Failed to create temp dir: %v", err))
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
// Generate one signing key to be reused across all tests in the package
|
|
sharedTestKeyPath = filepath.Join(tmpDir, "shared-signing-key")
|
|
privateKey, err := oauth.GenerateOrLoadPDSKey(sharedTestKeyPath)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("Failed to generate shared signing key: %v", err))
|
|
}
|
|
|
|
// Store the key bytes so tests can copy them
|
|
sharedTestKey = privateKey.Bytes()
|
|
|
|
// Create one shared, bootstrapped PDS for read-only tests
|
|
// Use in-memory database for speed
|
|
sharedCtx = context.Background()
|
|
sharedPDS, err = NewHoldPDS(sharedCtx, "did:web:hold.example.com", "https://hold.example.com", "https://atcr.io", ":memory:", sharedTestKeyPath, true)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("Failed to create shared PDS: %v", err))
|
|
}
|
|
|
|
// Bootstrap once
|
|
ownerDID := "did:plc:testowner123"
|
|
err = sharedPDS.Bootstrap(sharedCtx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true})
|
|
if err != nil {
|
|
panic(fmt.Sprintf("Failed to bootstrap shared PDS: %v", err))
|
|
}
|
|
|
|
// Create shared handler
|
|
sharedHandler = NewXRPCHandler(sharedPDS, s3.S3Service{}, nil, &mockPDSClient{}, nil)
|
|
|
|
// Run tests
|
|
code := m.Run()
|
|
|
|
sharedPDS.Close()
|
|
os.Exit(code)
|
|
}
|