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, `NoSuchKey`) 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") } }