Files
Michilis 498d7d8a7d Add photo content-hash dedup and local↔S3 library sync.
Uploads skip per-gallery duplicates, checksums can be backfilled, and STORAGE_BACKEND plus sync tooling make switching storage backends safe.
2026-08-05 05:41:12 +00:00

136 lines
3.8 KiB
Go

package photosync
import (
"context"
"io"
"path/filepath"
"strings"
"testing"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
)
// Two local backends stand in for the real pair: copyObject only talks to the
// storage.Storage interface, so the S3 side needs no fake here.
func backends(t *testing.T) (src, dst storage.Storage, srcRoot string) {
t.Helper()
dir := t.TempDir()
srcRoot = filepath.Join(dir, "src")
src, err := storage.NewLocal(srcRoot)
if err != nil {
t.Fatal(err)
}
dst, err = storage.NewLocal(filepath.Join(dir, "dst"))
if err != nil {
t.Fatal(err)
}
return src, dst, srcRoot
}
func put(t *testing.T, s storage.Storage, key, body string) {
t.Helper()
if err := s.Put(context.Background(), key, strings.NewReader(body), int64(len(body)), "image/jpeg"); err != nil {
t.Fatal(err)
}
}
const testKey = "galleries/g1/original/p1.jpg"
func testObject() store.PhotoObject {
return store.PhotoObject{PhotoID: "p1", GalleryID: "g1", Variant: "original", Key: testKey, ContentType: "image/jpeg"}
}
func TestCopyObject(t *testing.T) {
ctx := context.Background()
t.Run("copies to destination", func(t *testing.T) {
src, dst, _ := backends(t)
put(t, src, testKey, "hello-photo")
act, size, err := copyObject(ctx, src, dst, testObject(), Options{})
if err != nil || act != actionCopied || size != 11 {
t.Fatalf("got (%v, %d, %v), want (copied, 11, nil)", act, size, err)
}
if got, err := dst.Stat(ctx, testKey); err != nil || got != 11 {
t.Fatalf("destination stat: (%d, %v)", got, err)
}
})
t.Run("skips same-size object already present", func(t *testing.T) {
src, dst, _ := backends(t)
put(t, src, testKey, "hello-photo")
put(t, dst, testKey, "hello-photo")
act, _, err := copyObject(ctx, src, dst, testObject(), Options{})
if err != nil || act != actionSkipped {
t.Fatalf("got (%v, %v), want (skipped, nil)", act, err)
}
})
t.Run("overwrite re-copies", func(t *testing.T) {
src, dst, _ := backends(t)
put(t, src, testKey, "new-content")
put(t, dst, testKey, "old-content")
act, _, err := copyObject(ctx, src, dst, testObject(), Options{Overwrite: true})
if err != nil || act != actionCopied {
t.Fatalf("got (%v, %v), want (copied, nil)", act, err)
}
r, _, err := dst.Open(ctx, testKey)
if err != nil {
t.Fatal(err)
}
defer r.Close()
body, err := io.ReadAll(r)
if err != nil {
t.Fatal(err)
}
if string(body) != "new-content" {
t.Fatalf("destination body = %q, want new-content", body)
}
})
t.Run("reports objects missing on the source", func(t *testing.T) {
src, dst, _ := backends(t)
act, _, err := copyObject(ctx, src, dst, testObject(), Options{})
if err != nil || act != actionMissing {
t.Fatalf("got (%v, %v), want (missing, nil)", act, err)
}
if _, err := dst.Stat(ctx, testKey); err != storage.ErrNotExist {
t.Fatalf("destination stat err = %v, want ErrNotExist", err)
}
})
t.Run("dry run writes nothing", func(t *testing.T) {
src, dst, _ := backends(t)
put(t, src, testKey, "hello-photo")
act, size, err := copyObject(ctx, src, dst, testObject(), Options{DryRun: true})
if err != nil || act != actionCopied || size != 11 {
t.Fatalf("got (%v, %d, %v), want (copied, 11, nil)", act, size, err)
}
if _, err := dst.Stat(ctx, testKey); err != storage.ErrNotExist {
t.Fatalf("destination stat err = %v, want ErrNotExist", err)
}
})
}
func TestParseDirection(t *testing.T) {
for in, want := range map[string]Direction{
"to-s3": ToS3,
"local-to-s3": ToS3,
"to-local": ToLocal,
"s3-to-local": ToLocal,
} {
got, err := ParseDirection(in)
if err != nil || got != want {
t.Errorf("ParseDirection(%q) = (%v, %v), want %v", in, got, err, want)
}
}
if _, err := ParseDirection("sideways"); err == nil {
t.Error("ParseDirection(\"sideways\") should fail")
}
}