- Remove seed_helpers.go (SeedTestMessage, SeedTestMessages, SeedTestCursor — all unused, channel/message features gutted) - Rename channel- prefixed test paths to test- in storage tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
327 lines
7.5 KiB
Go
327 lines
7.5 KiB
Go
package storage
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"os"
|
|
"testing"
|
|
)
|
|
|
|
// ── Unit Tests (always run) ──────────────
|
|
|
|
func TestS3_NewS3_Validation(t *testing.T) {
|
|
// Missing bucket
|
|
_, err := NewS3(S3Config{
|
|
AccessKey: "test",
|
|
SecretKey: "test",
|
|
})
|
|
if err == nil {
|
|
t.Error("expected error for missing bucket")
|
|
}
|
|
|
|
// Missing credentials
|
|
_, err = NewS3(S3Config{
|
|
Bucket: "test-bucket",
|
|
})
|
|
if err == nil {
|
|
t.Error("expected error for missing credentials")
|
|
}
|
|
|
|
// Missing access key only
|
|
_, err = NewS3(S3Config{
|
|
Bucket: "test-bucket",
|
|
SecretKey: "test",
|
|
})
|
|
if err == nil {
|
|
t.Error("expected error for missing access key")
|
|
}
|
|
}
|
|
|
|
func TestS3_FullKey(t *testing.T) {
|
|
tests := []struct {
|
|
prefix string
|
|
key string
|
|
want string
|
|
}{
|
|
{"", "files/ch/f.txt", "files/ch/f.txt"},
|
|
{"switchboard/", "files/ch/f.txt", "switchboard/files/ch/f.txt"},
|
|
{"prefix", "files/ch/f.txt", "prefix/files/ch/f.txt"}, // trailing slash added by NewS3
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
s := &S3Store{prefix: tt.prefix}
|
|
// Normalize prefix same as NewS3 does
|
|
if s.prefix != "" && s.prefix[len(s.prefix)-1] != '/' {
|
|
s.prefix += "/"
|
|
}
|
|
got := s.fullKey(tt.key)
|
|
if got != tt.want {
|
|
t.Errorf("fullKey(%q, %q) = %q, want %q", tt.prefix, tt.key, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestS3_KeyValidation(t *testing.T) {
|
|
// validateKey is shared between PVC and S3 — test with S3 context
|
|
bad := []string{
|
|
"",
|
|
"../etc/passwd",
|
|
"/absolute/path",
|
|
"files/../../etc/shadow",
|
|
}
|
|
for _, key := range bad {
|
|
if err := validateKey(key); err == nil {
|
|
t.Errorf("validateKey(%q) should have failed", key)
|
|
}
|
|
}
|
|
|
|
good := []string{
|
|
"files/ch/f.txt",
|
|
"files/test-1/att-abc_test.pdf",
|
|
"processing/abc123/status.json",
|
|
}
|
|
for _, key := range good {
|
|
if err := validateKey(key); err != nil {
|
|
t.Errorf("validateKey(%q) failed: %v", key, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Integration Tests (require live S3 endpoint) ──────────────
|
|
//
|
|
// Set these environment variables to run:
|
|
//
|
|
// S3_TEST_ENDPOINT=localhost:9000
|
|
// S3_TEST_BUCKET=switchboard-test
|
|
// S3_TEST_ACCESS_KEY=minioadmin
|
|
// S3_TEST_SECRET_KEY=minioadmin
|
|
//
|
|
// Example with MinIO:
|
|
//
|
|
// docker run -d -p 9000:9000 -p 9001:9001 \
|
|
// -e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD=minioadmin \
|
|
// minio/minio server /data --console-address :9001
|
|
// mc alias set local http://localhost:9000 minioadmin minioadmin
|
|
// mc mb local/switchboard-test
|
|
|
|
func testS3Store(t *testing.T) *S3Store {
|
|
t.Helper()
|
|
|
|
endpoint := os.Getenv("S3_TEST_ENDPOINT")
|
|
bucket := os.Getenv("S3_TEST_BUCKET")
|
|
accessKey := os.Getenv("S3_TEST_ACCESS_KEY")
|
|
secretKey := os.Getenv("S3_TEST_SECRET_KEY")
|
|
|
|
if endpoint == "" || bucket == "" {
|
|
t.Skip("S3_TEST_ENDPOINT and S3_TEST_BUCKET not set — skipping S3 integration tests")
|
|
}
|
|
|
|
s, err := NewS3(S3Config{
|
|
Endpoint: endpoint,
|
|
Bucket: bucket,
|
|
Region: "us-east-1",
|
|
AccessKey: accessKey,
|
|
SecretKey: secretKey,
|
|
Prefix: "test-" + t.Name() + "/",
|
|
ForcePathStyle: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewS3: %v", err)
|
|
}
|
|
|
|
// Clean up prefix after test
|
|
t.Cleanup(func() {
|
|
ctx := context.Background()
|
|
_ = s.DeletePrefix(ctx, "files")
|
|
_ = s.DeletePrefix(ctx, "processing")
|
|
})
|
|
|
|
return s
|
|
}
|
|
|
|
func TestS3_Integration_PutGetRoundTrip(t *testing.T) {
|
|
s := testS3Store(t)
|
|
ctx := context.Background()
|
|
|
|
data := []byte("hello, S3 storage world")
|
|
key := "files/test-1/att-1_test.txt"
|
|
|
|
err := s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "text/plain")
|
|
if err != nil {
|
|
t.Fatalf("Put: %v", err)
|
|
}
|
|
|
|
rc, size, ct, err := s.Get(ctx, key)
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
defer rc.Close()
|
|
|
|
if size != int64(len(data)) {
|
|
t.Errorf("size = %d, want %d", size, len(data))
|
|
}
|
|
if ct != "text/plain" {
|
|
t.Errorf("content_type = %q, want %q", ct, "text/plain")
|
|
}
|
|
|
|
got, _ := io.ReadAll(rc)
|
|
if !bytes.Equal(got, data) {
|
|
t.Errorf("data = %q, want %q", got, data)
|
|
}
|
|
}
|
|
|
|
func TestS3_Integration_GetNotFound(t *testing.T) {
|
|
s := testS3Store(t)
|
|
ctx := context.Background()
|
|
|
|
_, _, _, err := s.Get(ctx, "files/nonexistent/file.txt")
|
|
if err != ErrNotFound {
|
|
t.Errorf("err = %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestS3_Integration_Delete(t *testing.T) {
|
|
s := testS3Store(t)
|
|
ctx := context.Background()
|
|
|
|
key := "files/ch/delete-me.txt"
|
|
_ = s.Put(ctx, key, bytes.NewReader([]byte("bye")), 3, "text/plain")
|
|
|
|
err := s.Delete(ctx, key)
|
|
if err != nil {
|
|
t.Fatalf("Delete: %v", err)
|
|
}
|
|
|
|
exists, _ := s.Exists(ctx, key)
|
|
if exists {
|
|
t.Error("file still exists after Delete")
|
|
}
|
|
}
|
|
|
|
func TestS3_Integration_DeleteIdempotent(t *testing.T) {
|
|
s := testS3Store(t)
|
|
ctx := context.Background()
|
|
|
|
// S3 delete is inherently idempotent
|
|
err := s.Delete(ctx, "files/no-such/file.txt")
|
|
if err != nil {
|
|
t.Errorf("Delete nonexistent: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestS3_Integration_DeletePrefix(t *testing.T) {
|
|
s := testS3Store(t)
|
|
ctx := context.Background()
|
|
|
|
prefix := "files/test-abc/"
|
|
for _, name := range []string{"a.txt", "b.png", "c.pdf"} {
|
|
key := prefix + name
|
|
_ = s.Put(ctx, key, bytes.NewReader([]byte("x")), 1, "text/plain")
|
|
}
|
|
|
|
other := "files/test-other/keep.txt"
|
|
_ = s.Put(ctx, other, bytes.NewReader([]byte("y")), 1, "text/plain")
|
|
|
|
err := s.DeletePrefix(ctx, "files/test-abc")
|
|
if err != nil {
|
|
t.Fatalf("DeletePrefix: %v", err)
|
|
}
|
|
|
|
for _, name := range []string{"a.txt", "b.png", "c.pdf"} {
|
|
exists, _ := s.Exists(ctx, prefix+name)
|
|
if exists {
|
|
t.Errorf("%s still exists after DeletePrefix", name)
|
|
}
|
|
}
|
|
|
|
exists, _ := s.Exists(ctx, other)
|
|
if !exists {
|
|
t.Error("other channel file was incorrectly deleted")
|
|
}
|
|
}
|
|
|
|
func TestS3_Integration_Exists(t *testing.T) {
|
|
s := testS3Store(t)
|
|
ctx := context.Background()
|
|
|
|
key := "files/ch/exists.txt"
|
|
_ = s.Put(ctx, key, bytes.NewReader([]byte("hi")), 2, "text/plain")
|
|
|
|
exists, err := s.Exists(ctx, key)
|
|
if err != nil {
|
|
t.Fatalf("Exists: %v", err)
|
|
}
|
|
if !exists {
|
|
t.Error("Exists returned false for existing file")
|
|
}
|
|
|
|
exists, err = s.Exists(ctx, "files/ch/nope.txt")
|
|
if err != nil {
|
|
t.Fatalf("Exists (missing): %v", err)
|
|
}
|
|
if exists {
|
|
t.Error("Exists returned true for missing file")
|
|
}
|
|
}
|
|
|
|
func TestS3_Integration_Healthy(t *testing.T) {
|
|
s := testS3Store(t)
|
|
if err := s.Healthy(context.Background()); err != nil {
|
|
t.Errorf("Healthy: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestS3_Integration_Stats(t *testing.T) {
|
|
s := testS3Store(t)
|
|
ctx := context.Background()
|
|
|
|
// Add some files
|
|
for i := 0; i < 3; i++ {
|
|
key := "files/ch/" + string(rune('a'+i)) + ".txt"
|
|
data := bytes.Repeat([]byte("x"), 100*(i+1))
|
|
_ = s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "text/plain")
|
|
}
|
|
|
|
stats, err := s.Stats(ctx)
|
|
if err != nil {
|
|
t.Fatalf("Stats: %v", err)
|
|
}
|
|
if stats.TotalFiles != 3 {
|
|
t.Errorf("files = %d, want 3", stats.TotalFiles)
|
|
}
|
|
if stats.TotalBytes != 600 {
|
|
t.Errorf("bytes = %d, want 600", stats.TotalBytes)
|
|
}
|
|
if !stats.Healthy || !stats.Configured {
|
|
t.Error("expected healthy + configured")
|
|
}
|
|
if stats.Backend != "s3" {
|
|
t.Errorf("backend = %q, want s3", stats.Backend)
|
|
}
|
|
}
|
|
|
|
func TestS3_Integration_PutUnknownSize(t *testing.T) {
|
|
s := testS3Store(t)
|
|
ctx := context.Background()
|
|
|
|
data := []byte("unknown size upload")
|
|
key := "files/ch/unknown-size.txt"
|
|
|
|
// size=0 means unknown — S3 should handle via multipart
|
|
err := s.Put(ctx, key, bytes.NewReader(data), 0, "text/plain")
|
|
if err != nil {
|
|
t.Fatalf("Put with size=0: %v", err)
|
|
}
|
|
|
|
rc, size, _, err := s.Get(ctx, key)
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
defer rc.Close()
|
|
|
|
if size != int64(len(data)) {
|
|
t.Errorf("size = %d, want %d", size, len(data))
|
|
}
|
|
}
|