// 🤖 SPIKE tests (issue #550 Part 4 v2). Live S3 tests are gated on // LCHAT_TEST_S3_BUCKET; without it everything skips so `go test ./...` // stays green on CI and dev machines with no AWS credentials. package activator import ( "context" "crypto/rand" "database/sql" "encoding/hex" "errors" "fmt" "os" "path/filepath" "strings" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" awss3 "github.com/aws/aws-sdk-go-v2/service/s3" s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/benbjohnson/litestream" _ "modernc.org/sqlite" ) func liveConfig(t *testing.T) (bucket, prefix, region string) { t.Helper() bucket = os.Getenv("LCHAT_TEST_S3_BUCKET") if bucket == "" { t.Skip("LCHAT_TEST_S3_BUCKET not set; skipping live S3 spike test") } region = os.Getenv("LCHAT_TEST_S3_REGION") if region == "" { region = "eu-west-1" } b := make([]byte, 4) _, _ = rand.Read(b) prefix = fmt.Sprintf("spike/%d-%s", time.Now().Unix(), hex.EncodeToString(b)) t.Cleanup(func() { cleanupPrefix(t, bucket, prefix, region) }) return bucket, prefix, region } // seedDB creates a WAL-mode SQLite database via modernc.org/sqlite (the // driver this repo already uses — no cgo anywhere in the test). func seedDB(t *testing.T, path string) { t.Helper() db, err := sql.Open("sqlite", path) if err != nil { t.Fatalf("open sqlite: %v", err) } defer db.Close() if _, err := db.Exec(`PRAGMA journal_mode=WAL`); err != nil { t.Fatalf("wal mode: %v", err) } if _, err := db.Exec(`CREATE TABLE t (id INTEGER PRIMARY KEY, body TEXT)`); err != nil { t.Fatalf("create table: %v", err) } for i := 0; i < 50; i++ { if _, err := db.Exec(`INSERT INTO t (body) VALUES (?)`, fmt.Sprintf("row-%d", i)); err != nil { t.Fatalf("insert: %v", err) } } } func s3Client(t *testing.T, region string) *awss3.Client { t.Helper() cfg, err := awsconfig.LoadDefaultConfig(context.Background(), awsconfig.WithRegion(region)) if err != nil { t.Fatalf("aws config: %v", err) } return awss3.NewFromConfig(cfg) } func listKeys(t *testing.T, bucket, prefix, region string) []string { t.Helper() client := s3Client(t, region) var keys []string p := awss3.NewListObjectsV2Paginator(client, &awss3.ListObjectsV2Input{ Bucket: aws.String(bucket), Prefix: aws.String(prefix), }) for p.HasMorePages() { out, err := p.NextPage(context.Background()) if err != nil { t.Fatalf("list objects: %v", err) } for _, o := range out.Contents { keys = append(keys, *o.Key) } } return keys } // cleanupPrefix deletes every object AND version under the test prefix so // spike runs leave nothing behind in the bucket. func cleanupPrefix(t *testing.T, bucket, prefix, region string) { t.Helper() client := s3Client(t, region) ctx := context.Background() p := awss3.NewListObjectVersionsPaginator(client, &awss3.ListObjectVersionsInput{ Bucket: aws.String(bucket), Prefix: aws.String(prefix), }) for p.HasMorePages() { out, err := p.NextPage(ctx) if err != nil { t.Logf("cleanup list versions: %v", err) return } var objs []s3types.ObjectIdentifier for _, v := range out.Versions { objs = append(objs, s3types.ObjectIdentifier{Key: v.Key, VersionId: v.VersionId}) } for _, m := range out.DeleteMarkers { objs = append(objs, s3types.ObjectIdentifier{Key: m.Key, VersionId: m.VersionId}) } if len(objs) == 0 { continue } if _, err := client.DeleteObjects(ctx, &awss3.DeleteObjectsInput{ Bucket: aws.String(bucket), Delete: &s3types.Delete{Objects: objs}, }); err != nil { t.Logf("cleanup delete: %v", err) } } } // TestReplicateWithLease_ReplicatesToS3 proves Q1+Q2: the embedded library // (pure Go, modernc driver) replicates a real database to S3 under a lease. func TestReplicateWithLease_ReplicatesToS3(t *testing.T) { bucket, prefix, region := liveConfig(t) ctx := context.Background() dbPath := filepath.Join(t.TempDir(), "spike.db") seedDB(t, dbPath) r, err := ReplicateWithLease(ctx, dbPath, bucket, prefix, region) if err != nil { t.Fatalf("ReplicateWithLease: %v", err) } defer r.Close(ctx) if err := r.SyncAndWait(ctx); err != nil { t.Fatalf("SyncAndWait: %v", err) } keys := listKeys(t, bucket, prefix, region) t.Logf("objects under %s: %v", prefix, keys) var haveLock, haveLTX bool for _, k := range keys { if strings.HasSuffix(k, "lock.json") { haveLock = true } if strings.Contains(k, ".ltx") { haveLTX = true } } if !haveLock { t.Errorf("expected %s/lock.json in bucket, keys=%v", prefix, keys) } if !haveLTX { t.Errorf("expected replicated ltx data under %s, keys=%v", prefix, keys) } if err := r.Close(ctx); err != nil { t.Fatalf("Close: %v", err) } } // TestReplicateWithLease_MutualExclusion proves Q3: a second claimant on the // same replica path fails LOUDLY at lease acquisition with a typed error — // unlike the CLI, which silently absorbs the second writer. func TestReplicateWithLease_MutualExclusion(t *testing.T) { bucket, prefix, region := liveConfig(t) ctx := context.Background() dir := t.TempDir() db1 := filepath.Join(dir, "one.db") db2 := filepath.Join(dir, "two.db") seedDB(t, db1) seedDB(t, db2) first, err := ReplicateWithLease(ctx, db1, bucket, prefix, region) if err != nil { t.Fatalf("first claimant: %v", err) } defer first.Close(ctx) // Second claimant, same bucket+prefix: must fail, and fail typed. second, err := ReplicateWithLease(ctx, db2, bucket, prefix, region) if err == nil { second.Close(ctx) t.Fatal("second claimant acquired the lease while the first held it — mutual exclusion broken") } var lexists *litestream.LeaseExistsError if !errors.As(err, &lexists) { t.Fatalf("second claimant failed, but not with *litestream.LeaseExistsError: %v", err) } t.Logf("second claimant failed loudly (typed): %v", err) t.Logf(" holder=%q expires=%s", lexists.Owner, lexists.ExpiresAt.Format(time.RFC3339)) // Release, then the next claimant must succeed immediately (no TTL wait). if err := first.Close(ctx); err != nil { t.Fatalf("release: %v", err) } third, err := ReplicateWithLease(ctx, db2, bucket, prefix, region) if err != nil { t.Fatalf("claimant after release should succeed: %v", err) } if err := third.Close(ctx); err != nil { t.Fatalf("third close: %v", err) } }