package s3 import ( "bytes" "context" "fmt" goio "io" "io/fs" "sort" "strings" "sync" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // mockS3 is an in-memory mock implementing the s3API interface. type mockS3 struct { mu sync.RWMutex objects map[string][]byte mtimes map[string]time.Time } func newMockS3() *mockS3 { return &mockS3{ objects: make(map[string][]byte), mtimes: make(map[string]time.Time), } } func (m *mockS3) GetObject(_ context.Context, params *s3.GetObjectInput, _ ...func(*s3.Options)) (*s3.GetObjectOutput, error) { m.mu.RLock() defer m.mu.RUnlock() key := aws.ToString(params.Key) data, ok := m.objects[key] if !ok { return nil, fmt.Errorf("NoSuchKey: key %q not found", key) } mtime := m.mtimes[key] return &s3.GetObjectOutput{ Body: goio.NopCloser(bytes.NewReader(data)), ContentLength: aws.Int64(int64(len(data))), LastModified: &mtime, }, nil } func (m *mockS3) PutObject(_ context.Context, params *s3.PutObjectInput, _ ...func(*s3.Options)) (*s3.PutObjectOutput, error) { m.mu.Lock() defer m.mu.Unlock() key := aws.ToString(params.Key) data, err := goio.ReadAll(params.Body) if err != nil { return nil, err } m.objects[key] = data m.mtimes[key] = time.Now() return &s3.PutObjectOutput{}, nil } func (m *mockS3) DeleteObject(_ context.Context, params *s3.DeleteObjectInput, _ ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) { m.mu.Lock() defer m.mu.Unlock() key := aws.ToString(params.Key) delete(m.objects, key) delete(m.mtimes, key) return &s3.DeleteObjectOutput{}, nil } func (m *mockS3) DeleteObjects(_ context.Context, params *s3.DeleteObjectsInput, _ ...func(*s3.Options)) (*s3.DeleteObjectsOutput, error) { m.mu.Lock() defer m.mu.Unlock() for _, obj := range params.Delete.Objects { key := aws.ToString(obj.Key) delete(m.objects, key) delete(m.mtimes, key) } return &s3.DeleteObjectsOutput{}, nil } func (m *mockS3) HeadObject(_ context.Context, params *s3.HeadObjectInput, _ ...func(*s3.Options)) (*s3.HeadObjectOutput, error) { m.mu.RLock() defer m.mu.RUnlock() key := aws.ToString(params.Key) data, ok := m.objects[key] if !ok { return nil, fmt.Errorf("NotFound: key %q not found", key) } mtime := m.mtimes[key] return &s3.HeadObjectOutput{ ContentLength: aws.Int64(int64(len(data))), LastModified: &mtime, }, nil } func (m *mockS3) ListObjectsV2(_ context.Context, params *s3.ListObjectsV2Input, _ ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) { m.mu.RLock() defer m.mu.RUnlock() prefix := aws.ToString(params.Prefix) delimiter := aws.ToString(params.Delimiter) maxKeys := int32(1000) if params.MaxKeys != nil { maxKeys = *params.MaxKeys } // Collect all matching keys sorted var allKeys []string for k := range m.objects { if strings.HasPrefix(k, prefix) { allKeys = append(allKeys, k) } } sort.Strings(allKeys) var contents []types.Object commonPrefixes := make(map[string]bool) for _, k := range allKeys { rest := strings.TrimPrefix(k, prefix) if delimiter != "" { if idx := strings.Index(rest, delimiter); idx >= 0 { // This key has a delimiter after the prefix -> common prefix cp := prefix + rest[:idx+len(delimiter)] commonPrefixes[cp] = true continue } } if int32(len(contents)) >= maxKeys { break } data := m.objects[k] mtime := m.mtimes[k] contents = append(contents, types.Object{ Key: aws.String(k), Size: aws.Int64(int64(len(data))), LastModified: &mtime, }) } var cpSlice []types.CommonPrefix // Sort common prefixes for deterministic output var cpKeys []string for cp := range commonPrefixes { cpKeys = append(cpKeys, cp) } sort.Strings(cpKeys) for _, cp := range cpKeys { cpSlice = append(cpSlice, types.CommonPrefix{Prefix: aws.String(cp)}) } return &s3.ListObjectsV2Output{ Contents: contents, CommonPrefixes: cpSlice, IsTruncated: aws.Bool(false), }, nil } func (m *mockS3) CopyObject(_ context.Context, params *s3.CopyObjectInput, _ ...func(*s3.Options)) (*s3.CopyObjectOutput, error) { m.mu.Lock() defer m.mu.Unlock() // CopySource is "bucket/key" source := aws.ToString(params.CopySource) parts := strings.SplitN(source, "/", 2) if len(parts) != 2 { return nil, fmt.Errorf("invalid CopySource: %s", source) } srcKey := parts[1] data, ok := m.objects[srcKey] if !ok { return nil, fmt.Errorf("NoSuchKey: source key %q not found", srcKey) } destKey := aws.ToString(params.Key) m.objects[destKey] = append([]byte{}, data...) m.mtimes[destKey] = time.Now() return &s3.CopyObjectOutput{}, nil } // --- Helper --- func newTestMedium(t *testing.T) (*Medium, *mockS3) { t.Helper() mock := newMockS3() m, err := New("test-bucket", withAPI(mock)) require.NoError(t, err) return m, mock } // --- Tests --- func TestNew_Good(t *testing.T) { mock := newMockS3() m, err := New("my-bucket", withAPI(mock)) require.NoError(t, err) assert.Equal(t, "my-bucket", m.bucket) assert.Equal(t, "", m.prefix) } func TestNew_Bad_NoBucket(t *testing.T) { _, err := New("") assert.Error(t, err) assert.Contains(t, err.Error(), "bucket name is required") } func TestNew_Bad_NoClient(t *testing.T) { _, err := New("bucket") assert.Error(t, err) assert.Contains(t, err.Error(), "S3 client is required") } func TestWithPrefix_Good(t *testing.T) { mock := newMockS3() m, err := New("bucket", withAPI(mock), WithPrefix("data/")) require.NoError(t, err) assert.Equal(t, "data/", m.prefix) // Prefix without trailing slash gets one added m2, err := New("bucket", withAPI(mock), WithPrefix("data")) require.NoError(t, err) assert.Equal(t, "data/", m2.prefix) } func TestReadWrite_Good(t *testing.T) { m, _ := newTestMedium(t) err := m.Write("hello.txt", "world") require.NoError(t, err) content, err := m.Read("hello.txt") require.NoError(t, err) assert.Equal(t, "world", content) } func TestReadWrite_Bad_NotFound(t *testing.T) { m, _ := newTestMedium(t) _, err := m.Read("nonexistent.txt") assert.Error(t, err) } func TestReadWrite_Bad_EmptyPath(t *testing.T) { m, _ := newTestMedium(t) _, err := m.Read("") assert.Error(t, err) err = m.Write("", "content") assert.Error(t, err) } func TestReadWrite_Good_WithPrefix(t *testing.T) { mock := newMockS3() m, err := New("bucket", withAPI(mock), WithPrefix("pfx")) require.NoError(t, err) err = m.Write("file.txt", "data") require.NoError(t, err) // Verify the key has the prefix _, ok := mock.objects["pfx/file.txt"] assert.True(t, ok, "object should be stored with prefix") content, err := m.Read("file.txt") require.NoError(t, err) assert.Equal(t, "data", content) } func TestEnsureDir_Good(t *testing.T) { m, _ := newTestMedium(t) // EnsureDir is a no-op for S3 err := m.EnsureDir("any/path") assert.NoError(t, err) } func TestIsFile_Good(t *testing.T) { m, _ := newTestMedium(t) err := m.Write("file.txt", "content") require.NoError(t, err) assert.True(t, m.IsFile("file.txt")) assert.False(t, m.IsFile("nonexistent.txt")) assert.False(t, m.IsFile("")) } func TestFileGetFileSet_Good(t *testing.T) { m, _ := newTestMedium(t) err := m.FileSet("key.txt", "value") require.NoError(t, err) val, err := m.FileGet("key.txt") require.NoError(t, err) assert.Equal(t, "value", val) } func TestDelete_Good(t *testing.T) { m, _ := newTestMedium(t) err := m.Write("to-delete.txt", "content") require.NoError(t, err) assert.True(t, m.Exists("to-delete.txt")) err = m.Delete("to-delete.txt") require.NoError(t, err) assert.False(t, m.IsFile("to-delete.txt")) } func TestDelete_Bad_EmptyPath(t *testing.T) { m, _ := newTestMedium(t) err := m.Delete("") assert.Error(t, err) } func TestDeleteAll_Good(t *testing.T) { m, _ := newTestMedium(t) // Create nested structure require.NoError(t, m.Write("dir/file1.txt", "a")) require.NoError(t, m.Write("dir/sub/file2.txt", "b")) require.NoError(t, m.Write("other.txt", "c")) err := m.DeleteAll("dir") require.NoError(t, err) assert.False(t, m.IsFile("dir/file1.txt")) assert.False(t, m.IsFile("dir/sub/file2.txt")) assert.True(t, m.IsFile("other.txt")) } func TestDeleteAll_Bad_EmptyPath(t *testing.T) { m, _ := newTestMedium(t) err := m.DeleteAll("") assert.Error(t, err) } func TestRename_Good(t *testing.T) { m, _ := newTestMedium(t) require.NoError(t, m.Write("old.txt", "content")) assert.True(t, m.IsFile("old.txt")) err := m.Rename("old.txt", "new.txt") require.NoError(t, err) assert.False(t, m.IsFile("old.txt")) assert.True(t, m.IsFile("new.txt")) content, err := m.Read("new.txt") require.NoError(t, err) assert.Equal(t, "content", content) } func TestRename_Bad_EmptyPath(t *testing.T) { m, _ := newTestMedium(t) err := m.Rename("", "new.txt") assert.Error(t, err) err = m.Rename("old.txt", "") assert.Error(t, err) } func TestRename_Bad_SourceNotFound(t *testing.T) { m, _ := newTestMedium(t) err := m.Rename("nonexistent.txt", "new.txt") assert.Error(t, err) } func TestList_Good(t *testing.T) { m, _ := newTestMedium(t) require.NoError(t, m.Write("dir/file1.txt", "a")) require.NoError(t, m.Write("dir/file2.txt", "b")) require.NoError(t, m.Write("dir/sub/file3.txt", "c")) entries, err := m.List("dir") require.NoError(t, err) names := make(map[string]bool) for _, e := range entries { names[e.Name()] = true } assert.True(t, names["file1.txt"], "should list file1.txt") assert.True(t, names["file2.txt"], "should list file2.txt") assert.True(t, names["sub"], "should list sub directory") assert.Len(t, entries, 3) // Check that sub is a directory for _, e := range entries { if e.Name() == "sub" { assert.True(t, e.IsDir()) info, err := e.Info() require.NoError(t, err) assert.True(t, info.IsDir()) } } } func TestList_Good_Root(t *testing.T) { m, _ := newTestMedium(t) require.NoError(t, m.Write("root.txt", "content")) require.NoError(t, m.Write("dir/nested.txt", "nested")) entries, err := m.List("") require.NoError(t, err) names := make(map[string]bool) for _, e := range entries { names[e.Name()] = true } assert.True(t, names["root.txt"]) assert.True(t, names["dir"]) } func TestStat_Good(t *testing.T) { m, _ := newTestMedium(t) require.NoError(t, m.Write("file.txt", "hello world")) info, err := m.Stat("file.txt") require.NoError(t, err) assert.Equal(t, "file.txt", info.Name()) assert.Equal(t, int64(11), info.Size()) assert.False(t, info.IsDir()) } func TestStat_Bad_NotFound(t *testing.T) { m, _ := newTestMedium(t) _, err := m.Stat("nonexistent.txt") assert.Error(t, err) } func TestStat_Bad_EmptyPath(t *testing.T) { m, _ := newTestMedium(t) _, err := m.Stat("") assert.Error(t, err) } func TestOpen_Good(t *testing.T) { m, _ := newTestMedium(t) require.NoError(t, m.Write("file.txt", "open me")) f, err := m.Open("file.txt") require.NoError(t, err) defer f.Close() data, err := goio.ReadAll(f.(goio.Reader)) require.NoError(t, err) assert.Equal(t, "open me", string(data)) stat, err := f.Stat() require.NoError(t, err) assert.Equal(t, "file.txt", stat.Name()) } func TestOpen_Bad_NotFound(t *testing.T) { m, _ := newTestMedium(t) _, err := m.Open("nonexistent.txt") assert.Error(t, err) } func TestCreate_Good(t *testing.T) { m, _ := newTestMedium(t) w, err := m.Create("new.txt") require.NoError(t, err) n, err := w.Write([]byte("created")) require.NoError(t, err) assert.Equal(t, 7, n) err = w.Close() require.NoError(t, err) content, err := m.Read("new.txt") require.NoError(t, err) assert.Equal(t, "created", content) } func TestAppend_Good(t *testing.T) { m, _ := newTestMedium(t) require.NoError(t, m.Write("append.txt", "hello")) w, err := m.Append("append.txt") require.NoError(t, err) _, err = w.Write([]byte(" world")) require.NoError(t, err) err = w.Close() require.NoError(t, err) content, err := m.Read("append.txt") require.NoError(t, err) assert.Equal(t, "hello world", content) } func TestAppend_Good_NewFile(t *testing.T) { m, _ := newTestMedium(t) w, err := m.Append("new.txt") require.NoError(t, err) _, err = w.Write([]byte("fresh")) require.NoError(t, err) err = w.Close() require.NoError(t, err) content, err := m.Read("new.txt") require.NoError(t, err) assert.Equal(t, "fresh", content) } func TestReadStream_Good(t *testing.T) { m, _ := newTestMedium(t) require.NoError(t, m.Write("stream.txt", "streaming content")) reader, err := m.ReadStream("stream.txt") require.NoError(t, err) defer reader.Close() data, err := goio.ReadAll(reader) require.NoError(t, err) assert.Equal(t, "streaming content", string(data)) } func TestReadStream_Bad_NotFound(t *testing.T) { m, _ := newTestMedium(t) _, err := m.ReadStream("nonexistent.txt") assert.Error(t, err) } func TestWriteStream_Good(t *testing.T) { m, _ := newTestMedium(t) writer, err := m.WriteStream("output.txt") require.NoError(t, err) _, err = goio.Copy(writer, strings.NewReader("piped data")) require.NoError(t, err) err = writer.Close() require.NoError(t, err) content, err := m.Read("output.txt") require.NoError(t, err) assert.Equal(t, "piped data", content) } func TestExists_Good(t *testing.T) { m, _ := newTestMedium(t) assert.False(t, m.Exists("nonexistent.txt")) require.NoError(t, m.Write("file.txt", "content")) assert.True(t, m.Exists("file.txt")) } func TestExists_Good_DirectoryPrefix(t *testing.T) { m, _ := newTestMedium(t) require.NoError(t, m.Write("dir/file.txt", "content")) // "dir" should exist as a directory prefix assert.True(t, m.Exists("dir")) } func TestIsDir_Good(t *testing.T) { m, _ := newTestMedium(t) require.NoError(t, m.Write("dir/file.txt", "content")) assert.True(t, m.IsDir("dir")) assert.False(t, m.IsDir("dir/file.txt")) assert.False(t, m.IsDir("nonexistent")) assert.False(t, m.IsDir("")) } func TestKey_Good(t *testing.T) { mock := newMockS3() // No prefix m, _ := New("bucket", withAPI(mock)) assert.Equal(t, "file.txt", m.key("file.txt")) assert.Equal(t, "dir/file.txt", m.key("dir/file.txt")) assert.Equal(t, "", m.key("")) assert.Equal(t, "file.txt", m.key("/file.txt")) assert.Equal(t, "file.txt", m.key("../file.txt")) // With prefix m2, _ := New("bucket", withAPI(mock), WithPrefix("pfx")) assert.Equal(t, "pfx/file.txt", m2.key("file.txt")) assert.Equal(t, "pfx/dir/file.txt", m2.key("dir/file.txt")) assert.Equal(t, "pfx/", m2.key("")) } // Ugly: verify the Medium interface is satisfied at compile time. func TestInterfaceCompliance_Ugly(t *testing.T) { mock := newMockS3() m, err := New("bucket", withAPI(mock)) require.NoError(t, err) // Verify all methods exist by calling them in a way that // proves compile-time satisfaction of the interface. var _ interface { Read(string) (string, error) Write(string, string) error EnsureDir(string) error IsFile(string) bool FileGet(string) (string, error) FileSet(string, string) error Delete(string) error DeleteAll(string) error Rename(string, string) error List(string) ([]fs.DirEntry, error) Stat(string) (fs.FileInfo, error) Open(string) (fs.File, error) Create(string) (goio.WriteCloser, error) Append(string) (goio.WriteCloser, error) ReadStream(string) (goio.ReadCloser, error) WriteStream(string) (goio.WriteCloser, error) Exists(string) bool IsDir(string) bool } = m }