Phones cannot land downloads in the photo library, so the gallery opens a share sheet for a cacheable preview while streaming via a dedicated download endpoint and recording preview sizes. Co-authored-by: Cursor <cursoragent@cursor.com>
263 lines
7.0 KiB
Go
263 lines
7.0 KiB
Go
package photosync
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/config"
|
|
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage"
|
|
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
|
|
)
|
|
|
|
// stubS3 is a minimal path-style S3 (PUT/HEAD/GET on /bucket/key) so the sync
|
|
// can be driven end to end over the real aws-sdk client.
|
|
type stubS3 struct {
|
|
mu sync.Mutex
|
|
objects map[string][]byte
|
|
puts int
|
|
}
|
|
|
|
func newStubS3(t *testing.T) (*stubS3, string) {
|
|
t.Helper()
|
|
s := &stubS3{objects: map[string][]byte{}}
|
|
srv := httptest.NewServer(s)
|
|
t.Cleanup(srv.Close)
|
|
return s, srv.URL
|
|
}
|
|
|
|
func (s *stubS3) get(key string) ([]byte, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
b, ok := s.objects[key]
|
|
return b, ok
|
|
}
|
|
|
|
func (s *stubS3) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
key := strings.TrimPrefix(r.URL.Path, "/test-bucket/")
|
|
switch r.Method {
|
|
case http.MethodPut:
|
|
body, err := readS3Body(r)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
s.objects[key] = body
|
|
s.puts++
|
|
s.mu.Unlock()
|
|
w.WriteHeader(http.StatusOK)
|
|
case http.MethodHead:
|
|
body, ok := s.get(key)
|
|
if !ok {
|
|
w.WriteHeader(http.StatusNotFound) // a HEAD carries no error body
|
|
return
|
|
}
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
|
w.WriteHeader(http.StatusOK)
|
|
case http.MethodGet:
|
|
body, ok := s.get(key)
|
|
if !ok {
|
|
w.Header().Set("Content-Type", "application/xml")
|
|
w.WriteHeader(http.StatusNotFound)
|
|
fmt.Fprint(w, `<Error><Code>NoSuchKey</Code></Error>`)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
|
w.Write(body)
|
|
default:
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
// readS3Body undoes the SDK's aws-chunked framing when it streams with a
|
|
// trailing checksum (what it does for the non-seekable S3-to-S3 style reader).
|
|
func readS3Body(r *http.Request) ([]byte, error) {
|
|
raw, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !strings.Contains(r.Header.Get("Content-Encoding"), "aws-chunked") {
|
|
return raw, nil
|
|
}
|
|
var out []byte
|
|
rest := raw
|
|
for {
|
|
nl := strings.Index(string(rest), "\r\n")
|
|
if nl < 0 {
|
|
return out, nil
|
|
}
|
|
header := string(rest[:nl])
|
|
rest = rest[nl+2:]
|
|
size, err := strconv.ParseInt(strings.SplitN(header, ";", 2)[0], 16, 64)
|
|
if err != nil || size == 0 {
|
|
return out, nil // trailer section or malformed: body is complete
|
|
}
|
|
if int64(len(rest)) < size {
|
|
return nil, fmt.Errorf("truncated aws-chunked body")
|
|
}
|
|
out = append(out, rest[:size]...)
|
|
rest = rest[size:]
|
|
if len(rest) >= 2 {
|
|
rest = rest[2:] // chunk CRLF
|
|
}
|
|
}
|
|
}
|
|
|
|
// syncEnv seeds a scratch SQLite DB with one gallery holding one ready photo
|
|
// (original + thumb + preview) and returns a config wired to the stub bucket.
|
|
func syncEnv(t *testing.T) (config.Config, *store.DB, *stubS3, []string) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
stub, endpoint := newStubS3(t)
|
|
cfg := config.Config{
|
|
DBType: "sqlite",
|
|
DatabaseURL: filepath.Join(dir, "test.db"),
|
|
ViewTokenSecret: "test-secret",
|
|
StoragePath: filepath.Join(dir, "photos"),
|
|
S3Endpoint: endpoint,
|
|
S3Region: "auto",
|
|
S3Bucket: "test-bucket",
|
|
S3AccessKeyID: "key",
|
|
S3SecretKey: "secret",
|
|
S3ForcePathStyle: true,
|
|
}
|
|
db, err := store.Open(cfg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { db.Close() })
|
|
ctx := context.Background()
|
|
if err := db.Migrate(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
const (
|
|
galleryID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
|
photoID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
|
)
|
|
now := time.Now()
|
|
if err := db.CreateGallery(ctx, store.Gallery{
|
|
ID: galleryID, Slug: "trip", Title: "Trip", Visibility: store.VisibilityPublic,
|
|
ShareToken: "tok", CreatedAt: now, UpdatedAt: now,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
origKey := "galleries/" + galleryID + "/original/" + photoID + ".jpg"
|
|
thumbKey := "galleries/" + galleryID + "/thumb/" + photoID + ".jpg"
|
|
previewKey := "galleries/" + galleryID + "/preview/" + photoID + ".jpg"
|
|
if err := db.InsertPhoto(ctx, store.Photo{
|
|
ID: photoID, GalleryID: galleryID, OriginalKey: origKey, ContentType: "image/jpeg",
|
|
SizeBytes: 11, NextAttemptAt: now, CreatedAt: now, UpdatedAt: now,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.MarkPhotoReady(ctx, photoID, thumbKey, previewKey, 7, 100, 80, now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return cfg, db, stub, []string{origKey, thumbKey, previewKey}
|
|
}
|
|
|
|
func TestRunToS3(t *testing.T) {
|
|
ctx := context.Background()
|
|
cfg, db, stub, keys := syncEnv(t)
|
|
|
|
src, err := storage.NewLocal(cfg.StoragePath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for i, k := range keys {
|
|
put(t, src, k, fmt.Sprintf("photo-bytes-%d", i))
|
|
}
|
|
|
|
res, err := Run(ctx, cfg, db, Options{Direction: ToS3, Concurrency: 2})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Total != 3 || res.Copied != 3 || res.Skipped != 0 || res.Failed != 0 || res.Missing != 0 {
|
|
t.Fatalf("first run = %+v, want 3 total / 3 copied", res)
|
|
}
|
|
for i, k := range keys {
|
|
body, ok := stub.get(k)
|
|
if !ok {
|
|
t.Fatalf("%s not uploaded", k)
|
|
}
|
|
if want := fmt.Sprintf("photo-bytes-%d", i); string(body) != want {
|
|
t.Errorf("%s = %q, want %q", k, body, want)
|
|
}
|
|
}
|
|
|
|
// Rerunning is a no-op: everything is already there at the same size.
|
|
res, err = Run(ctx, cfg, db, Options{Direction: ToS3})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Copied != 0 || res.Skipped != 3 {
|
|
t.Fatalf("rerun = %+v, want 0 copied / 3 skipped", res)
|
|
}
|
|
|
|
// --overwrite re-uploads them.
|
|
before := stub.puts
|
|
res, err = Run(ctx, cfg, db, Options{Direction: ToS3, Overwrite: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Copied != 3 || stub.puts != before+3 {
|
|
t.Fatalf("overwrite run = %+v, puts %d → %d", res, before, stub.puts)
|
|
}
|
|
}
|
|
|
|
func TestRunToLocal(t *testing.T) {
|
|
ctx := context.Background()
|
|
cfg, db, stub, keys := syncEnv(t)
|
|
for i, k := range keys {
|
|
stub.objects[k] = []byte(fmt.Sprintf("s3-bytes-%d", i))
|
|
}
|
|
|
|
res, err := Run(ctx, cfg, db, Options{Direction: ToLocal})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Total != 3 || res.Copied != 3 || res.Failed != 0 {
|
|
t.Fatalf("run = %+v, want 3 total / 3 copied", res)
|
|
}
|
|
for i, k := range keys {
|
|
body, err := os.ReadFile(filepath.Join(cfg.StoragePath, k))
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", k, err)
|
|
}
|
|
if want := fmt.Sprintf("s3-bytes-%d", i); string(body) != want {
|
|
t.Errorf("%s = %q, want %q", k, body, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunMissingOnSource(t *testing.T) {
|
|
cfg, db, _, _ := syncEnv(t)
|
|
// Nothing on local disk: every object is reported missing, none fail.
|
|
res, err := Run(context.Background(), cfg, db, Options{Direction: ToS3})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Missing != 3 || res.Copied != 0 || res.Failed != 0 {
|
|
t.Fatalf("run = %+v, want 3 missing", res)
|
|
}
|
|
}
|
|
|
|
func TestRunRequiresS3Config(t *testing.T) {
|
|
cfg, db, _, _ := syncEnv(t)
|
|
cfg.S3Endpoint, cfg.S3Bucket = "", ""
|
|
if _, err := Run(context.Background(), cfg, db, Options{Direction: ToS3}); err == nil {
|
|
t.Fatal("sync without S3 configured should fail")
|
|
}
|
|
}
|