223 lines
5.8 KiB
Go
223 lines
5.8 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// FakeBackend provides a deterministic in-memory object store for tests.
|
|
type FakeBackend struct {
|
|
Objects map[string]FakeObject
|
|
Uploads []FakeUploadCall
|
|
Downloads []FakeDownloadCall
|
|
|
|
ListErr error
|
|
DownloadErr error
|
|
UploadErr error
|
|
ExistsErr error
|
|
}
|
|
|
|
// FakeUploadCall captures one upload invocation in call order.
|
|
type FakeUploadCall struct {
|
|
LocalPath string
|
|
Key string
|
|
Options UploadOptions
|
|
}
|
|
|
|
// FakeDownloadCall captures one download invocation in call order.
|
|
type FakeDownloadCall struct {
|
|
Key string
|
|
LocalPath string
|
|
}
|
|
|
|
// FakeObject is a deterministic fake object-store record.
|
|
type FakeObject struct {
|
|
Key string
|
|
Data []byte
|
|
Metadata map[string]string
|
|
ETag string
|
|
LastModified *time.Time
|
|
}
|
|
|
|
// SeedObject inserts or replaces an object in the fake object store.
|
|
func (f *FakeBackend) SeedObject(obj FakeObject) {
|
|
if f.Objects == nil {
|
|
f.Objects = map[string]FakeObject{}
|
|
}
|
|
key := normalizeObjectKey(obj.Key)
|
|
obj.Key = key
|
|
obj.Data = append([]byte(nil), obj.Data...)
|
|
obj.Metadata = copyMetadata(obj.Metadata)
|
|
f.Objects[key] = obj
|
|
}
|
|
|
|
// List returns deterministic prefix-filtered objects.
|
|
func (f *FakeBackend) List(ctx context.Context, prefix string) ([]ObjectInfo, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if f.ListErr != nil {
|
|
return nil, f.ListErr
|
|
}
|
|
|
|
normalizedPrefix := normalizeObjectKey(prefix)
|
|
keys := make([]string, 0, len(f.Objects))
|
|
for key := range f.Objects {
|
|
if strings.HasPrefix(key, normalizedPrefix) {
|
|
keys = append(keys, key)
|
|
}
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
out := make([]ObjectInfo, 0, len(keys))
|
|
for _, key := range keys {
|
|
obj := f.Objects[key]
|
|
out = append(out, ObjectInfo{
|
|
Key: obj.Key,
|
|
Size: int64(len(obj.Data)),
|
|
ETag: obj.ETag,
|
|
LastModified: obj.LastModified,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// DownloadTo writes one object to a caller-owned destination writer.
|
|
func (f *FakeBackend) DownloadTo(ctx context.Context, key string, destination io.Writer) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if f.DownloadErr != nil {
|
|
return f.DownloadErr
|
|
}
|
|
if destination == nil {
|
|
return fmt.Errorf("download object: destination writer is required")
|
|
}
|
|
|
|
obj, ok := f.Objects[normalizeObjectKey(key)]
|
|
if !ok {
|
|
return fmt.Errorf("download object %q: %w", key, os.ErrNotExist)
|
|
}
|
|
f.Downloads = append(f.Downloads, FakeDownloadCall{Key: normalizeObjectKey(key)})
|
|
if _, err := destination.Write(obj.Data); err != nil {
|
|
return fmt.Errorf("download object %q: write destination: %w", key, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Download writes one object to a local path.
|
|
func (f *FakeBackend) Download(ctx context.Context, key, localPath string) error {
|
|
if strings.TrimSpace(localPath) == "" {
|
|
return fmt.Errorf("download object: local path is required")
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil {
|
|
return fmt.Errorf("download object %q: create parent directory: %w", key, err)
|
|
}
|
|
destination, err := os.Create(localPath)
|
|
if err != nil {
|
|
return fmt.Errorf("download object %q: create local file: %w", key, err)
|
|
}
|
|
defer destination.Close()
|
|
return f.DownloadTo(ctx, key, destination)
|
|
}
|
|
|
|
// Upload reads a local file and stores it under key.
|
|
func (f *FakeBackend) Upload(ctx context.Context, localPath, key string, opts UploadOptions) (ObjectInfo, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return ObjectInfo{}, err
|
|
}
|
|
if f.UploadErr != nil {
|
|
return ObjectInfo{}, f.UploadErr
|
|
}
|
|
if strings.TrimSpace(localPath) == "" {
|
|
return ObjectInfo{}, fmt.Errorf("upload object: local path is required")
|
|
}
|
|
if strings.TrimSpace(key) == "" {
|
|
return ObjectInfo{}, fmt.Errorf("upload object: key is required")
|
|
}
|
|
|
|
file, err := os.Open(localPath)
|
|
if err != nil {
|
|
return ObjectInfo{}, fmt.Errorf("upload object %q from %q: %w", key, localPath, err)
|
|
}
|
|
defer file.Close()
|
|
return f.uploadReader(ctx, file, key, opts, localPath)
|
|
}
|
|
|
|
// UploadReader stores content provided by a caller-owned reader.
|
|
func (f *FakeBackend) UploadReader(ctx context.Context, source io.Reader, key string, opts UploadOptions) (ObjectInfo, error) {
|
|
return f.uploadReader(ctx, source, key, opts, "reader")
|
|
}
|
|
|
|
func (f *FakeBackend) uploadReader(ctx context.Context, source io.Reader, key string, opts UploadOptions, localPath string) (ObjectInfo, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return ObjectInfo{}, err
|
|
}
|
|
if f.UploadErr != nil {
|
|
return ObjectInfo{}, f.UploadErr
|
|
}
|
|
if source == nil {
|
|
return ObjectInfo{}, fmt.Errorf("upload object: source is required")
|
|
}
|
|
if strings.TrimSpace(key) == "" {
|
|
return ObjectInfo{}, fmt.Errorf("upload object: key is required")
|
|
}
|
|
|
|
data, err := io.ReadAll(source)
|
|
if err != nil {
|
|
return ObjectInfo{}, fmt.Errorf("upload object %q from %q: %w", key, localPath, err)
|
|
}
|
|
|
|
normalizedKey := normalizeObjectKey(key)
|
|
f.Uploads = append(f.Uploads, FakeUploadCall{
|
|
LocalPath: localPath,
|
|
Key: normalizedKey,
|
|
Options: UploadOptions{
|
|
Metadata: copyMetadata(opts.Metadata),
|
|
ContentType: opts.ContentType,
|
|
},
|
|
})
|
|
now := time.Now().UTC()
|
|
obj := FakeObject{
|
|
Key: normalizedKey,
|
|
Data: data,
|
|
Metadata: copyMetadata(opts.Metadata),
|
|
LastModified: &now,
|
|
}
|
|
f.SeedObject(obj)
|
|
return ObjectInfo{
|
|
Key: normalizedKey,
|
|
Size: int64(len(data)),
|
|
LastModified: &now,
|
|
}, nil
|
|
}
|
|
|
|
// Exists checks object presence.
|
|
func (f *FakeBackend) Exists(ctx context.Context, key string) (bool, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return false, err
|
|
}
|
|
if f.ExistsErr != nil {
|
|
return false, f.ExistsErr
|
|
}
|
|
_, ok := f.Objects[normalizeObjectKey(key)]
|
|
return ok, nil
|
|
}
|
|
|
|
func copyMetadata(in map[string]string) map[string]string {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(in))
|
|
for k, v := range in {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|