//go:build test package main import ( "bytes" "fmt" "io" "os" "path/filepath" "sort" "strings" "sync/atomic" "testing" "time" "gitea.k3s.k0.nu/tools/photocli/internal/manifest" "gitea.k3s.k0.nu/tools/photocli/internal/photos" ) type mockBridge struct { accessErr error albums []photos.Album albumsErr error assets []photos.Asset assetsErr error assetsByAlbum map[string][]photos.Asset assetsByAlbumErr map[string]error listAssetsFn func(albumID string) ([]photos.Asset, int, error) tree []photos.CollectionNode treeErr error exportPreviewFn func(string, string, int, int, int) (photos.ExportResult, error) exportOrigFn func(string, string, int) (photos.ExportResult, error) reverseGeocodeFn func(float64, float64) (photos.Placemark, error) cancelled atomic.Bool } type errWriter struct{} func (errWriter) Write([]byte) (int, error) { return 0, fmt.Errorf("write") } type testFileInfo struct{ size int64 } func (t testFileInfo) Name() string { return "test" } func (t testFileInfo) Size() int64 { return t.size } func (t testFileInfo) Mode() os.FileMode { return 0644 } func (t testFileInfo) ModTime() time.Time { return time.Time{} } func (t testFileInfo) IsDir() bool { return false } func (t testFileInfo) Sys() any { return nil } type noEntryManifest struct{} func (noEntryManifest) Has(string) bool { return false } func (noEntryManifest) Add(string, string, int64, string) {} func (noEntryManifest) AddEntry(manifest.Entry) {} func (noEntryManifest) Save() error { return nil } func (noEntryManifest) Close() {} func (noEntryManifest) OpenAppend() error { return nil } func (noEntryManifest) Entries() map[string]manifest.Entry { return nil } func (m *mockBridge) RequestAccess() error { return m.accessErr } func (m *mockBridge) ListAlbums() ([]photos.Album, error) { return m.albums, m.albumsErr } func (m *mockBridge) ListAssets(albumID string) ([]photos.Asset, int, error) { if m.listAssetsFn != nil { return m.listAssetsFn(albumID) } if m.assetsErr != nil { return nil, 0, m.assetsErr } if m.assetsByAlbumErr != nil { if err, ok := m.assetsByAlbumErr[albumID]; ok { return nil, 0, err } } if m.assetsByAlbum != nil { if assets, ok := m.assetsByAlbum[albumID]; ok { return assets, len(assets), nil } return nil, 0, fmt.Errorf("album not found") } return m.assets, len(m.assets), nil } func (m *mockBridge) ListTree() ([]photos.CollectionNode, error) { return m.tree, m.treeErr } func (m *mockBridge) ReverseGeocode(lat, lon float64) (photos.Placemark, error) { if m.reverseGeocodeFn != nil { return m.reverseGeocodeFn(lat, lon) } return photos.Placemark{}, nil } func (m *mockBridge) ExportPreview(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) { if m.exportPreviewFn != nil { return m.exportPreviewFn(assetID, out, targetSize, quality, index) } return photos.ExportResult{Filename: "0000_test.jpg", Size: 1024, Cloud: "local"}, nil } func (m *mockBridge) ExportOriginal(assetID, out string, index int) (photos.ExportResult, error) { if m.exportOrigFn != nil { return m.exportOrigFn(assetID, out, index) } return photos.ExportResult{Filename: "test.jpg", Size: 2048, Cloud: "cloud"}, nil } func (m *mockBridge) ExportPreviewWithSlot(assetID, out string, targetSize, quality, index, slotIndex int) (photos.ExportResult, error) { return m.ExportPreview(assetID, out, targetSize, quality, index) } func (m *mockBridge) ExportOriginalWithSlot(assetID, out string, index, slotIndex int) (photos.ExportResult, error) { return m.ExportOriginal(assetID, out, index) } func (m *mockBridge) Cancel() { m.cancelled.Store(true) } func (m *mockBridge) IsCancelled() bool { return m.cancelled.Load() } func runWith(args []string, b photos.Bridge) (string, string, int) { var out, err bytes.Buffer rc := run(args, &out, &err, b) return out.String(), err.String(), rc } func TestRunNoArgs(t *testing.T) { _, stderr, rc := runWith(nil, &mockBridge{}) if rc != 1 { t.Errorf("rc = %d, want 1", rc) } if !strings.Contains(stderr, "photoscli") { t.Errorf("stderr should contain usage, got: %s", stderr) } } func TestRunHelp(t *testing.T) { for _, cmd := range []string{"help", "--help", "-h"} { _, stderr, rc := runWith([]string{cmd}, &mockBridge{}) if rc != 0 { t.Errorf("%s: rc = %d, want 0", cmd, rc) } if !strings.Contains(stderr, "photoscli") { t.Errorf("%s: stderr should contain usage", cmd) } } } func TestRunVersion(t *testing.T) { for _, cmd := range []string{"version", "--version", "-v"} { out, _, rc := runWith([]string{cmd}, &mockBridge{}) if rc != 0 { t.Errorf("%s: rc = %d, want 0", cmd, rc) } if out == "" { t.Errorf("%s: output is empty", cmd) } } } func TestRunUnknownCommand(t *testing.T) { _, stderr, rc := runWith([]string{"foo"}, &mockBridge{}) if rc != 1 { t.Errorf("rc = %d, want 1", rc) } if !strings.Contains(stderr, "unknown command: foo") { t.Errorf("stderr = %q", stderr) } } func TestCmdAlbumsSuccess(t *testing.T) { b := &mockBridge{ albums: []photos.Album{{ID: "a1", Title: "Vacation"}, {ID: "a2", Title: "Work"}}, } out, _, rc := runWith([]string{"albums"}, b) if rc != 0 { t.Errorf("rc = %d", rc) } expected := "a1\tVacation\na2\tWork\n" if out != expected { t.Errorf("out = %q, want %q", out, expected) } } func TestCmdAlbumsAuthDenied(t *testing.T) { b := &mockBridge{accessErr: fmt.Errorf("denied")} _, stderr, rc := runWith([]string{"albums"}, b) if rc != exitAuth { t.Errorf("rc = %d, want %d", rc, exitAuth) } if !strings.Contains(stderr, "denied") { t.Errorf("stderr = %q", stderr) } } func TestCmdAlbumsBridgeError(t *testing.T) { b := &mockBridge{albumsErr: fmt.Errorf("boom")} _, stderr, rc := runWith([]string{"albums"}, b) if rc != exitErr { t.Errorf("rc = %d, want %d", rc, exitErr) } if !strings.Contains(stderr, "boom") { t.Errorf("stderr = %q", stderr) } } func TestCmdAlbumsEmpty(t *testing.T) { b := &mockBridge{albums: []photos.Album{}} out, _, rc := runWith([]string{"albums"}, b) if rc != 0 { t.Errorf("rc = %d", rc) } if out != "" { t.Errorf("out = %q, want empty", out) } } func TestCmdPhotosMissingAlbumID(t *testing.T) { _, stderr, rc := runWith([]string{"photos"}, &mockBridge{}) if rc != 1 { t.Errorf("rc = %d, want 1", rc) } if !strings.Contains(stderr, "--album-id is required") { t.Errorf("stderr = %q", stderr) } } func TestCmdPhotosSuccess(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{ {ID: "as1", Filename: "IMG_0001.JPG", Cloud: "local", MediaType: "image", PixelWidth: 4032, PixelHeight: 3024}, {ID: "as2", Filename: "IMG_0002.JPG", Cloud: "cloud", MediaType: "image", PixelWidth: 1920, PixelHeight: 1080}, }, } out, _, rc := runWith([]string{"photos", "--album-id", "x"}, b) if rc != 0 { t.Errorf("rc = %d", rc) } expected := "as1\tIMG_0001.JPG\tlocal\timage\t4032x3024\nas2\tIMG_0002.JPG\tcloud\timage\t1920x1080\n" if out != expected { t.Errorf("out = %q, want %q", out, expected) } } func TestCmdPhotosAuthDenied(t *testing.T) { b := &mockBridge{accessErr: fmt.Errorf("nope")} _, stderr, rc := runWith([]string{"photos", "--album-id", "x"}, b) if rc != exitAuth { t.Errorf("rc = %d, want %d", rc, exitAuth) } if !strings.Contains(stderr, "nope") { t.Errorf("stderr = %q", stderr) } } func TestCmdPhotosBridgeError(t *testing.T) { b := &mockBridge{albumsErr: fmt.Errorf("fail")} _, stderr, rc := runWith([]string{"photos", "--album-id", "x"}, b) if rc != exitErr { t.Errorf("rc = %d, want %d", rc, exitErr) } if !strings.Contains(stderr, "fail") { t.Errorf("stderr = %q", stderr) } } func TestCmdTreeSuccess(t *testing.T) { b := &mockBridge{tree: []photos.CollectionNode{ {Name: "Trips", Kind: "folder", Children: []photos.CollectionNode{{Name: "Italy 2024", Kind: "folder", Children: []photos.CollectionNode{{Name: "Venice", Kind: "album"}}}}}, {Name: "Favorites", Kind: "album"}, }} out, _, rc := runWith([]string{"tree"}, b) if rc != 0 { t.Errorf("rc = %d", rc) } expected := "Trips\n Italy 2024\n Venice\nFavorites\n" if out != expected { t.Errorf("out = %q, want %q", out, expected) } } func TestCmdTreeAuthDenied(t *testing.T) { b := &mockBridge{accessErr: fmt.Errorf("denied")} _, stderr, rc := runWith([]string{"tree"}, b) if rc != exitAuth { t.Errorf("rc = %d, want %d", rc, exitAuth) } if !strings.Contains(stderr, "denied") { t.Errorf("stderr = %q", stderr) } } func TestCmdTreeBridgeError(t *testing.T) { b := &mockBridge{treeErr: fmt.Errorf("boom")} _, stderr, rc := runWith([]string{"tree"}, b) if rc != exitErr { t.Errorf("rc = %d, want %d", rc, exitErr) } if !strings.Contains(stderr, "boom") { t.Errorf("stderr = %q", stderr) } } func TestCmdBackupAllPreviewSuccess(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{{Name: "Trips", Kind: "folder", Children: []photos.CollectionNode{{ID: "a1", Name: "Venice", Kind: "album"}}}, {ID: "a2", Name: "Favorites", Kind: "album"}}, assetsByAlbum: map[string][]photos.Asset{ "a1": {{ID: "as1", Filename: "img1.jpg"}}, "a2": {{ID: "as2", Filename: "img2.jpg"}}, }, } _, stderr, rc := runWith([]string{"backup-all", "--out", "/backup", "--size", "2048", "--no-manifest"}, b) if rc != 0 { t.Fatalf("rc = %d, stderr = %q", rc, stderr) } if !strings.Contains(stderr, "exported 2 preview files across 2 albums to /backup") { t.Errorf("stderr = %q", stderr) } if strings.Contains(stderr, "failed") { t.Errorf("unexpected failed count in stderr = %q", stderr) } } func TestCmdBackupAllOriginalsSuccess(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{{Name: "Trips", Kind: "folder", Children: []photos.CollectionNode{{ID: "a1", Name: "Venice", Kind: "album"}}}}, assetsByAlbum: map[string][]photos.Asset{ "a1": {{ID: "as1", Filename: "img.jpg"}, {ID: "as2", Filename: "img2.jpg"}, {ID: "as3", Filename: "img3.jpg"}}, }, } _, stderr, rc := runWith([]string{"backup-all", "--out", "/backup", "--originals", "--size", "bad", "--no-manifest"}, b) if rc != 0 { t.Fatalf("rc = %d, stderr = %q", rc, stderr) } if !strings.Contains(stderr, "exported 3 original files across 1 albums to /backup") { t.Errorf("stderr = %q", stderr) } if strings.Contains(stderr, "failed") { t.Errorf("unexpected failed count in stderr = %q", stderr) } } func TestCmdBackupAllMissingOutDir(t *testing.T) { _, stderr, rc := runWith([]string{"backup-all", "--no-manifest"}, &mockBridge{}) if rc != 1 { t.Fatalf("rc = %d", rc) } if !strings.Contains(stderr, "--out is required") { t.Errorf("stderr = %q", stderr) } } func TestCmdBackupAllInvalidSize(t *testing.T) { b := &mockBridge{tree: []photos.CollectionNode{}} _, stderr, rc := runWith([]string{"backup-all", "--out", "/backup", "--size", "bad", "--no-manifest"}, b) if rc != 1 { t.Fatalf("rc = %d", rc) } if !strings.Contains(stderr, "--size must be a positive integer") { t.Errorf("stderr = %q", stderr) } } func TestCmdBackupAllTreeError(t *testing.T) { b := &mockBridge{treeErr: fmt.Errorf("tree fail")} _, stderr, rc := runWith([]string{"backup-all", "--out", "/backup", "--no-manifest"}, b) if rc != 1 { t.Fatalf("rc = %d", rc) } if !strings.Contains(stderr, "tree fail") { t.Errorf("stderr = %q", stderr) } } func TestCmdBackupAllExportError(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{{ID: "a1", Name: "Venice", Kind: "album"}}, assetsByAlbum: map[string][]photos.Asset{ "a1": {{ID: "as1", Filename: "img.jpg"}}, }, exportPreviewFn: func(string, string, int, int, int) (photos.ExportResult, error) { return photos.ExportResult{}, fmt.Errorf("disk full") }, } _, stderr, rc := runWith([]string{"backup-all", "--out", "/backup", "--no-manifest"}, b) if rc != 0 { t.Fatalf("rc = %d, stderr = %q", rc, stderr) } if !strings.Contains(stderr, "\u274c img.jpg: disk full") { t.Errorf("stderr should contain failure detail, got: %q", stderr) } if !strings.Contains(stderr, "(1 failed)") { t.Errorf("stderr should contain failed count, got: %q", stderr) } } func TestCmdExportMissingAlbumID(t *testing.T) { _, stderr, rc := runWith([]string{"export", "--out", "/tmp", "--no-manifest"}, &mockBridge{}) if rc != 1 { t.Errorf("rc = %d", rc) } if !strings.Contains(stderr, "--album-id is required") { t.Errorf("stderr = %q", stderr) } } func TestCmdExportMissingOutDir(t *testing.T) { _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--no-manifest"}, &mockBridge{}) if rc != 1 { t.Errorf("rc = %d", rc) } if !strings.Contains(stderr, "--out is required") { t.Errorf("stderr = %q", stderr) } } func TestCmdExportInvalidSize(t *testing.T) { _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--size", "abc", "--no-manifest"}, &mockBridge{}) if rc != 1 { t.Errorf("rc = %d", rc) } if !strings.Contains(stderr, "--size must be a positive integer") { t.Errorf("stderr = %q", stderr) } } func TestCmdExportNegativeSize(t *testing.T) { _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--size", "-5", "--no-manifest"}, &mockBridge{}) if rc != 1 { t.Errorf("rc = %d", rc) } if !strings.Contains(stderr, "--size must be a positive integer") { t.Errorf("stderr = %q", stderr) } } func TestCmdExportSuccess(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "a1", Filename: "img.jpg"}}, } _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp/out", "--no-manifest"}, b) if rc != 0 { t.Errorf("rc = %d, stderr = %q", rc, stderr) } if !strings.Contains(stderr, "exported 1 photos to /tmp/out") { t.Errorf("stderr = %q", stderr) } } func TestCmdExportDefaultSize(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "a1", Filename: "img.jpg"}}, } _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--no-manifest"}, b) if rc != 0 { t.Errorf("rc = %d", rc) } if !strings.Contains(stderr, "exported 1") { t.Errorf("stderr = %q", stderr) } } func TestCmdExportAuthDenied(t *testing.T) { b := &mockBridge{accessErr: fmt.Errorf("no")} _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--no-manifest"}, b) if rc != exitAuth { t.Errorf("rc = %d, want %d", rc, exitAuth) } if !strings.Contains(stderr, "no") { t.Errorf("stderr = %q", stderr) } } func TestCmdExportBridgeError(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "a1", Filename: "img.jpg"}}, exportPreviewFn: func(string, string, int, int, int) (photos.ExportResult, error) { return photos.ExportResult{}, fmt.Errorf("disk full") }, } _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--no-manifest"}, b) if rc != exitErr { t.Errorf("rc = %d, stderr = %q", rc, stderr) } if !strings.Contains(stderr, "all exports failed") { t.Errorf("stderr = %q", stderr) } if !strings.Contains(stderr, "\u274c img.jpg: disk full") { t.Errorf("stderr should contain failure detail, got: %q", stderr) } } func TestCmdExportPartialFailureSimple(t *testing.T) { assets := []photos.Asset{ {ID: "ok1", Filename: "ok.jpg"}, {ID: "fail1", Filename: "bad.jpg"}, } b := &mockBridge{assets: assets} var callCount int64 b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) { n := atomic.AddInt64(&callCount, 1) if n == 2 { return photos.ExportResult{}, fmt.Errorf("timeout") } return photos.ExportResult{Filename: "ok.jpg", Size: 1024, Cloud: "local"}, nil } _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--no-manifest"}, b) if rc != exitPartial { t.Errorf("rc = %d, want %d (partial failure), stderr = %q", rc, exitPartial, stderr) } if !strings.Contains(stderr, "1 failed") { t.Errorf("stderr should mention partial failure, got: %q", stderr) } } func TestCmdExportAllFailedExitCode(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "a1", Filename: "img.jpg"}}, exportPreviewFn: func(string, string, int, int, int) (photos.ExportResult, error) { return photos.ExportResult{}, fmt.Errorf("error") }, } _, _, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--no-manifest"}, b) if rc != exitErr { t.Errorf("rc = %d, want %d (all failed)", rc, exitErr) } } func TestExitCodeConstants(t *testing.T) { if exitOK != 0 { t.Errorf("exitOK = %d, want 0", exitOK) } if exitErr != 1 { t.Errorf("exitErr = %d, want 1", exitErr) } if exitPartial != 2 { t.Errorf("exitPartial = %d, want 2", exitPartial) } if exitAuth != 3 { t.Errorf("exitAuth = %d, want 3", exitAuth) } } func TestCmdExportQualityInvalid(t *testing.T) { b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}} _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--quality", "0", "--no-manifest"}, b) if rc != exitErr { t.Errorf("rc = %d, want %d", rc, exitErr) } if !strings.Contains(stderr, "--quality") { t.Errorf("stderr = %q", stderr) } } func TestCmdExportQualityValid(t *testing.T) { b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}} dir := t.TempDir() _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--quality", "90", "--no-manifest"}, b) if rc != exitOK { t.Errorf("rc = %d, stderr = %q", rc, stderr) } } func TestCmdBackupAllQualityInvalid(t *testing.T) { b := &mockBridge{tree: []photos.CollectionNode{}} _, stderr, rc := runWith([]string{"backup-all", "--out", "/tmp", "--quality", "abc", "--no-manifest"}, b) if rc != exitErr { t.Errorf("rc = %d, want %d", rc, exitErr) } if !strings.Contains(stderr, "--quality") { t.Errorf("stderr = %q", stderr) } } func TestCmdBackupAllQualityValid(t *testing.T) { b := &mockBridge{tree: []photos.CollectionNode{}} dir := t.TempDir() _, _, rc := runWith([]string{"backup-all", "--out", dir, "--quality", "70", "--no-manifest"}, b) if rc != exitOK { t.Errorf("rc = %d", rc) } } func TestCmdExportConcurrencyInvalid(t *testing.T) { b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}} _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--concurrency", "0", "--no-manifest"}, b) if rc != exitErr { t.Errorf("rc = %d, want %d", rc, exitErr) } if !strings.Contains(stderr, "--concurrency") { t.Errorf("stderr = %q", stderr) } } func TestCmdBackupAllConcurrencyInvalid(t *testing.T) { b := &mockBridge{tree: []photos.CollectionNode{}} _, stderr, rc := runWith([]string{"backup-all", "--out", "/tmp", "--concurrency", "abc", "--no-manifest"}, b) if rc != exitErr { t.Errorf("rc = %d, want %d", rc, exitErr) } if !strings.Contains(stderr, "--concurrency") { t.Errorf("stderr = %q", stderr) } } func TestCmdExportConcurrencyCapped(t *testing.T) { b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}} dir := t.TempDir() _, _, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--concurrency", "100", "--no-manifest"}, b) if rc != exitOK { t.Errorf("rc = %d, want 0 (concurrency should be capped)", rc) } } func TestCmdBackupAllConcurrencyCapped(t *testing.T) { b := &mockBridge{tree: []photos.CollectionNode{}} dir := t.TempDir() _, _, rc := runWith([]string{"backup-all", "--out", dir, "--concurrency", "100", "--no-manifest"}, b) if rc != exitOK { t.Errorf("rc = %d, want 0 (concurrency should be capped)", rc) } } func TestCmdExportOriginalsSuccess(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "a1", Filename: "img.jpg"}}, } _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp/out", "--originals", "--no-manifest"}, b) if rc != 0 { t.Errorf("rc = %d, stderr = %q", rc, stderr) } if !strings.Contains(stderr, "exported 1 original files to /tmp/out") { t.Errorf("stderr = %q", stderr) } } func TestCmdExportOriginalsIgnoresSizeValidation(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "a1", Filename: "img.jpg"}}, } _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp/out", "--originals", "--size", "abc", "--no-manifest"}, b) if rc != 0 { t.Errorf("rc = %d, stderr = %q", rc, stderr) } if strings.Contains(stderr, "--size must be a positive integer") { t.Errorf("stderr = %q", stderr) } } func TestCmdExportOriginalsBridgeError(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "a1", Filename: "img.jpg"}}, exportOrigFn: func(string, string, int) (photos.ExportResult, error) { return photos.ExportResult{}, fmt.Errorf("copy failed") }, } _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp/out", "--originals", "--no-manifest"}, b) if rc != 1 { t.Errorf("rc = %d, stderr = %q", rc, stderr) } if !strings.Contains(stderr, "all exports failed") { t.Errorf("stderr = %q", stderr) } if !strings.Contains(stderr, "\u274c img.jpg: copy failed") { t.Errorf("stderr should contain failure detail, got: %q", stderr) } } func TestFlagVal(t *testing.T) { args := []string{"--album-id", "abc", "--out", "/tmp"} if v := flagVal(args, "--album-id"); v != "abc" { t.Errorf("got %q", v) } if v := flagVal(args, "--out"); v != "/tmp" { t.Errorf("got %q", v) } if v := flagVal(args, "--missing"); v != "" { t.Errorf("got %q, want empty", v) } } func TestFlagValTrailing(t *testing.T) { args := []string{"--album-id"} if v := flagVal(args, "--album-id"); v != "" { t.Errorf("got %q, want empty for trailing flag", v) } } func TestFlagValWithDefault(t *testing.T) { args := []string{"--size", "2048"} if v := flagValWithDefault(args, "--size", "1024"); v != "2048" { t.Errorf("got %q", v) } if v := flagValWithDefault(args, "--missing", "fallback"); v != "fallback" { t.Errorf("got %q", v) } } func TestHasFlag(t *testing.T) { args := []string{"export", "--originals", "--album-id", "x"} if !hasFlag(args, "--originals") { t.Fatal("expected --originals to be found") } if hasFlag(args, "--missing") { t.Fatal("did not expect missing flag") } } func TestFlagValEmptyArgs(t *testing.T) { if v := flagVal(nil, "--album-id"); v != "" { t.Errorf("got %q", v) } if v := flagValWithDefault(nil, "--size", "1024"); v != "1024" { t.Errorf("got %q", v) } } func TestResolveAlbumIDDirectMatch(t *testing.T) { b := &mockBridge{ assetsByAlbum: map[string][]photos.Asset{ "ABC/L0/001": {{ID: "a1", Filename: "img.jpg", Cloud: "local"}}, }, } id, err := resolveAlbumID(b, "ABC/L0/001") if err != nil { t.Fatal(err) } if id != "ABC/L0/001" { t.Errorf("got %q", id) } } func TestResolveAlbumIDByName(t *testing.T) { b := &mockBridge{ albums: []photos.Album{ {ID: "ABC/L0/001", Title: "DnD"}, {ID: "DEF/L0/001", Title: "Work"}, }, assetsByAlbum: map[string][]photos.Asset{ "ABC/L0/001": {{ID: "a1", Filename: "img.jpg", Cloud: "local"}}, }, } id, err := resolveAlbumID(b, "DnD") if err != nil { t.Fatal(err) } if id != "ABC/L0/001" { t.Errorf("got %q, want ABC/L0/001", id) } } func TestResolveAlbumIDNotFound(t *testing.T) { b := &mockBridge{ albums: []photos.Album{ {ID: "ABC/L0/001", Title: "DnD"}, }, assetsByAlbum: map[string][]photos.Asset{}, } _, err := resolveAlbumID(b, "Nonexistent") if err == nil { t.Fatal("expected error") } } func TestResolveAlbumIDListAlbumsFails(t *testing.T) { b := &mockBridge{ albumsErr: fmt.Errorf("no access"), assetsByAlbum: map[string][]photos.Asset{}, } _, err := resolveAlbumID(b, "DnD") if err == nil { t.Fatal("expected error") } } func TestCmdPhotosResolvesAlbumName(t *testing.T) { b := &mockBridge{ albums: []photos.Album{ {ID: "ABC/L0/001", Title: "DnD"}, }, assetsByAlbum: map[string][]photos.Asset{ "ABC/L0/001": {{ID: "a1", Filename: "img.jpg", Cloud: "local"}}, }, } out, stderr, rc := runWith([]string{"photos", "--album-id", "DnD"}, b) if rc != 0 { t.Fatalf("rc = %d, stderr = %q", rc, stderr) } if !strings.Contains(out, "a1\timg.jpg\tlocal") { t.Errorf("out = %q", out) } } func TestCmdExportResolvesAlbumName(t *testing.T) { b := &mockBridge{ albums: []photos.Album{ {ID: "ABC/L0/001", Title: "DnD"}, }, assetsByAlbum: map[string][]photos.Asset{ "ABC/L0/001": {{ID: "a1", Filename: "img.jpg"}, {ID: "a2", Filename: "img2.jpg"}, {ID: "a3", Filename: "img3.jpg"}, {ID: "a4", Filename: "img4.jpg"}, {ID: "a5", Filename: "img5.jpg"}}, }, } _, stderr, rc := runWith([]string{"export", "--album-id", "DnD", "--out", "/tmp/out", "--no-manifest"}, b) if rc != 0 { t.Fatalf("rc = %d, stderr = %q", rc, stderr) } if !strings.Contains(stderr, "exported 5 photos") { t.Errorf("stderr = %q", stderr) } } func TestCmdExportOriginalsResolvesAlbumName(t *testing.T) { b := &mockBridge{ albums: []photos.Album{ {ID: "ABC/L0/001", Title: "DnD"}, }, assetsByAlbum: map[string][]photos.Asset{ "ABC/L0/001": {{ID: "a1", Filename: "img.jpg"}, {ID: "a2", Filename: "img2.jpg"}, {ID: "a3", Filename: "img3.jpg"}}, }, } _, stderr, rc := runWith([]string{"export", "--album-id", "DnD", "--out", "/tmp/out", "--originals", "--no-manifest"}, b) if rc != 0 { t.Fatalf("rc = %d, stderr = %q", rc, stderr) } if !strings.Contains(stderr, "exported 3 original files") { t.Errorf("stderr = %q", stderr) } } func TestCmdExportPartialFailure(t *testing.T) { call := 0 b := &mockBridge{ albums: []photos.Album{{ID: "a1", Title: "Album"}}, assetsByAlbum: map[string][]photos.Asset{ "a1": {{ID: "x1", Filename: "ok.jpg", Cloud: "local"}, {ID: "x2", Filename: "bad.jpg", Cloud: "local"}, {ID: "x3", Filename: "ok2.jpg", Cloud: "local"}}, }, exportPreviewFn: func(string, string, int, int, int) (photos.ExportResult, error) { call++ if call == 2 { return photos.ExportResult{}, fmt.Errorf("disk full") } return photos.ExportResult{Filename: "ok.jpg", Size: 1024, Cloud: "local"}, nil }, } _, stderr, rc := runWith([]string{"export", "--album-id", "Album", "--out", "/tmp", "--no-manifest"}, b) if rc != exitPartial { t.Errorf("rc = %d, want %d (partial failure), stderr = %q", rc, exitPartial, stderr) } if !strings.Contains(stderr, "\u274c bad.jpg: disk full") { t.Errorf("stderr should contain failure detail, got: %q", stderr) } if !strings.Contains(stderr, "(1 failed)") { t.Errorf("stderr should contain failed count, got: %q", stderr) } if !strings.Contains(stderr, "exported 2 photos") { t.Errorf("stderr should contain export count, got: %q", stderr) } } func TestCmdBackupAllSkippedAlbum(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{{ID: "bad-album", Name: "Broken", Kind: "album"}}, assetsByAlbum: map[string][]photos.Asset{}, } _, stderr, rc := runWith([]string{"backup-all", "--out", "/backup", "--no-manifest"}, b) if rc != 0 { t.Fatalf("rc = %d, stderr = %q", rc, stderr) } if !strings.Contains(stderr, "\u26a0 album Broken") { t.Errorf("stderr should contain skipped album, got: %q", stderr) } } func TestResolveAlbumIDNotFoundMessage(t *testing.T) { b := &mockBridge{ albums: []photos.Album{{ID: "x", Title: "Other"}}, assetsByAlbum: map[string][]photos.Asset{}, } _, err := resolveAlbumID(b, "Nonexistent") if err == nil { t.Fatal("expected error") } if !strings.Contains(err.Error(), "album not found: Nonexistent") { t.Errorf("err = %q", err.Error()) } } func TestFormatSpeed(t *testing.T) { tests := []struct { bps float64 want string }{ {0, ""}, {500, "500 B/s"}, {1500, "1.5 KB/s"}, {1024 * 1024, "1.0 MB/s"}, {2.5 * 1024 * 1024, "2.5 MB/s"}, } for _, tt := range tests { got := formatSpeed(tt.bps) if got != tt.want { t.Errorf("formatSpeed(%v) = %q, want %q", tt.bps, got, tt.want) } } } func TestFormatSize(t *testing.T) { tests := []struct { bytes int64 want string }{ {0, ""}, {-1, ""}, {500, "500 B"}, {1500, "1.5 KB"}, {1024 * 1024, "1.0 MB"}, {2.5 * 1024 * 1024, "2.5 MB"}, } for _, tt := range tests { got := formatSize(tt.bytes) if got != tt.want { t.Errorf("formatSize(%d) = %q, want %q", tt.bytes, got, tt.want) } } } func TestSanitizePathComponent(t *testing.T) { tests := []struct { input string want string }{ {"Hello World", "Hello World"}, {"Hello/World", "Hello_World"}, {"Hello\\World", "Hello_World"}, {" spaces ", "spaces"}, {"", "Untitled"}, {" ", "Untitled"}, } for _, tt := range tests { got := sanitizePathComponent(tt.input) if got != tt.want { t.Errorf("sanitizePathComponent(%q) = %q, want %q", tt.input, got, tt.want) } } } func TestExportMode(t *testing.T) { if exportMode(true) != "originals" { t.Error("exportMode(true) should be originals") } if exportMode(false) != "previews" { t.Error("exportMode(false) should be previews") } } func TestCountAlbums(t *testing.T) { nodes := []photos.CollectionNode{ {Name: "folder", Kind: "folder", Children: []photos.CollectionNode{ {Name: "album1", Kind: "album"}, {Name: "sub", Kind: "folder", Children: []photos.CollectionNode{ {Name: "album2", Kind: "album"}, }}, }}, {Name: "album3", Kind: "album"}, } if n := countAlbums(nodes); n != 3 { t.Errorf("countAlbums = %d, want 3", n) } } func TestCmdExportAllFailures(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "a1", Filename: "bad.jpg"}}, exportPreviewFn: func(string, string, int, int, int) (photos.ExportResult, error) { return photos.ExportResult{}, fmt.Errorf("error") }, } _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--no-manifest"}, b) if rc != 1 { t.Errorf("rc = %d, want 1", rc) } if !strings.Contains(stderr, "all exports failed") { t.Errorf("stderr = %q", stderr) } } func TestCmdPhotosAssetsError(t *testing.T) { b := &mockBridge{ albums: []photos.Album{{ID: "x", Title: "Album"}}, assetsByAlbum: map[string][]photos.Asset{}, } _, stderr, rc := runWith([]string{"photos", "--album-id", "x"}, b) if rc != 1 { t.Errorf("rc = %d, want 1", rc) } if !strings.Contains(stderr, "album not found") { t.Errorf("stderr = %q", stderr) } } func TestCmdBackupAllAuthDenied(t *testing.T) { b := &mockBridge{accessErr: fmt.Errorf("denied")} _, stderr, rc := runWith([]string{"backup-all", "--out", "/tmp", "--no-manifest"}, b) if rc != exitAuth { t.Errorf("rc = %d, want %d", rc, exitAuth) } if !strings.Contains(stderr, "denied") { t.Errorf("stderr = %q", stderr) } } func TestCmdExportAssetsByAlbumMap(t *testing.T) { b := &mockBridge{ albums: []photos.Album{{ID: "a1", Title: "TestAlbum"}}, assetsByAlbum: map[string][]photos.Asset{ "a1": {{ID: "x1", Filename: "photo.jpg", Cloud: "cloud"}}, }, } _, stderr, rc := runWith([]string{"export", "--album-id", "TestAlbum", "--out", "/tmp", "--no-manifest"}, b) if rc != 0 { t.Errorf("rc = %d, stderr = %q", rc, stderr) } if !strings.Contains(stderr, "exported 1") { t.Errorf("stderr = %q", stderr) } } func TestCmdBackupAllWithFolder(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{ {Name: "MyFolder", Kind: "folder", Children: []photos.CollectionNode{ {ID: "a1", Name: "SubAlbum", Kind: "album"}, }}, }, assetsByAlbum: map[string][]photos.Asset{ "a1": {{ID: "x1", Filename: "photo.jpg"}}, }, } _, stderr, rc := runWith([]string{"backup-all", "--out", "/backup", "--no-manifest"}, b) if rc != 0 { t.Fatalf("rc = %d, stderr = %q", rc, stderr) } if !strings.Contains(stderr, "exported 1 preview files across 1 albums") { t.Errorf("stderr = %q", stderr) } } func TestProgressDisplayRenderBar(t *testing.T) { var buf bytes.Buffer d := newProgressBar(&buf, 1) d.setTotal(5, 10, "1.0 MB") d.setAlbum("DnD", 3, 5) d.setWorker(0, "photo.jpg", 0, "cloud", "exporting") d.draw() output := buf.String() if !strings.Contains(output, "Total") { t.Error("should contain Total") } if !strings.Contains(output, "Album") { t.Error("should contain Album") } if !strings.Contains(output, "DnD") { t.Error("should contain album name DnD") } if !strings.Contains(output, "photo.jpg") { t.Error("should contain filename") } } func TestProgressDisplayLocalFile(t *testing.T) { var buf bytes.Buffer d := newProgressBar(&buf, 1) d.setTotal(1, 1, "500 B") d.setAlbum("", 0, 0) d.logCompleted("\u2705 local.jpg - 500 B - copied") output := buf.String() if !strings.Contains(output, "copied") { t.Error("local files should show copied status") } if !strings.Contains(output, "\u2705") { t.Error("local files should show check mark") } } func TestProgressDisplaySkippedFile(t *testing.T) { var buf bytes.Buffer d := newProgressBar(&buf, 1) d.setTotal(1, 1, "0 B") d.setAlbum("", 0, 0) d.logCompleted("\u23ed exists.jpg") output := buf.String() if !strings.Contains(output, "\u23ed") { t.Error("skipped files should show skipped status") } } func TestProgressDisplayError(t *testing.T) { var buf bytes.Buffer d := newProgressBar(&buf, 1) d.logCompleted("\u274c bad.jpg: some error") output := buf.String() if !strings.Contains(output, "\u274c") { t.Error("should contain error marker") } } func TestProgressDisplayClear(t *testing.T) { var buf bytes.Buffer d := newProgressBar(&buf, 1) d.setTotal(1, 1, "0 B") d.draw() d.clear() output := buf.String() if !strings.Contains(output, "\x1b[") { t.Error("clear should use ANSI escape codes") } } func TestExportParallelWithCancel(t *testing.T) { var cancelFlag int32 call := int32(0) bridge := &mockBridge{ albums: []photos.Album{{ID: "a1", Title: "Test"}}, assetsByAlbum: map[string][]photos.Asset{ "a1": { {ID: "x1", Filename: "img1.jpg"}, {ID: "x2", Filename: "img2.jpg"}, {ID: "x3", Filename: "img3.jpg"}, {ID: "x4", Filename: "img4.jpg"}, {ID: "x5", Filename: "img5.jpg"}, }, }, exportOrigFn: func(string, string, int) (photos.ExportResult, error) { if atomic.AddInt32(&call, 1) >= 2 { atomic.StoreInt32(&cancelFlag, 1) } return photos.ExportResult{Filename: "img.jpg", Size: 1024, Cloud: "local"}, nil }, } _, _, _ = runWith([]string{"export", "--album-id", "Test", "--out", "/tmp", "--originals", "--no-manifest"}, bridge) _ = cancelFlag } func TestExportParallelPartialFailure(t *testing.T) { b := &mockBridge{ albums: []photos.Album{{ID: "a1", Title: "Test"}}, assetsByAlbum: map[string][]photos.Asset{ "a1": { {ID: "x1", Filename: "ok1.jpg"}, {ID: "x2", Filename: "bad.jpg"}, {ID: "x3", Filename: "ok2.jpg"}, {ID: "x4", Filename: "ok3.jpg"}, {ID: "x5", Filename: "ok4.jpg"}, }, }, exportPreviewFn: func(_ string, _ string, _ int, _ int, idx int) (photos.ExportResult, error) { if idx == 1 { return photos.ExportResult{}, fmt.Errorf("fail") } return photos.ExportResult{Filename: "ok.jpg", Size: 2048, Cloud: "local"}, nil }, } _, stderr, rc := runWith([]string{"export", "--album-id", "Test", "--out", "/tmp", "--no-manifest"}, b) if rc != exitPartial { t.Errorf("rc = %d, want %d (partial success)", rc, exitPartial) } if !strings.Contains(stderr, "1 failed") { t.Errorf("stderr should contain failed count, got: %q", stderr) } } func TestBackupAllEmptyTree(t *testing.T) { b := &mockBridge{tree: []photos.CollectionNode{}} _, stderr, rc := runWith([]string{"backup-all", "--out", "/tmp", "--no-manifest"}, b) if rc != 0 { t.Errorf("rc = %d", rc) } if !strings.Contains(stderr, "exported 0") { t.Errorf("stderr = %q", stderr) } } func TestFilterVideos(t *testing.T) { assets := []photos.Asset{ {ID: "1", Filename: "a.jpg", MediaType: "image"}, {ID: "2", Filename: "b.mov", MediaType: "video"}, {ID: "3", Filename: "c.jpg", MediaType: "image"}, {ID: "4", Filename: "d.mp3", MediaType: "audio"}, {ID: "5", Filename: "e.heic", MediaType: "image"}, } filtered, count := filterVideos(assets) if count != 3 { t.Errorf("count = %d, want 3", count) } for _, a := range filtered { if a.MediaType == "video" || a.MediaType == "audio" { t.Errorf("found %s asset: %+v", a.MediaType, a) } } } func TestPhotosOutputWithCreationDate(t *testing.T) { date := "2024-06-15T12:30:00+0200" b := &mockBridge{ assets: []photos.Asset{ {ID: "a1", Filename: "IMG.jpg", Cloud: "local", MediaType: "image", PixelWidth: 4032, PixelHeight: 3024, CreationDate: &date}, }, } out, _, rc := runWith([]string{"photos", "--album-id", "x"}, b) if rc != 0 { t.Errorf("rc = %d", rc) } if !strings.Contains(out, date) { t.Errorf("out = %q, want creation date %s", out, date) } } func TestPhotosOutputWithDuration(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{ {ID: "v1", Filename: "clip.mov", Cloud: "cloud", MediaType: "video", PixelWidth: 1920, PixelHeight: 1080, Duration: 12.5}, }, } out, _, rc := runWith([]string{"photos", "--album-id", "x"}, b) if rc != 0 { t.Errorf("rc = %d", rc) } if !strings.Contains(out, "12.5s") { t.Errorf("out = %q, want duration", out) } } func TestPhotosOutputWithFavorite(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{ {ID: "f1", Filename: "fav.jpg", Cloud: "local", MediaType: "image", PixelWidth: 1000, PixelHeight: 1000, IsFavorite: true}, }, } out, _, rc := runWith([]string{"photos", "--album-id", "x"}, b) if rc != 0 { t.Errorf("rc = %d", rc) } if !strings.Contains(out, "*") { t.Errorf("out = %q, want favorite marker", out) } } func TestExportSkipsVideos(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{ {ID: "1", Filename: "a.jpg", MediaType: "image"}, {ID: "2", Filename: "b.mov", MediaType: "video"}, }, } _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--no-manifest"}, b) if rc != 0 { t.Errorf("rc = %d", rc) } if !strings.Contains(stderr, "1 assets") { t.Errorf("stderr = %q, want 1 asset (video skipped)", stderr) } } func TestExportIncludesVideos(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{ {ID: "1", Filename: "a.jpg", MediaType: "image"}, {ID: "2", Filename: "b.mov", MediaType: "video"}, }, } _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--include-videos", "--no-manifest"}, b) if rc != 0 { t.Errorf("rc = %d", rc) } if !strings.Contains(stderr, "2 assets") { t.Errorf("stderr = %q, want 2 assets (video included)", stderr) } } func TestManifestLoadEmpty(t *testing.T) { dir := t.TempDir() mf := manifest.LoadJSONL(dir) if mf == nil { t.Fatal("loadManifest should not return nil") } if mf.Has("nonexistent") { t.Error("empty manifest should not have any entries") } } func TestManifestAddAndHas(t *testing.T) { dir := t.TempDir() mf := manifest.LoadJSONL(dir) if err := mf.OpenAppend(); err != nil { t.Fatal(err) } defer mf.Close() mf.Add("id1", "photo.jpg", 1024, "local") if !mf.Has("id1") { t.Error("manifest should have id1 after add") } if mf.Has("id2") { t.Error("manifest should not have id2") } } func TestManifestSaveAndReload(t *testing.T) { dir := t.TempDir() mf := manifest.LoadJSONL(dir) if err := mf.OpenAppend(); err != nil { t.Fatal(err) } mf.Add("id1", "photo.jpg", 1024, "local") mf.Add("id2", "cloud.heic", 4096, "cloud") if err := mf.Save(); err != nil { t.Fatal(err) } mf.Close() mf2 := manifest.LoadJSONL(dir) if !mf2.Has("id1") { t.Error("reloaded manifest should have id1") } if !mf2.Has("id2") { t.Error("reloaded manifest should have id2") } } func TestManifestOpenAppendCreatesDir(t *testing.T) { dir := t.TempDir() subdir := filepath.Join(dir, "sub", "deep") mf := manifest.LoadJSONL(subdir) if err := mf.OpenAppend(); err != nil { t.Fatal(err) } mf.Add("id1", "photo.jpg", 512, "local") mf.Close() if _, err := os.Stat(filepath.Join(subdir, "downloads.jsonl")); err != nil { t.Errorf("manifest file should exist: %v", err) } } func TestManifestCloseIdempotent(t *testing.T) { dir := t.TempDir() mf := manifest.LoadJSONL(dir) if err := mf.OpenAppend(); err != nil { t.Fatal(err) } mf.Add("id1", "photo.jpg", 1024, "local") mf.Close() mf.Close() } func TestManifestNilSafe(t *testing.T) { var mf manifest.Manifest if mf != nil && mf.Has("anything") { t.Error("nil manifest should not have entries") } } func TestCollectNodesWithManifest(t *testing.T) { dir := t.TempDir() mf := manifest.LoadJSONL(dir) if err := mf.OpenAppend(); err != nil { t.Fatal(err) } defer mf.Close() mf.Add("x1", "img.jpg", 1024, "local") b := &mockBridge{ tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, assetsByAlbum: map[string][]photos.Asset{ "a1": {{ID: "x1", Filename: "img.jpg"}, {ID: "x2", Filename: "img2.jpg"}}, }, } var items []pendingAsset var skipped int collectNodes(b.tree, dir, b, false, false, &items, &skipped, nil, mf, nil, exportOptions{}) if skipped != 1 { t.Errorf("expected 1 skipped, got %d", skipped) } if len(items) != 1 { t.Errorf("expected 1 item, got %d", len(items)) } if len(items) > 0 && items[0].asset.ID != "x2" { t.Errorf("expected x2, got %s", items[0].asset.ID) } } func TestBackupTreeWithManifest(t *testing.T) { dir := t.TempDir() mf := manifest.LoadJSONL(dir) if err := mf.OpenAppend(); err != nil { t.Fatal(err) } mf.Add("as1", "img1.jpg", 1024, "local") mf.Close() b := &mockBridge{ tree: []photos.CollectionNode{ {ID: "a1", Name: "Album", Kind: "album"}, {ID: "a2", Name: "Other", Kind: "album"}, }, assetsByAlbum: map[string][]photos.Asset{ "a1": {{ID: "as1", Filename: "img1.jpg"}}, "a2": {{ID: "as2", Filename: "img2.jpg"}}, }, } var stderr bytes.Buffer exported, failed, err := backupTree(b.tree, dir, 1024, 85, 3, false, false, &stderr, b, false, manifest.FormatJSONL, false, nil, time.Time{}, false, exportOptions{}) if err != nil { t.Fatalf("unexpected error: %v", err) } if exported != 1 { t.Errorf("expected 1 exported (as1 skipped), got %d", exported) } if failed != 0 { t.Errorf("expected 0 failed, got %d", failed) } if !strings.Contains(stderr.String(), "1 skipped") { t.Errorf("stderr should mention skipped, got: %q", stderr.String()) } } func TestBackupTreeNoManifest(t *testing.T) { dir := t.TempDir() b := &mockBridge{ tree: []photos.CollectionNode{ {ID: "a1", Name: "Album", Kind: "album"}, }, assetsByAlbum: map[string][]photos.Asset{ "a1": {{ID: "x1", Filename: "img.jpg"}}, }, } var stderr bytes.Buffer exported, failed, err := backupTree(b.tree, dir, 1024, 85, 3, false, false, &stderr, b, true, manifest.FormatJSONL, false, nil, time.Time{}, false, exportOptions{}) if err != nil { t.Fatalf("unexpected error: %v", err) } if exported != 1 { t.Errorf("expected 1 exported, got %d", exported) } if failed != 0 { t.Errorf("expected 0 failed, got %d", failed) } if strings.Contains(stderr.String(), "manifest") { t.Errorf("should not mention manifest with --no-manifest, got: %q", stderr.String()) } } func TestCmdBackupAllWithManifest(t *testing.T) { dir := t.TempDir() b := &mockBridge{ tree: []photos.CollectionNode{ {ID: "a1", Name: "Album", Kind: "album"}, }, assetsByAlbum: map[string][]photos.Asset{ "a1": {{ID: "x1", Filename: "img.jpg"}}, }, } _, stderr, rc := runWith([]string{"backup-all", "--out", dir}, b) if rc != 0 { t.Fatalf("rc = %d, stderr = %q", rc, stderr) } manifestPath := filepath.Join(dir, "downloads.jsonl") data, err := os.ReadFile(manifestPath) if err != nil { t.Fatalf("manifest file should exist: %v", err) } if len(data) == 0 { t.Error("manifest file should not be empty") } } func TestCmdBackupAllNoManifest(t *testing.T) { dir := t.TempDir() b := &mockBridge{ tree: []photos.CollectionNode{ {ID: "a1", Name: "Album", Kind: "album"}, }, assetsByAlbum: map[string][]photos.Asset{ "a1": {{ID: "x1", Filename: "img.jpg"}}, }, } _, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--no-manifest"}, b) if rc != 0 { t.Fatalf("rc = %d, stderr = %q", rc, stderr) } manifestPath := filepath.Join(dir, "downloads.jsonl") if _, err := os.Stat(manifestPath); !os.IsNotExist(err) { t.Error("manifest file should not exist with --no-manifest") } } func TestCmdExportWithManifest(t *testing.T) { dir := t.TempDir() b := &mockBridge{ assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, } _, _, rc := runWith([]string{"export", "--album-id", "x", "--out", dir}, b) if rc != 0 { t.Fatalf("rc = 0 expected, got %d", rc) } manifestPath := filepath.Join(dir, "downloads.jsonl") if _, err := os.Stat(manifestPath); err != nil { t.Fatalf("manifest file should exist: %v", err) } } func TestCmdExportNoManifest(t *testing.T) { dir := t.TempDir() b := &mockBridge{ assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, } _, _, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--no-manifest"}, b) if rc != 0 { t.Fatalf("rc = 0 expected, got %d", rc) } manifestPath := filepath.Join(dir, "downloads.jsonl") if _, err := os.Stat(manifestPath); !os.IsNotExist(err) { t.Error("manifest file should not exist with --no-manifest") } } func TestManifestOpenAppendIdempotent(t *testing.T) { dir := t.TempDir() mf := manifest.LoadJSONL(dir) if err := mf.OpenAppend(); err != nil { t.Fatal(err) } if err := mf.OpenAppend(); err != nil { t.Error("second openAppend should be idempotent") } mf.Close() } func TestManifestOpenAppendMkdirError(t *testing.T) { mf := manifest.LoadJSONL("/proc/cannot-create-dir-here") if err := mf.OpenAppend(); err == nil { t.Error("expected error from openAppend on read-only path") } } func TestManifestSaveWithNoFile(t *testing.T) { dir := t.TempDir() mf := manifest.LoadJSONL(dir) if err := mf.Save(); err != nil { t.Errorf("save with no file open should return nil, got %v", err) } } func TestManifestLoadFromExistingFile(t *testing.T) { dir := t.TempDir() mf := manifest.LoadJSONL(dir) if err := mf.OpenAppend(); err != nil { t.Fatal(err) } mf.Add("id1", "photo.jpg", 1024, "local") mf.Add("id2", "cloud.heic", 4096, "cloud") mf.Close() loaded := manifest.LoadJSONL(dir) if !loaded.Has("id1") { t.Error("loaded manifest should have id1") } if !loaded.Has("id2") { t.Error("loaded manifest should have id2") } } func TestCollectNodesCancelled(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, assetsByAlbum: map[string][]photos.Asset{ "a1": {{ID: "x1", Filename: "img.jpg"}}, }, } b.Cancel() var items []pendingAsset var skipped int collectNodes(b.tree, "/out", b, false, false, &items, &skipped, nil, nil, nil, exportOptions{}) if len(items) != 0 { t.Errorf("cancelled collectNodes should return 0 items, got %d", len(items)) } } func TestCollectNodesAlbumWithEmptyID(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{{Name: "Empty", Kind: "album", ID: ""}}, } var items []pendingAsset var skipped int collectNodes(b.tree, "/out", b, false, false, &items, &skipped, nil, nil, nil, exportOptions{}) if len(items) != 0 { t.Errorf("album with empty ID should be skipped, got %d items", len(items)) } } func TestCollectNodesListAssetsError(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, assetsErr: fmt.Errorf("fetch error"), } var progressCalls []collectProgress var items []pendingAsset var skipped int collectNodes(b.tree, "/out", b, false, false, &items, &skipped, func(p collectProgress) { progressCalls = append(progressCalls, p) }, nil, nil, exportOptions{}) if len(progressCalls) != 1 || progressCalls[0].err == nil { t.Errorf("expected error progress call, got %v", progressCalls) } } func TestCollectNodesNilProgress(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, assetsByAlbum: map[string][]photos.Asset{ "a1": {{ID: "x1", Filename: "img.jpg"}}, }, } var items []pendingAsset var skipped int collectNodes(b.tree, "/out", b, false, false, &items, &skipped, nil, nil, nil, exportOptions{}) if len(items) != 1 { t.Errorf("expected 1 item, got %d", len(items)) } } func TestCmdBackupAllOriginalsWithFailures(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{ {ID: "a1", Name: "Album", Kind: "album"}, }, assetsByAlbum: map[string][]photos.Asset{ "a1": { {ID: "ok1", Filename: "good.jpg"}, {ID: "bad1", Filename: "bad.jpg"}, }, }, exportOrigFn: func(id string, _ string, _ int) (photos.ExportResult, error) { if id == "bad1" { return photos.ExportResult{}, fmt.Errorf("write error") } return photos.ExportResult{Filename: "good.jpg", Size: 1024, Cloud: "local"}, nil }, } dir := t.TempDir() _, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--originals"}, b) if rc != exitPartial { t.Errorf("rc = %d, want %d, stderr = %q", rc, exitPartial, stderr) } if !strings.Contains(stderr, "1 failed") { t.Errorf("should report failed count, got: %q", stderr) } } func TestCmdExportOriginalsWithFailures(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{ {ID: "ok1", Filename: "good.jpg"}, {ID: "bad1", Filename: "bad.jpg"}, }, exportOrigFn: func(id string, _ string, _ int) (photos.ExportResult, error) { if id == "bad1" { return photos.ExportResult{}, fmt.Errorf("export error") } return photos.ExportResult{Filename: "good.heic", Size: 8192, Cloud: "local"}, nil }, } dir := t.TempDir() _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--originals"}, b) if rc != exitPartial { t.Errorf("rc = %d, want %d, stderr = %q", rc, exitPartial, stderr) } if !strings.Contains(stderr, "1 failed") { t.Errorf("should report failed count, got: %q", stderr) } } func TestCmdExportAllFailuresOriginals(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "bad1", Filename: "bad.jpg"}}, exportOrigFn: func(string, string, int) (photos.ExportResult, error) { return photos.ExportResult{}, fmt.Errorf("export error") }, } dir := t.TempDir() _, _, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--originals"}, b) if rc != 1 { t.Errorf("expected rc=1 when all exports fail, got %d", rc) } } func TestExportAssetsManifestError(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, } var stderr bytes.Buffer exported, _ := exportAssets(b.assets, "/proc/cannot-create", 1024, 85, 3, false, 1, &stderr, b, "", false, manifest.FormatJSONL, false, exportOptions{}) if exported != 1 { t.Errorf("expected 1 exported, got %d", exported) } if !strings.Contains(stderr.String(), "could not open manifest") { t.Errorf("expected manifest warning, got: %q", stderr.String()) } } func TestBackupTreeManifestOpenError(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, assetsByAlbum: map[string][]photos.Asset{ "a1": {{ID: "x1", Filename: "img.jpg"}}, }, } var stderr bytes.Buffer _, _, err := backupTree(b.tree, "/proc/cannot-create", 1024, 85, 3, false, false, &stderr, b, false, manifest.FormatJSONL, false, nil, time.Time{}, false, exportOptions{}) if err != nil { t.Errorf("unexpected error: %v", err) } if !strings.Contains(stderr.String(), "could not open manifest") { t.Errorf("expected manifest warning, got: %q", stderr.String()) } } func TestBackupTreeCancelledAfterCollect(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, assetsByAlbum: map[string][]photos.Asset{ "a1": {{ID: "x1", Filename: "img.jpg"}}, }, } b.Cancel() var stderr bytes.Buffer _, _, err := backupTree(b.tree, "/tmp", 1024, 85, 3, false, false, &stderr, b, true, manifest.FormatJSONL, false, nil, time.Time{}, false, exportOptions{}) if err == nil { t.Error("cancelled backupTree should return error") } } func TestExportPendingSerialSkipped(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, } b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) { return photos.ExportResult{Filename: "img.jpg", Size: 0, Skipped: true, Cloud: "local"}, nil } pending := []pendingAsset{{asset: b.assets[0], path: "/tmp", album: "test"}} var buf bytes.Buffer bar := newProgressBar(&buf, 1) done, failed := exportPendingSerial(pending, 1024, 85, false, 1, bar, b, nil, manifest.NoopLogWriter, exportOptions{}) if done != 0 { t.Errorf("expected 0 done for skipped, got %d", done) } if failed != 0 { t.Errorf("expected 0 failed for skipped, got %d", failed) } } func TestExportPendingSerialCloudDownload(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "c1", Filename: "cloud.jpg", Cloud: "cloud"}}, } b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) { return photos.ExportResult{Filename: "cloud.jpg", Size: 2048, Cloud: "cloud"}, nil } pending := []pendingAsset{{asset: b.assets[0], path: "/tmp", album: "test"}} var buf bytes.Buffer bar := newProgressBar(&buf, 1) done, _ := exportPendingSerial(pending, 1024, 85, false, 1, bar, b, nil, manifest.NoopLogWriter, exportOptions{}) if done != 1 { t.Errorf("expected 1 done, got %d", done) } } func TestExportPendingSerialWithManifest(t *testing.T) { dir := t.TempDir() mf := manifest.LoadJSONL(dir) if err := mf.OpenAppend(); err != nil { t.Fatal(err) } b := &mockBridge{ assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, } b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) { return photos.ExportResult{Filename: "img.jpg", Size: 1024, Cloud: "local"}, nil } pending := []pendingAsset{{asset: b.assets[0], path: dir, album: "test"}} var buf bytes.Buffer bar := newProgressBar(&buf, 1) done, _ := exportPendingSerial(pending, 1024, 85, false, 1, bar, b, mf, manifest.NoopLogWriter, exportOptions{}) if done != 1 { t.Errorf("expected 1 done, got %d", done) } mf.Close() if !mf.Has("x1") { t.Error("manifest should have x1 after serial export") } } func TestExportPendingSerialSkippedWritesToManifest(t *testing.T) { dir := t.TempDir() mf := manifest.LoadJSONL(dir) if err := mf.OpenAppend(); err != nil { t.Fatal(err) } b := &mockBridge{ assets: []photos.Asset{{ID: "s1", Filename: "skip.jpg"}}, } b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) { return photos.ExportResult{Filename: "skip.jpg", Size: 512, Skipped: true, Cloud: "local"}, nil } pending := []pendingAsset{{asset: b.assets[0], path: dir, album: "test"}} var buf bytes.Buffer bar := newProgressBar(&buf, 1) done, failed := exportPendingSerial(pending, 1024, 85, false, 1, bar, b, mf, manifest.NoopLogWriter, exportOptions{}) if done != 0 { t.Errorf("expected 0 done for skipped, got %d", done) } if failed != 0 { t.Errorf("expected 0 failed, got %d", failed) } mf.Close() if !mf.Has("s1") { t.Error("manifest should have s1 after skipped serial export") } } func TestPhotosNilCreationDateWithDuration(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{ {ID: "1", Filename: "video.mov", Duration: 30.5}, }, } var buf bytes.Buffer rc := cmdPhotos([]string{"--album-id", "x"}, &buf, &buf, b) if rc != 0 { t.Errorf("rc = %d", rc) } } func TestPhotosNilCreationDateWithFavorite(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{ {ID: "1", Filename: "photo.jpg", IsFavorite: true}, }, } var buf bytes.Buffer rc := cmdPhotos([]string{"--album-id", "x"}, &buf, &buf, b) if rc != 0 { t.Errorf("rc = %d", rc) } if !strings.Contains(buf.String(), "*") { t.Errorf("expected favorite marker *, got: %q", buf.String()) } } func TestFormatDuration(t *testing.T) { tests := []struct { d time.Duration want string }{ {5 * time.Second, "5s"}, {90 * time.Second, "1m30s"}, {3661 * time.Second, "61m01s"}, } for _, tt := range tests { got := formatDuration(tt.d) if got != tt.want { t.Errorf("formatDuration(%v) = %q, want %q", tt.d, got, tt.want) } } } func TestRuneWidth(t *testing.T) { tests := []struct { s string want int }{ {"abc", 3}, {"hello world", 11}, {"\u4e16\u754c", 4}, {"\u1100", 2}, {"\u2329", 2}, {"\u232a", 2}, {"\uac00", 2}, {"\uf900", 2}, {"\ufe30", 2}, {"\uff01", 2}, {"\uffe0", 2}, {"", 0}, } for _, tt := range tests { got := runeWidth(tt.s) if got != tt.want { t.Errorf("runeWidth(%q) = %d, want %d", tt.s, got, tt.want) } } } func TestTruncateOrPad(t *testing.T) { tests := []struct { s string width int want string }{ {"hi", 5, "hi "}, {"hello world", 20, "hello world "}, {"a very long string that exceeds the width", 10, "a very ..."}, {"short", 6, "short "}, } for _, tt := range tests { got := truncateOrPad(tt.s, tt.width) if got != tt.want { t.Errorf("truncateOrPad(%q, %d) = %q, want %q", tt.s, tt.width, got, tt.want) } } } func TestTruncateOrPadWideChars(t *testing.T) { got := truncateOrPad("\u4e16\u754c", 10) if !strings.HasSuffix(got, " ") { t.Errorf("expected trailing spaces for wide chars, got %q", got) } got2 := truncateOrPad("\u4e16\u754c\u4e16\u754c\u4e16\u754c", 6) if !strings.Contains(got2, "...") { t.Errorf("expected truncation for wide chars, got %q", got2) } } func TestRenderWorkerLineStatuses(t *testing.T) { tests := []struct { name string ws workerSlot want string }{ {"fail status", workerSlot{status: "FAIL", filename: "bad.jpg"}, "\u274c bad.jpg"}, {"skipped status", workerSlot{status: "skipped", filename: "skip.jpg"}, "\u23ed skip.jpg"}, {"skipped with size", workerSlot{status: "skipped", filename: "skip.jpg", size: 1024}, "\u23ed skip.jpg 1.0 KB"}, {"local completed", workerSlot{status: "", filename: "local.jpg", cloud: "local", size: 1024}, "\u2705 local.jpg 1.0 KB copied"}, {"local no size", workerSlot{status: "", filename: "local.jpg", cloud: "local", size: 0}, "\u2705 local.jpg copied"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := renderWorkerLine(tt.ws, 80) if !strings.Contains(got, tt.want) { t.Errorf("renderWorkerLine(%+v) = %q, want containing %q", tt.ws, got, tt.want) } }) } } func TestRenderWorkerLineCloudCompleted(t *testing.T) { ws := workerSlot{status: "", filename: "cloud.jpg", cloud: "cloud", size: 2048, speed: 512 * 1024} got := renderWorkerLine(ws, 80) if !strings.Contains(got, "\u2601 cloud.jpg 2.0 KB downloaded 512.0 KB/s") { t.Errorf("expected cloud completed line, got %q", got) } } func TestRenderWorkerLineCloudProgress(t *testing.T) { ws := workerSlot{status: "", filename: "cloud.jpg", cloud: "cloud", progress: 0.5, bytesTotal: 4096, bytesDone: 2048, speed: 1024 * 1024} got := renderWorkerLine(ws, 80) if !strings.Contains(got, "\u2601 cloud.jpg") { t.Errorf("expected cloud marker, got %q", got) } if !strings.Contains(got, "50%") { t.Errorf("expected 50%% progress, got %q", got) } } func TestUpdateWorkerProgress(t *testing.T) { var buf bytes.Buffer p := newProgressBar(&buf, 2) p.updateWorkerProgress(0, 0.5, 1024, 4096) p.mu.Lock() if p.workerState[0].progress != 0.5 { t.Errorf("expected progress 0.5, got %f", p.workerState[0].progress) } if p.workerState[0].bytesDone != 1024 { t.Errorf("expected bytesDone 1024, got %d", p.workerState[0].bytesDone) } p.mu.Unlock() } func TestUpdateWorkerProgressNegativeIndex(t *testing.T) { var buf bytes.Buffer p := newProgressBar(&buf, 1) p.updateWorkerProgress(-1, 0.5, 1024, 4096) p.mu.Lock() if p.workerState[0].progress != 0 { t.Error("negative index should be ignored") } p.mu.Unlock() } func TestEnsureScrollRegionSmallTerminal(t *testing.T) { var buf bytes.Buffer p := newProgressBar(&buf, 10) p.width = 40 p.termH = 5 p.ensureScrollRegion() if !p.scrollSet { t.Error("scrollSet should be true after ensureScrollRegion") } } func TestRenderBarPctAbove50(t *testing.T) { bar := renderBar(75, 20) if !strings.Contains(bar, "\x1b[") { t.Error("expected ANSI color codes in bar") } } func TestRenderBarFull(t *testing.T) { bar := renderBar(100, 20) if !strings.Contains(bar, "\u2588") { t.Error("expected full blocks in 100% bar") } } func TestRenderBarZero(t *testing.T) { bar := renderBar(0, 20) if !strings.Contains(bar, "\u2591") { t.Error("expected empty blocks in 0% bar") } } func TestRenderBarNegativeWidth(t *testing.T) { bar := renderBar(50, 0) if bar != "" { t.Errorf("expected empty bar for zero width, got %q", bar) } } func TestRenderLineWithETA(t *testing.T) { b := barLine{current: 50, total: 100, label: "Total", detail: ""} got := renderLine(b, 2*time.Second, 80) if !strings.Contains(got, "2s") { t.Errorf("expected duration in line, got %q", got) } } func TestRenderLineNoCounter(t *testing.T) { b := barLine{current: 0, total: 0, label: "Total", detail: "Venice"} got := renderLine(b, 0, 80) if !strings.Contains(got, "Venice") { t.Errorf("expected detail in line, got %q", got) } } func TestRenderLineAlbumLabel(t *testing.T) { b := barLine{current: 3, total: 5, label: "Album", detail: "Venice"} got := renderLine(b, 0, 80) if !strings.Contains(got, "Venice 3/5") { t.Errorf("expected Album format with detail and counter, got %q", got) } } func TestCmdBackupAllSortNewest(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, assetsByAlbum: map[string][]photos.Asset{ "a1": {{ID: "x1", Filename: "img.jpg"}, {ID: "x2", Filename: "img2.jpg"}}, }, } dir := t.TempDir() _, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--sort", "newest", "--no-manifest"}, b) if rc != 0 { t.Fatalf("rc = %d, stderr = %q", rc, stderr) } if !strings.Contains(stderr, "exported") { t.Errorf("expected export output, got: %q", stderr) } } func TestCmdBackupAllSortInvalid(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, assetsByAlbum: map[string][]photos.Asset{ "a1": {{ID: "x1", Filename: "img.jpg"}}, }, } _, stderr, rc := runWith([]string{"backup-all", "--out", "/tmp", "--sort", "invalid", "--no-manifest"}, b) if rc != 1 { t.Errorf("expected rc=1 for invalid --sort, got %d", rc) } if !strings.Contains(stderr, "--sort must be newest or oldest") { t.Errorf("expected sort error, got: %q", stderr) } } func TestCmdExportSortNewest(t *testing.T) { dateNew := "2024-06-01" dateOld := "2024-01-01" b := &mockBridge{ assets: []photos.Asset{ {ID: "x1", Filename: "old.jpg", CreationDate: &dateOld}, {ID: "x2", Filename: "new.jpg", CreationDate: &dateNew}, {ID: "x3", Filename: "nil.jpg"}, }, } dir := t.TempDir() _, _, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--sort", "newest", "--no-manifest"}, b) if rc != 0 { t.Fatalf("rc = %d", rc) } } func TestCmdExportSortInvalid(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, } _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--sort", "bad"}, b) if rc != 1 { t.Errorf("expected rc=1 for invalid --sort, got %d", rc) } if !strings.Contains(stderr, "--sort must be newest or oldest") { t.Errorf("expected sort error, got: %q", stderr) } } func TestCollectPendingAssetsSortNewest(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, assetsByAlbum: map[string][]photos.Asset{ "a1": { {ID: "old", Filename: "old.jpg", CreationDate: strPtr("2020-01-01T00:00:00Z")}, {ID: "new", Filename: "new.jpg", CreationDate: strPtr("2024-06-01T00:00:00Z")}, }, }, } var progressCalls []collectProgress items, _ := collectPendingAssets(b.tree, "/out", b, false, false, func(p collectProgress) { progressCalls = append(progressCalls, p) }, nil, true, nil, time.Time{}, exportOptions{}) if len(items) != 2 { t.Fatalf("expected 2 items, got %d", len(items)) } if items[0].asset.ID != "new" { t.Errorf("expected newest first, got %s then %s", items[0].asset.ID, items[1].asset.ID) } } func TestCollectPendingAssetsSortOldest(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, assetsByAlbum: map[string][]photos.Asset{ "a1": { {ID: "new", Filename: "new.jpg", CreationDate: strPtr("2024-06-01T00:00:00Z")}, {ID: "old", Filename: "old.jpg", CreationDate: strPtr("2020-01-01T00:00:00Z")}, }, }, } items, _ := collectPendingAssets(b.tree, "/out", b, false, false, nil, nil, false, nil, time.Time{}, exportOptions{}) if len(items) != 2 { t.Fatalf("expected 2 items, got %d", len(items)) } if items[0].asset.ID != "new" { t.Errorf("oldest sort should preserve order, got %s first", items[0].asset.ID) } } func strPtr(s string) *string { return &s } func TestCmdExportManifestSQLite(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, } dir := t.TempDir() _, _, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--manifest", "sqlite"}, b) if rc != 0 { t.Fatalf("rc = %d", rc) } if _, err := os.Stat(filepath.Join(dir, "downloads.db")); err != nil { t.Errorf("sqlite manifest should exist: %v", err) } } func TestCmdBackupAllManifestSQLite(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, assetsByAlbum: map[string][]photos.Asset{ "a1": {{ID: "x1", Filename: "img.jpg"}}, }, } dir := t.TempDir() _, _, rc := runWith([]string{"backup-all", "--out", dir, "--manifest", "sqlite"}, b) if rc != 0 { t.Fatalf("rc = %d", rc) } if _, err := os.Stat(filepath.Join(dir, "downloads.db")); err != nil { t.Errorf("sqlite manifest should exist: %v", err) } } func TestCmdExportInvalidManifestFormat(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, } _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--manifest", "csv", "--no-manifest"}, b) if rc != 1 { t.Errorf("expected rc=1 for invalid manifest format, got %d", rc) } if !strings.Contains(stderr, "unknown manifest format") { t.Errorf("expected format error, got: %q", stderr) } } func TestCmdBackupAllInvalidManifestFormat(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{}, } _, stderr, rc := runWith([]string{"backup-all", "--out", "/tmp", "--manifest", "csv"}, b) if rc != 1 { t.Errorf("expected rc=1 for invalid manifest format, got %d", rc) } if !strings.Contains(stderr, "unknown manifest format") { t.Errorf("expected format error, got: %q", stderr) } } func TestCmdExportResolveAlbumIDError(t *testing.T) { b := &mockBridge{ albums: []photos.Album{{ID: "a1", Title: "Album"}}, assetsByAlbum: map[string][]photos.Asset{}, } _, stderr, rc := runWith([]string{"export", "--album-id", "Nonexistent", "--out", "/tmp", "--no-manifest"}, b) if rc != 1 { t.Errorf("expected rc=1, got %d", rc) } if !strings.Contains(stderr, "error") { t.Errorf("expected error in stderr, got: %q", stderr) } } func TestCmdExportListAssetsAfterResolveErr(t *testing.T) { b := &mockBridge{ albums: []photos.Album{{ID: "x", Title: "Album"}}, assetsByAlbumErr: map[string]error{"x": fmt.Errorf("fetch failed")}, } _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--no-manifest"}, b) if rc != 1 { t.Errorf("expected rc=1, got %d", rc) } if !strings.Contains(stderr, "error") { t.Errorf("expected error, got: %q", stderr) } } func TestSortNewestNilCreationDate(t *testing.T) { assets := []photos.Asset{ {ID: "a", Filename: "a.jpg"}, {ID: "b", Filename: "b.jpg"}, } sort.Slice(assets, func(i, j int) bool { di := assets[i].CreationDate dj := assets[j].CreationDate if di == nil && dj == nil { return assets[i].ID < assets[j].ID } if di == nil { return false } if dj == nil { return true } return *di > *dj }) if assets[0].ID != "a" { t.Errorf("both nil: expected sorted by ID, got %s first", assets[0].ID) } } func TestSortNewestMixedCreationDate(t *testing.T) { assets := []photos.Asset{ {ID: "a", Filename: "a.jpg"}, {ID: "b", Filename: "b.jpg", CreationDate: strPtr("2024-01-01T00:00:00Z")}, } sort.Slice(assets, func(i, j int) bool { di := assets[i].CreationDate dj := assets[j].CreationDate if di == nil && dj == nil { return assets[i].ID < assets[j].ID } if di == nil { return false } if dj == nil { return true } return *di > *dj }) if assets[0].ID != "b" { t.Errorf("newest first: expected b (has date) before a (nil), got %s", assets[0].ID) } } func TestSortNewestSecondNil(t *testing.T) { assets := []photos.Asset{ {ID: "a", Filename: "a.jpg", CreationDate: strPtr("2024-01-01T00:00:00Z")}, {ID: "b", Filename: "b.jpg"}, } sort.Slice(assets, func(i, j int) bool { di := assets[i].CreationDate dj := assets[j].CreationDate if di == nil && dj == nil { return assets[i].ID < assets[j].ID } if di == nil { return false } if dj == nil { return true } return *di > *dj }) if assets[0].ID != "a" { t.Errorf("newest first: expected a (has date) before b (nil), got %s", assets[0].ID) } } func TestCollectNodesNilProgressListAssetsError(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, assetsErr: fmt.Errorf("fetch error"), } var items []pendingAsset var skipped int collectNodes(b.tree, "/out", b, false, false, &items, &skipped, nil, nil, nil, exportOptions{}) if len(items) != 0 { t.Errorf("expected 0 items on error, got %d", len(items)) } } func TestBackupTreeManifestOpenAppendError(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, assetsByAlbum: map[string][]photos.Asset{ "a1": {{ID: "x1", Filename: "img.jpg"}}, }, } var stderr bytes.Buffer _, _, err := backupTree(b.tree, "/tmp/test-backup-manifest-err", 1024, 85, 3, false, false, &stderr, b, false, manifest.FormatJSONL, false, nil, time.Time{}, false, exportOptions{}) if err != nil { t.Errorf("unexpected error: %v", err) } } func TestExportAssetsSaveError(t *testing.T) { dir := t.TempDir() b := &mockBridge{ assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, } var stderr bytes.Buffer exported, failed := exportAssets(b.assets, dir, 1024, 85, 3, false, 1, &stderr, b, "", false, manifest.FormatJSONL, false, exportOptions{}) if exported != 1 { t.Errorf("expected 1 exported, got %d", exported) } if failed != 0 { t.Errorf("expected 0 failed, got %d", failed) } } func TestExportPendingSerialCancelled(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}, {ID: "x2", Filename: "img2.jpg"}}, } b.Cancel() pending := []pendingAsset{{asset: b.assets[0], path: "/tmp", album: "test"}} var buf bytes.Buffer bar := newProgressBar(&buf, 1) done, _ := exportPendingSerial(pending, 1024, 85, false, 1, bar, b, nil, manifest.NoopLogWriter, exportOptions{}) if done != 0 { t.Errorf("expected 0 done when cancelled, got %d", done) } } func TestExportPendingSerialSkippedNoProgress(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, } b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) { return photos.ExportResult{Filename: "img.jpg", Size: 0, Skipped: true, Cloud: "local"}, nil } pending := []pendingAsset{{asset: b.assets[0], path: "/tmp", album: "test"}} var buf bytes.Buffer bar := newProgressBar(&buf, 1) done, failed := exportPendingSerial(pending, 1024, 85, false, 1, bar, b, nil, manifest.NoopLogWriter, exportOptions{}) if done != 0 { t.Errorf("expected 0 done for skipped, got %d", done) } if failed != 0 { t.Errorf("expected 0 failed for skipped, got %d", failed) } } func TestCmdExportNoManifestFlag(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, } dir := t.TempDir() _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--no-manifest"}, b) if rc != 0 { t.Fatalf("rc = %d, stderr = %q", rc, stderr) } if strings.Contains(stderr, "manifest") { t.Errorf("should not mention manifest with --no-manifest, got: %q", stderr) } } func TestRunMainNormal(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, } dir := t.TempDir() rc := runMain([]string{"export", "--album-id", "x", "--out", dir, "--no-manifest"}, os.Stdout, os.Stderr, b) if rc != 0 { t.Errorf("expected rc 0, got %d", rc) } } func TestRunMainCancelled(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, } b.Cancel() dir := t.TempDir() rc := runMain([]string{"export", "--album-id", "x", "--out", dir, "--no-manifest"}, os.Stdout, os.Stderr, b) if rc != 0 { t.Errorf("expected rc 0 for cancelled (still returns 0), got %d", rc) } } func TestRunMainNoArgs(t *testing.T) { rc := runMain(nil, os.Stdout, os.Stderr, &mockBridge{}) if rc != 1 { t.Errorf("expected rc 1 for no args, got %d", rc) } } func TestRunMainSignal(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, } b.Cancel() sigCh := make(chan struct{}) close(sigCh) rc := runMainWithSignal([]string{"export", "--album-id", "x", "--out", t.TempDir(), "--no-manifest"}, os.Stdout, os.Stderr, b, sigCh) if rc != 0 { t.Errorf("expected rc 0, got %d", rc) } } func TestCmdPhotosListAssetsError(t *testing.T) { b := &mockBridge{ albums: []photos.Album{{ID: "x", Title: "Album"}}, assetsByAlbumErr: map[string]error{"x": fmt.Errorf("fetch failed")}, } var buf bytes.Buffer rc := cmdPhotos([]string{"--album-id", "x"}, &buf, &buf, b) if rc != 1 { t.Errorf("expected rc 1, got %d", rc) } } func TestCmdExportListAssetsAfterResolveError(t *testing.T) { b := &mockBridge{ albums: []photos.Album{{ID: "x", Title: "Album"}}, assetsByAlbumErr: map[string]error{"x": fmt.Errorf("fetch failed")}, } var buf bytes.Buffer rc := cmdExport([]string{"--album-id", "x", "--out", "/tmp", "--no-manifest"}, &buf, &buf, b) if rc != 1 { t.Errorf("expected rc 1, got %d", rc) } } func TestCmdExportOriginalsFlag(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "x1", Filename: "img.heic"}}, } dir := t.TempDir() _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--originals", "--no-manifest"}, b) if rc != 0 { t.Fatalf("rc = %d, stderr = %q", rc, stderr) } if !strings.Contains(stderr, "originals") { t.Errorf("expected originals in output, got: %s", stderr) } } func TestCmdExportBadSize(t *testing.T) { b := &mockBridge{} var buf bytes.Buffer rc := cmdExport([]string{"--album-id", "x", "--out", "/tmp", "--size", "abc"}, &buf, &buf, b) if rc != 1 { t.Errorf("expected rc 1 for invalid size, got %d", rc) } } func TestCmdExportZeroSize(t *testing.T) { b := &mockBridge{} var buf bytes.Buffer rc := cmdExport([]string{"--album-id", "x", "--out", "/tmp", "--size", "0"}, &buf, &buf, b) if rc != 1 { t.Errorf("expected rc 1 for zero size, got %d", rc) } } func TestCmdExportBadSort(t *testing.T) { b := &mockBridge{} var buf bytes.Buffer rc := cmdExport([]string{"--album-id", "x", "--out", "/tmp", "--sort", "invalid"}, &buf, &buf, b) if rc != 1 { t.Errorf("expected rc 1 for invalid sort, got %d", rc) } } func TestCmdExportAllExportsFail(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, } b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) { return photos.ExportResult{}, fmt.Errorf("export error") } dir := t.TempDir() _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--no-manifest"}, b) if rc != 1 { t.Errorf("expected rc 1 when all exports fail, got %d", rc) } if !strings.Contains(stderr, "all exports failed") { t.Errorf("expected 'all exports failed' in stderr, got: %s", stderr) } } func TestCmdBackupAllCancelledTree(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{ {Name: "Album1", Kind: "album", ID: "a1", Children: []photos.CollectionNode{ {Kind: "asset", ID: "x1", Name: "photo.jpg"}, }}, }, } b.Cancel() var stderr bytes.Buffer _, _, err := backupTree(b.tree, "/tmp/test-backup-cancelled", 1024, 85, 3, false, false, &stderr, b, true, manifest.FormatJSONL, false, nil, time.Time{}, false, exportOptions{}) if err == nil { t.Error("cancelled backupTree should return error") } } func TestCmdBackupAllOriginalsFlag(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{ {Name: "Album1", Kind: "album", ID: "a1"}, }, } dir := t.TempDir() _, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--originals", "--no-manifest"}, b) if rc != 0 { t.Errorf("rc = %d, stderr = %q", rc, stderr) } if !strings.Contains(stderr, "original files") { t.Errorf("expected 'original files' in output, got: %s", stderr) } } func TestCmdBackupAllBadSort(t *testing.T) { b := &mockBridge{tree: []photos.CollectionNode{}} var buf bytes.Buffer rc := cmdBackupAll([]string{"--out", "/tmp", "--sort", "invalid", "--no-manifest"}, &buf, &buf, b) if rc != 1 { t.Errorf("expected rc 1 for invalid sort, got %d", rc) } } func TestCmdBackupAllBadSize(t *testing.T) { b := &mockBridge{tree: []photos.CollectionNode{}} var buf bytes.Buffer rc := cmdBackupAll([]string{"--out", "/tmp", "--size", "abc"}, &buf, &buf, b) if rc != 1 { t.Errorf("expected rc 1 for invalid size, got %d", rc) } } func TestCmdBackupAllTreeErr(t *testing.T) { b := &mockBridge{treeErr: fmt.Errorf("tree error")} var buf bytes.Buffer rc := cmdBackupAll([]string{"--out", "/tmp", "--no-manifest"}, &buf, &buf, b) if rc != 1 { t.Errorf("expected rc 1, got %d", rc) } } func TestCollectPendingAssetsCancelledDuringCollect(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{ {Name: "Root", Kind: "folder", Children: []photos.CollectionNode{ {Name: "Album1", Kind: "album", ID: "a1"}, }}, }, } b.Cancel() items, _ := collectPendingAssets(b.tree, "/tmp", b, false, false, nil, nil, false, nil, time.Time{}, exportOptions{}) if len(items) != 0 { t.Errorf("expected 0 items when cancelled, got %d", len(items)) } } func TestCollectPendingAssetsAlbumErr(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{ {Name: "Album1", Kind: "album", ID: "a1"}, }, assetsErr: fmt.Errorf("album error"), } var progressErrors []string items, _ := collectPendingAssets(b.tree, "/tmp", b, false, false, func(p collectProgress) { if p.err != nil { progressErrors = append(progressErrors, p.err.Error()) } }, nil, false, nil, time.Time{}, exportOptions{}) if len(progressErrors) == 0 { t.Error("expected progress error for album error") } if len(items) != 0 { t.Errorf("expected 0 items, got %d", len(items)) } } func TestExportPendingParallelSmoke(t *testing.T) { assets := make([]photos.Asset, 5) for i := range assets { assets[i] = photos.Asset{ID: fmt.Sprintf("x%d", i), Filename: fmt.Sprintf("img%d.jpg", i)} } b := &mockBridge{assets: assets} pending := make([]pendingAsset, len(assets)) for i, a := range assets { pending[i] = pendingAsset{asset: a, path: "/tmp", album: "test"} } var buf bytes.Buffer bar := newProgressBar(&buf, 3) done, failed := exportPendingParallel(pending, 1024, 85, false, len(pending), bar, b, 3, nil, manifest.NoopLogWriter, exportOptions{}) if done != 5 { t.Errorf("expected 5 done, got %d", done) } if failed != 0 { t.Errorf("expected 0 failed, got %d", failed) } } func TestExportPendingParallelManifestAdd(t *testing.T) { dir := t.TempDir() mf := manifest.LoadJSONL(dir) if err := mf.OpenAppend(); err != nil { t.Fatal(err) } assets := make([]photos.Asset, 5) for i := range assets { assets[i] = photos.Asset{ID: fmt.Sprintf("m%d", i), Filename: fmt.Sprintf("img%d.jpg", i)} } b := &mockBridge{assets: assets} pending := make([]pendingAsset, len(assets)) for i, a := range assets { pending[i] = pendingAsset{asset: a, path: dir, album: "test"} } var buf bytes.Buffer bar := newProgressBar(&buf, 3) done, _ := exportPendingParallel(pending, 1024, 85, false, len(pending), bar, b, 3, mf, manifest.NoopLogWriter, exportOptions{}) if done != 5 { t.Errorf("expected 5 done, got %d", done) } mf.Close() for i := range assets { if !mf.Has(assets[i].ID) { t.Errorf("manifest should have %s", assets[i].ID) } } } func TestExportPendingParallelChecksumError(t *testing.T) { dir := t.TempDir() mf := manifest.LoadJSONL(dir) if err := mf.OpenAppend(); err != nil { t.Fatal(err) } b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}} b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) { return photos.ExportResult{Filename: "missing.jpg", Size: 3}, nil } bar := newProgressBar(io.Discard, 1) done, failed := exportPendingParallel([]pendingAsset{{asset: b.assets[0], path: dir}}, 1024, 85, false, 1, bar, b, 1, mf, manifest.NoopLogWriter, exportOptions{checksum: "sha256"}) if done != 0 || failed != 1 { t.Fatalf("done=%d failed=%d", done, failed) } } func TestExportPendingParallelCancel(t *testing.T) { b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}} b.Cancel() pending := []pendingAsset{{asset: b.assets[0], path: "/tmp", album: "test"}} var buf bytes.Buffer bar := newProgressBar(&buf, 3) done, _ := exportPendingParallel(pending, 1024, 85, false, 1, bar, b, 3, nil, manifest.NoopLogWriter, exportOptions{}) if done != 0 { t.Errorf("expected 0 done when cancelled, got %d", done) } } func TestExportPendingParallelErr(t *testing.T) { assets := make([]photos.Asset, 5) for i := range assets { assets[i] = photos.Asset{ID: fmt.Sprintf("e%d", i), Filename: fmt.Sprintf("img%d.jpg", i)} } b := &mockBridge{assets: assets} b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) { return photos.ExportResult{}, fmt.Errorf("export error") } pending := make([]pendingAsset, len(assets)) for i, a := range assets { pending[i] = pendingAsset{asset: a, path: "/tmp", album: "test"} } var buf bytes.Buffer bar := newProgressBar(&buf, 3) done, failed := exportPendingParallel(pending, 1024, 85, false, len(pending), bar, b, 3, nil, manifest.NoopLogWriter, exportOptions{}) if failed != 5 { t.Errorf("expected 5 failed, got %d", failed) } if done != 0 { t.Errorf("expected 0 done, got %d", done) } } func TestExportPendingParallelSkipped(t *testing.T) { assets := []photos.Asset{ {ID: "s1", Filename: "skip.jpg"}, {ID: "c1", Filename: "cloud.jpg", Cloud: "cloud"}, } b := &mockBridge{assets: assets} var callCount int64 b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) { n := atomic.AddInt64(&callCount, 1) if n == 1 { return photos.ExportResult{Filename: "skip.jpg", Size: 0, Skipped: true, Cloud: "local"}, nil } return photos.ExportResult{Filename: "cloud.jpg", Size: 2048, Cloud: "cloud"}, nil } pending := []pendingAsset{ {asset: assets[0], path: "/tmp", album: "test"}, {asset: assets[1], path: "/tmp", album: "test"}, } var buf bytes.Buffer bar := newProgressBar(&buf, 3) done, failed := exportPendingParallel(pending, 1024, 85, false, 2, bar, b, 3, nil, manifest.NoopLogWriter, exportOptions{}) if done != 1 { t.Errorf("expected 1 done (cloud only), got %d", done) } if failed != 0 { t.Errorf("expected 0 failed, got %d", failed) } } func TestExportPendingParallelSkippedWritesToManifest(t *testing.T) { dir := t.TempDir() mf := manifest.LoadJSONL(dir) if err := mf.OpenAppend(); err != nil { t.Fatal(err) } assets := []photos.Asset{ {ID: "s1", Filename: "skip.jpg"}, {ID: "c1", Filename: "cloud.jpg", Cloud: "cloud"}, } b := &mockBridge{assets: assets} var callCount int64 b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) { n := atomic.AddInt64(&callCount, 1) if n == 1 { return photos.ExportResult{Filename: "skip.jpg", Size: 512, Skipped: true, Cloud: "local"}, nil } return photos.ExportResult{Filename: "cloud.jpg", Size: 2048, Cloud: "cloud"}, nil } pending := []pendingAsset{ {asset: assets[0], path: dir, album: "test"}, {asset: assets[1], path: dir, album: "test"}, } var buf bytes.Buffer bar := newProgressBar(&buf, 3) done, failed := exportPendingParallel(pending, 1024, 85, false, 2, bar, b, 3, mf, manifest.NoopLogWriter, exportOptions{}) if done != 1 { t.Errorf("expected 1 done (cloud only), got %d", done) } if failed != 0 { t.Errorf("expected 0 failed, got %d", failed) } mf.Close() if !mf.Has("s1") { t.Error("manifest should have s1 after skipped parallel export") } if !mf.Has("c1") { t.Error("manifest should have c1 after parallel export") } } func TestExportPendingParallelOrig(t *testing.T) { assets := make([]photos.Asset, 5) for i := range assets { assets[i] = photos.Asset{ID: fmt.Sprintf("o%d", i), Filename: fmt.Sprintf("img%d.heic", i)} } b := &mockBridge{assets: assets} pending := make([]pendingAsset, len(assets)) for i, a := range assets { pending[i] = pendingAsset{asset: a, path: "/tmp", album: "test"} } var buf bytes.Buffer bar := newProgressBar(&buf, 3) done, failed := exportPendingParallel(pending, 1024, 85, true, len(pending), bar, b, 3, nil, manifest.NoopLogWriter, exportOptions{}) if done != 5 { t.Errorf("expected 5 done, got %d", done) } if failed != 0 { t.Errorf("expected 0 failed, got %d", failed) } } func TestExportAssetsManifestWrite(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, } dir := t.TempDir() var buf bytes.Buffer done, failed := exportAssets(b.assets, dir, 1024, 85, 3, false, 1, &buf, b, "", false, manifest.FormatJSONL, false, exportOptions{}) if done != 1 { t.Errorf("expected 1 done, got %d", done) } if failed != 0 { t.Errorf("expected 0 failed, got %d", failed) } } func TestExportAssetsChecksum(t *testing.T) { b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}} dir := t.TempDir() b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) { if err := os.WriteFile(filepath.Join(out, "img.jpg"), []byte("abc"), 0644); err != nil { return photos.ExportResult{}, err } return photos.ExportResult{Filename: "img.jpg", Size: 3, Cloud: "local"}, nil } done, failed := exportAssets(b.assets, dir, 1024, 85, 1, false, 1, io.Discard, b, "", false, manifest.FormatJSONL, false, exportOptions{checksum: "sha256"}) if done != 1 || failed != 0 { t.Fatalf("done=%d failed=%d", done, failed) } entry := manifest.LoadJSONL(dir).Entries()["x1"] if entry.Checksum != "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" { t.Fatalf("checksum=%q", entry.Checksum) } got, err := fileSHA256(filepath.Join(dir, "img.jpg")) if err != nil || got != entry.Checksum { t.Fatalf("fileSHA256 got=%q err=%v", got, err) } if _, err := fileSHA256(filepath.Join(dir, "missing.jpg")); err == nil { t.Fatal("expected missing checksum error") } if _, err := fileSHA256(dir); err == nil { t.Fatal("expected directory checksum error") } if err := addManifestEntryForResult(nil, pendingAsset{}, photos.ExportResult{}, exportOptions{}); err != nil { t.Fatalf("nil manifest add error: %v", err) } if err := addManifestEntryForResult(manifest.LoadJSONL(dir), pendingAsset{asset: photos.Asset{ID: "skip"}, path: dir}, photos.ExportResult{Filename: "missing.jpg", Skipped: true}, exportOptions{checksum: "sha256"}); err != nil { t.Fatalf("skipped checksum add error: %v", err) } } func TestExportAssetsChecksumErrors(t *testing.T) { dir := t.TempDir() b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}} b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) { return photos.ExportResult{Filename: "missing.jpg", Size: 3}, nil } done, failed := exportAssets(b.assets, dir, 1024, 85, 1, false, 1, io.Discard, b, "", false, manifest.FormatJSONL, false, exportOptions{checksum: "sha256"}) if done != 0 || failed != 1 { t.Fatalf("serial checksum error done=%d failed=%d", done, failed) } dir = t.TempDir() done, failed = exportAssets(b.assets, dir, 1024, 85, 2, false, 2, io.Discard, b, "", false, manifest.FormatJSONL, false, exportOptions{checksum: "sha256"}) if done != 0 || failed != 1 { t.Fatalf("parallel checksum error done=%d failed=%d", done, failed) } } func TestBackupTreeManifestOpenErr(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{ {Name: "Album1", Kind: "album", ID: "a1"}, }, assetsByAlbum: map[string][]photos.Asset{ "a1": {{ID: "x1", Filename: "img.jpg"}}, }, } dir := t.TempDir() dbPath := manifest.SQLitePath(dir) os.WriteFile(dbPath, []byte("not a sqlite file"), 0644) var buf bytes.Buffer _, _, err := backupTree(b.tree, dir, 1024, 85, 3, false, false, &buf, b, false, manifest.FormatJSONL, false, nil, time.Time{}, false, exportOptions{}) if err != nil { t.Logf("backupTree returned: %v", err) } if !strings.Contains(buf.String(), "could not open manifest") { t.Errorf("expected manifest warning, got: %q", buf.String()) } } func TestCmdExportResolveAlbumErr(t *testing.T) { b := &mockBridge{albumsErr: fmt.Errorf("album error")} var buf bytes.Buffer rc := cmdExport([]string{"--album-id", "x", "--out", "/tmp", "--no-manifest"}, &buf, &buf, b) if rc != 1 { t.Errorf("expected rc 1, got %d", rc) } } func TestCmdBackupAllAuthErr(t *testing.T) { b := &mockBridge{accessErr: fmt.Errorf("access denied")} var buf bytes.Buffer rc := cmdBackupAll([]string{"--out", "/tmp", "--no-manifest"}, &buf, &buf, b) if rc != exitAuth { t.Errorf("expected rc %d, got %d", exitAuth, rc) } } func TestCmdExportAuthErr(t *testing.T) { b := &mockBridge{accessErr: fmt.Errorf("access denied")} var buf bytes.Buffer rc := cmdExport([]string{"--album-id", "x", "--out", "/tmp", "--no-manifest"}, &buf, &buf, b) if rc != exitAuth { t.Errorf("expected rc %d, got %d", exitAuth, rc) } } func TestCmdPhotosAuthErr(t *testing.T) { b := &mockBridge{accessErr: fmt.Errorf("access denied")} var buf bytes.Buffer rc := cmdPhotos([]string{"--album-id", "x"}, &buf, &buf, b) if rc != exitAuth { t.Errorf("expected rc %d, got %d", exitAuth, rc) } } func TestCollectPendingAssetsSorted(t *testing.T) { dateOld := "2024-01-01" dateNew := "2024-06-01" b := &mockBridge{ tree: []photos.CollectionNode{ {Name: "Album1", Kind: "album", ID: "a1"}, }, assets: []photos.Asset{ {ID: "old", Filename: "old.jpg", CreationDate: &dateOld}, {ID: "new", Filename: "new.jpg", CreationDate: &dateNew}, }, } items, _ := collectPendingAssets(b.tree, "/tmp", b, false, false, nil, nil, true, nil, time.Time{}, exportOptions{}) if len(items) != 2 { t.Fatalf("expected 2 items, got %d", len(items)) } if items[0].asset.ID != "new" { t.Errorf("expected newest first, got %s", items[0].asset.ID) } } func TestCollectPendingAssetsManifestSkip(t *testing.T) { dir := t.TempDir() mf := manifest.LoadJSONL(dir) if err := mf.OpenAppend(); err != nil { t.Fatal(err) } mf.Add("x1", "img.jpg", 1024, "local") mf.Close() b := &mockBridge{ tree: []photos.CollectionNode{ {Name: "Album1", Kind: "album", ID: "a1"}, }, assets: []photos.Asset{ {ID: "x1", Filename: "img.jpg"}, {ID: "x2", Filename: "new.jpg"}, }, } items, skipped := collectPendingAssets(b.tree, "/tmp", b, false, false, nil, mf, false, nil, time.Time{}, exportOptions{}) if skipped != 1 { t.Errorf("expected 1 skipped, got %d", skipped) } if len(items) != 1 { t.Errorf("expected 1 item, got %d", len(items)) } } func TestExportPendingChoiceParallelPath(t *testing.T) { assets := make([]photos.Asset, 5) for i := range assets { assets[i] = photos.Asset{ID: fmt.Sprintf("p%d", i), Filename: fmt.Sprintf("img%d.jpg", i)} } b := &mockBridge{assets: assets} pending := make([]pendingAsset, len(assets)) for i, a := range assets { pending[i] = pendingAsset{asset: a, path: "/tmp", album: "test"} } var buf bytes.Buffer bar := newProgressBar(&buf, 3) done, failed := exportPending(pending, 1024, 85, false, len(pending), bar, b, nil, 3, manifest.NoopLogWriter, exportOptions{}) if done != 5 { t.Errorf("expected 5 done, got %d", done) } if failed != 0 { t.Errorf("expected 0 failed, got %d", failed) } } func TestCmdExportNoAlbumIDArg(t *testing.T) { b := &mockBridge{} var buf bytes.Buffer rc := cmdExport([]string{"--out", "/tmp"}, &buf, &buf, b) if rc != 1 { t.Errorf("expected rc 1 for missing --album-id, got %d", rc) } } func TestCmdBackupAllNoOutArg(t *testing.T) { b := &mockBridge{} var buf bytes.Buffer rc := cmdBackupAll([]string{}, &buf, &buf, b) if rc != 1 { t.Errorf("expected rc 1 for missing --out, got %d", rc) } } func TestRenderLineETADisplay(t *testing.T) { line := renderLine(barLine{current: 50, total: 100, label: "Total", detail: "test"}, 10*time.Second, 80) if !strings.Contains(line, "Total") { t.Error("expected Total in line") } } func TestRenderLineZeroWidthDisplay(t *testing.T) { line := renderLine(barLine{current: 50, total: 100, label: "Total"}, 0, 0) if !strings.Contains(line, "Total") { t.Error("expected Total in line with zero width") } } func TestRenderLineAlbumWithCounterDisplay(t *testing.T) { line := renderLine(barLine{current: 5, total: 10, label: "Album", detail: "Vacation"}, 5*time.Second, 80) if !strings.Contains(line, "Vacation") { t.Error("expected Vacation in line") } } func TestTruncateOrPadLong(t *testing.T) { longStr := strings.Repeat("x", 100) result := truncateOrPad(longStr, 20) if !strings.HasSuffix(result, "...") { t.Error("expected truncation with ...") } } func TestTruncateOrPadZero(t *testing.T) { result := truncateOrPad("hello", 0) if result == "" { t.Error("expected non-empty result for zero width") } } func TestRenderWorkerLineStatusTable(t *testing.T) { tests := []struct { name string ws workerSlot contains string }{ {"fail status", workerSlot{filename: "test.jpg", status: "FAIL"}, "\u274c"}, {"skipped status", workerSlot{filename: "test.jpg", status: "skipped", size: 1024}, "\u23ed"}, {"cloud in progress", workerSlot{filename: "test.jpg", cloud: "cloud", progress: 0.5, bytesTotal: 2048, bytesDone: 1024, speed: 1024.0}, "\u2601"}, {"cloud downloaded", workerSlot{filename: "test.jpg", cloud: "cloud", size: 2048}, "\u2601"}, {"local copied", workerSlot{filename: "test.jpg", size: 1024}, "\u2705"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { line := renderWorkerLine(tt.ws, 80) if !strings.Contains(line, tt.contains) { t.Errorf("expected %q in %q", tt.contains, line) } }) } } func TestRenderWorkerLineZeroWidthDisplay(t *testing.T) { line := renderWorkerLine(workerSlot{filename: "test.jpg", size: 100}, 0) if !strings.Contains(line, "test.jpg") { t.Error("expected filename in line") } } func TestRenderWorkerLineCloudProgressWithSpeedDisplay(t *testing.T) { ws := workerSlot{filename: "big.jpg", cloud: "cloud", progress: 0.75, bytesTotal: 4096, bytesDone: 3072, speed: 512.0} line := renderWorkerLine(ws, 80) if !strings.Contains(line, "75%") { t.Errorf("expected 75%% in line, got: %s", line) } } func TestEnsureScrollRegionDisplay(t *testing.T) { var buf bytes.Buffer bar := newProgressBar(&buf, 3) bar.ensureScrollRegion() if !bar.scrollSet { t.Error("expected scrollSet to be true after ensureScrollRegion") } } func TestDrawFooterLockedEmptyWorkerDisplay(t *testing.T) { var buf bytes.Buffer bar := newProgressBar(&buf, 3) bar.setTotal(0, 10, "") bar.draw() bar.clear() } func TestCmdExportIncludeVideosFlag(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{ {ID: "1", Filename: "photo.jpg", MediaType: "image"}, {ID: "2", Filename: "vid.mov", MediaType: "video"}, }, } dir := t.TempDir() _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--include-videos", "--no-manifest"}, b) if rc != 0 { t.Errorf("rc = %d, stderr = %q", rc, stderr) } } func TestCmdExportWithManifestFlag(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, } dir := t.TempDir() _, _, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--manifest", "jsonl"}, b) if rc != 0 { t.Fatalf("expected rc 0") } if _, err := os.Stat(filepath.Join(dir, "downloads.jsonl")); err != nil { t.Errorf("expected manifest file to exist: %v", err) } } func TestCmdExportFailedExports(t *testing.T) { b := &mockBridge{ albums: []photos.Album{{ID: "x", Title: "Album"}}, assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, } b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) { return photos.ExportResult{}, fmt.Errorf("export error") } var buf bytes.Buffer rc := cmdExport([]string{"--album-id", "x", "--out", "/tmp", "--no-manifest"}, &buf, &buf, b) if rc != 1 { t.Errorf("expected rc 1 when all exports fail, got %d", rc) } } func TestCmdExportOriginalsMode(t *testing.T) { b := &mockBridge{ albums: []photos.Album{{ID: "x", Title: "Album"}}, assets: []photos.Asset{{ID: "x1", Filename: "img.heic"}}, } dir := t.TempDir() _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--originals", "--no-manifest"}, b) if rc != 0 { t.Fatalf("rc = %d, stderr = %q", rc, stderr) } if !strings.Contains(stderr, "originals") { t.Errorf("expected 'originals' in output") } } func TestCmdBackupAllFailedManifestSave(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{ {Name: "Album1", Kind: "album", ID: "a1"}, }, } dir := t.TempDir() _, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--manifest", "jsonl"}, b) if rc != 0 { t.Fatalf("rc = %d, stderr = %q", rc, stderr) } _ = stderr } func TestCollectPendingAssetsSortNilDates(t *testing.T) { dateNew := "2024-06-01" b := &mockBridge{ tree: []photos.CollectionNode{ {Name: "Album1", Kind: "album", ID: "a1"}, }, assets: []photos.Asset{ {ID: "x1", Filename: "nil.jpg"}, {ID: "x2", Filename: "new.jpg", CreationDate: &dateNew}, }, } items, _ := collectPendingAssets(b.tree, "/tmp", b, false, false, nil, nil, true, nil, time.Time{}, exportOptions{}) if len(items) != 2 { t.Fatalf("expected 2 items, got %d", len(items)) } if items[0].asset.ID != "x2" { t.Errorf("expected newest (non-nil) first, got %s", items[0].asset.ID) } } func TestBackupTreeManifestOpenAppendErr(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{ {Name: "Album1", Kind: "album", ID: "a1"}, }, assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, } dir := t.TempDir() var buf bytes.Buffer _, _, err := backupTree(b.tree, dir, 1024, 85, 3, false, false, &buf, b, false, manifest.FormatJSONL, false, nil, time.Time{}, false, exportOptions{}) if err != nil { t.Errorf("backupTree should succeed even with manifest error, got %v", err) } } func TestRenderLineSmallBarWidth(t *testing.T) { line := renderLine(barLine{current: 50, total: 100, label: "Total"}, 30*time.Second, 20) if !strings.Contains(line, "Total") { t.Error("expected Total in line") } } func TestEnsureScrollRegionResize(t *testing.T) { var buf bytes.Buffer bar := newProgressBar(&buf, 1) bar.draw() bar.draw() bar.clear() } func TestProgressLogCompleted(t *testing.T) { var buf bytes.Buffer bar := newProgressBar(&buf, 1) bar.logCompleted("test completed") bar.clear() } func TestProgressSetWorkerNegIdx(t *testing.T) { var buf bytes.Buffer bar := newProgressBar(&buf, 2) bar.setWorker(-1, "test", 0, "", "exporting") bar.draw() bar.clear() } func TestBackupTreeManifestSaveFail(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{ {Name: "Album1", Kind: "album", ID: "a1"}, }, assetsByAlbum: map[string][]photos.Asset{ "a1": {{ID: "x1", Filename: "img.jpg"}}, }, } dir := t.TempDir() oldHook := manifest.SetJSONLSaveHook(func() error { return fmt.Errorf("sync failed") }) defer manifest.SetJSONLSaveHook(oldHook) var buf bytes.Buffer _, _, err := backupTree(b.tree, dir, 1024, 85, 3, false, false, &buf, b, false, manifest.FormatJSONL, false, nil, time.Time{}, false, exportOptions{}) if err != nil { t.Errorf("unexpected error: %v", err) } if !strings.Contains(buf.String(), "could not save manifest") { t.Errorf("expected save manifest warning, got: %s", buf.String()) } } func TestBackupTreeManifestOpenAppendFail(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{ {Name: "Album1", Kind: "album", ID: "a1"}, }, assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, } dir := t.TempDir() jsonlPath := manifest.JSONLPath(dir) os.WriteFile(jsonlPath, []byte("{}\n"), 0644) os.Chmod(jsonlPath, 0444) defer os.Chmod(jsonlPath, 0644) var buf bytes.Buffer _, _, err := backupTree(b.tree, dir, 1024, 85, 3, false, false, &buf, b, false, manifest.FormatJSONL, false, nil, time.Time{}, false, exportOptions{}) if err != nil { t.Errorf("unexpected error: %v", err) } if !strings.Contains(buf.String(), "could not open manifest") { t.Errorf("expected open append warning, got: %s", buf.String()) } } func TestExportAssetsManifestSaveFail(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, } dir := t.TempDir() oldHook := manifest.SetJSONLSaveHook(func() error { return fmt.Errorf("sync failed") }) defer manifest.SetJSONLSaveHook(oldHook) var buf bytes.Buffer done, failed := exportAssets(b.assets, dir, 1024, 85, 3, false, 1, &buf, b, "", false, manifest.FormatJSONL, false, exportOptions{}) if done != 1 { t.Errorf("expected 1 done, got %d", done) } if failed != 0 { t.Errorf("expected 0 failed, got %d", failed) } if !strings.Contains(buf.String(), "could not save manifest") { t.Errorf("expected save manifest warning, got: %s", buf.String()) } } func TestCmdBackupAllCancelledCmd(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{ {Name: "Album1", Kind: "album", ID: "a1"}, }, } b.Cancel() var buf bytes.Buffer rc := cmdBackupAll([]string{"--out", "/tmp", "--no-manifest"}, &buf, &buf, b) if rc != 1 { t.Errorf("expected rc 1 for cancelled, got %d", rc) } } func TestCmdExportSortNewestMixedDates(t *testing.T) { dateOld := "2024-01-01" dateNew := "2024-06-01" b := &mockBridge{ albums: []photos.Album{{ID: "x", Title: "Album"}}, assetsByAlbum: map[string][]photos.Asset{ "x": { {ID: "old", Filename: "old.jpg", CreationDate: &dateOld}, {ID: "new", Filename: "new.jpg", CreationDate: &dateNew}, {ID: "nil", Filename: "nil.jpg"}, }, }, } dir := t.TempDir() _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--sort", "newest", "--no-manifest"}, b) if rc != 0 { t.Fatalf("rc = %d, stderr = %q", rc, stderr) } } func TestProgressDrawSmallTerminal(t *testing.T) { var buf bytes.Buffer bar := newProgressBar(&buf, 30) bar.setTotal(5, 10, "test") bar.draw() bar.clear() } func TestCmdPhotosListAssetsErrorAfterResolve(t *testing.T) { b := &mockBridge{ albums: []photos.Album{{ID: "x", Title: "x"}}, listAssetsFn: func(albumID string) ([]photos.Asset, int, error) { return nil, 0, fmt.Errorf("list failed") }, } var buf bytes.Buffer rc := cmdPhotos([]string{"--album-id", "x"}, &buf, &buf, b) if rc != 1 { t.Errorf("expected rc 1, got %d", rc) } if !strings.Contains(buf.String(), "list failed") { t.Errorf("expected error message in output, got: %s", buf.String()) } } func TestCmdExportListAssetsErrorAfterResolve(t *testing.T) { b := &mockBridge{ albums: []photos.Album{{ID: "x", Title: "x"}}, listAssetsFn: func(albumID string) ([]photos.Asset, int, error) { return nil, 0, fmt.Errorf("list failed") }, } var buf bytes.Buffer rc := cmdExport([]string{"--album-id", "x", "--out", t.TempDir(), "--no-manifest"}, &buf, &buf, b) if rc != 1 { t.Errorf("expected rc 1, got %d", rc) } if !strings.Contains(buf.String(), "list failed") { t.Errorf("expected error message in output, got: %s", buf.String()) } } func TestCmdExportSortNewestBothNilDates(t *testing.T) { b := &mockBridge{ albums: []photos.Album{{ID: "x", Title: "Album"}}, assetsByAlbum: map[string][]photos.Asset{ "x": { {ID: "b", Filename: "b.jpg"}, {ID: "a", Filename: "a.jpg"}, }, }, } dir := t.TempDir() _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--sort", "newest", "--no-manifest"}, b) if rc != 0 { t.Fatalf("rc = %d, stderr = %q", rc, stderr) } } func TestCmdExportSortNewestOneNilDate(t *testing.T) { dateOld := "2024-01-01" dateNew := "2024-06-01" b := &mockBridge{ albums: []photos.Album{{ID: "x", Title: "Album"}}, assetsByAlbum: map[string][]photos.Asset{ "x": { {ID: "no-date", Filename: "nodate.jpg"}, {ID: "has-date-old", Filename: "old.jpg", CreationDate: &dateOld}, {ID: "has-date-new", Filename: "new.jpg", CreationDate: &dateNew}, }, }, } dir := t.TempDir() _, _, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--sort", "newest", "--no-manifest"}, b) if rc != 0 { t.Errorf("expected rc 0, got %d", rc) } } func TestCollectPendingAssetsSortNewestNilDates(t *testing.T) { dateOld := "2024-01-01" b := &mockBridge{ tree: []photos.CollectionNode{ {Kind: "album", ID: "a1", Name: "Album1"}, }, assetsByAlbum: map[string][]photos.Asset{ "a1": { {ID: "dated", Filename: "dated.jpg", CreationDate: &dateOld}, {ID: "nil1", Filename: "nil1.jpg"}, {ID: "nil2", Filename: "nil2.jpg"}, }, }, } items, _ := collectPendingAssets(b.tree, t.TempDir(), b, false, false, nil, nil, true, nil, time.Time{}, exportOptions{}) if len(items) != 3 { t.Fatalf("expected 3 items, got %d", len(items)) } if items[0].asset.ID != "dated" { t.Errorf("expected dated first, got %s", items[0].asset.ID) } } func TestExportPendingParallelCancelledWorker(t *testing.T) { b := &mockBridge{} var callCount atomic.Int32 b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) { if callCount.Add(1) >= 1 { b.Cancel() } return photos.ExportResult{Filename: "test.jpg", Size: 100, Cloud: "local"}, nil } dir := t.TempDir() pending := []pendingAsset{ {asset: photos.Asset{ID: "x1", Filename: "img1.jpg", Cloud: "local"}, path: dir, album: "test"}, {asset: photos.Asset{ID: "x2", Filename: "img2.jpg", Cloud: "local"}, path: dir, album: "test"}, } bar := newProgressBar(io.Discard, 1) done, _ := exportPendingParallel(pending, 1024, 85, false, 1, bar, b, 1, nil, manifest.NoopLogWriter, exportOptions{}) if done > 2 { t.Errorf("expected at most 2 done, got %d", done) } } func TestBackupTreeManifestOpenConvertErr(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{ {Name: "Album1", Kind: "album", ID: "a1"}, }, assetsByAlbum: map[string][]photos.Asset{ "a1": {{ID: "x1", Filename: "img.jpg"}}, }, } dir := t.TempDir() dbPath := manifest.SQLitePath(dir) os.WriteFile(dbPath, []byte("not a sqlite file"), 0644) var buf bytes.Buffer _, _, err := backupTree(b.tree, dir, 1024, 85, 3, false, false, &buf, b, false, manifest.FormatJSONL, false, nil, time.Time{}, false, exportOptions{}) if err != nil { t.Logf("backupTree: %v", err) } } func TestExportAssetsManifestOpenConvertErr(t *testing.T) { assets := []photos.Asset{{ID: "x1", Filename: "img.jpg", Cloud: "local"}} b := &mockBridge{} dir := t.TempDir() dbPath := manifest.SQLitePath(dir) os.WriteFile(dbPath, []byte("not a sqlite file"), 0644) var buf bytes.Buffer exportAssets(assets, dir, 1024, 85, 3, false, 1, &buf, b, "", false, manifest.FormatJSONL, false, exportOptions{}) } func TestExportPendingParallelProgressUpdate(t *testing.T) { origPollInterval := progressPollInterval progressPollInterval = 1 * time.Millisecond defer func() { progressPollInterval = origPollInterval }() b := &mockBridge{ exportPreviewFn: func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) { time.Sleep(20 * time.Millisecond) return photos.ExportResult{Filename: "test.jpg", Size: 100, Cloud: "local"}, nil }, } dir := t.TempDir() pending := []pendingAsset{ {asset: photos.Asset{ID: "x1", Filename: "img.jpg", Cloud: "local"}, path: dir, album: "test"}, } bar := newProgressBar(io.Discard, 1) done, failed := exportPendingParallel(pending, 1024, 85, false, 1, bar, b, 1, nil, manifest.NoopLogWriter, exportOptions{}) if done != 1 { t.Errorf("expected 1 done, got %d", done) } if failed != 0 { t.Errorf("expected 0 failed, got %d", failed) } } func TestExportPendingParallelTimeout(t *testing.T) { origTimeout := exportTimeout exportTimeout = 10 * time.Millisecond defer func() { exportTimeout = origTimeout }() b := &mockBridge{ exportPreviewFn: func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) { time.Sleep(30 * time.Millisecond) return photos.ExportResult{Filename: "test.jpg", Size: 100, Cloud: "local"}, nil }, } dir := t.TempDir() pending := []pendingAsset{ {asset: photos.Asset{ID: "x1", Filename: "img.jpg", Cloud: "local"}, path: dir, album: "test"}, } bar := newProgressBar(io.Discard, 1) done, failed := exportPendingParallel(pending, 1024, 85, false, 1, bar, b, 1, nil, manifest.NoopLogWriter, exportOptions{}) t.Logf("done=%d, failed=%d", done, failed) } func TestIsExcluded(t *testing.T) { tests := []struct { name string exclude []string want bool }{ {"no patterns", nil, false}, {"exact match", []string{"Photos"}, true}, {"no match", []string{"Videos"}, false}, {"glob match", []string{"Recent*"}, true}, {"glob no match", []string{"Recent*"}, false}, } for _, tt := range tests { t.Run(tt.name+"_"+tt.name, func(t *testing.T) { name := tt.name if tt.name == "glob match" { name = "Recently Deleted" } if tt.name == "glob no match" { name = "Favorites" } if tt.name == "exact match" { name = "Photos" } if tt.name == "no match" { name = "Photos" } if tt.name == "no patterns" { name = "Photos" } got := isExcluded(name, tt.exclude) if got != tt.want { t.Errorf("isExcluded(%q, %v) = %v, want %v", name, tt.exclude, got, tt.want) } }) } } func TestFlagVals(t *testing.T) { args := []string{"--exclude-album", "Photos", "--exclude-album", "Recent*", "--out", "/tmp"} vals := flagVals(args, "--exclude-album") if len(vals) != 2 || vals[0] != "Photos" || vals[1] != "Recent*" { t.Errorf("flagVals = %v, want [Photos Recent*]", vals) } empty := flagVals(args, "--nonexistent") if len(empty) != 0 { t.Errorf("flagVals for nonexistent = %v, want []", empty) } } func TestCmdBackupAllExcludeAlbum(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{ {ID: "a1", Name: "Photos", Kind: "album"}, {ID: "a2", Name: "Trips", Kind: "album"}, }, assetsByAlbum: map[string][]photos.Asset{ "a1": {{ID: "p1", Filename: "photo1.jpg"}}, "a2": {{ID: "t1", Filename: "trip1.jpg"}}, }, } dir := t.TempDir() _, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--exclude-album", "Photos", "--no-manifest"}, b) if rc != exitOK { t.Errorf("rc = %d, stderr = %q", rc, stderr) } if strings.Contains(stderr, "Photos") { t.Errorf("should not export excluded album Photos, stderr = %q", stderr) } } func TestCollectNodesExcludeAlbum(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{ {ID: "a1", Name: "Recently Deleted", Kind: "album"}, {ID: "a2", Name: "Favorites", Kind: "album"}, }, assetsByAlbum: map[string][]photos.Asset{ "a2": {{ID: "x1", Filename: "fav.jpg"}}, }, } var items []pendingAsset var skipped int collectNodes(b.tree, "/out", b, false, false, &items, &skipped, nil, nil, []string{"Recently Deleted"}, exportOptions{}) if len(items) != 1 || items[0].asset.ID != "x1" { t.Errorf("expected 1 item (Favorites), got %d items", len(items)) } } func TestFilterBySince(t *testing.T) { oldDate := "2023-01-01T00:00:00Z" newDate := "2025-01-01T00:00:00Z" assets := []photos.Asset{ {ID: "old", Filename: "old.jpg", CreationDate: &oldDate}, {ID: "new", Filename: "new.jpg", CreationDate: &newDate}, {ID: "nil", Filename: "nil.jpg"}, } since, _ := time.Parse("2006-01-02", "2024-06-01") filtered := filterBySince(assets, since) if len(filtered) != 2 { t.Errorf("expected 2 assets after since filter, got %d", len(filtered)) } if filtered[0].ID != "new" && filtered[1].ID != "new" { t.Errorf("expected 'new' asset to remain") } } func TestParseSinceDate(t *testing.T) { t1, err := parseSinceDate("2024-01-15") if err != nil || t1.Year() != 2024 { t.Errorf("expected 2024, got %v, err %v", t1, err) } t2, err := parseSinceDate("2024-06-01T10:30:00Z") if err != nil || t2.Year() != 2024 { t.Errorf("expected 2024, got %v, err %v", t2, err) } _, err = parseSinceDate("not-a-date") if err == nil { t.Error("expected error for invalid date") } } func TestCollectPendingAssetsWithSince(t *testing.T) { oldDate := "2023-01-01T00:00:00Z" newDate := "2025-01-01T00:00:00Z" b := &mockBridge{ tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, assetsByAlbum: map[string][]photos.Asset{ "a1": { {ID: "old", Filename: "old.jpg", CreationDate: &oldDate}, {ID: "new", Filename: "new.jpg", CreationDate: &newDate}, }, }, } since, _ := time.Parse("2006-01-02", "2024-06-01") items, skipped := collectPendingAssets(b.tree, "/out", b, false, false, nil, nil, false, nil, since, exportOptions{}) if len(items) != 1 { t.Errorf("expected 1 item after since filter, got %d", len(items)) } if items[0].asset.ID != "new" { t.Errorf("expected 'new' asset, got %s", items[0].asset.ID) } if skipped != 1 { t.Errorf("expected 1 skipped (old asset), got %d", skipped) } } func TestCmdBackupAllSinceFlag(t *testing.T) { b := &mockBridge{tree: []photos.CollectionNode{}} dir := t.TempDir() _, _, rc := runWith([]string{"backup-all", "--out", dir, "--since", "2024-01-01", "--no-manifest"}, b) if rc != exitOK { t.Errorf("rc = %d", rc) } } func TestCmdBackupAllSinceInvalid(t *testing.T) { b := &mockBridge{tree: []photos.CollectionNode{}} _, stderr, rc := runWith([]string{"backup-all", "--out", "/tmp", "--since", "bad-date", "--no-manifest"}, b) if rc != exitErr { t.Errorf("rc = %d, want %d", rc, exitErr) } if !strings.Contains(stderr, "date") { t.Errorf("stderr = %q", stderr) } } func TestCmdExportSinceFlag(t *testing.T) { dateNew := "2025-01-01T00:00:00Z" dateOld := "2023-01-01T00:00:00Z" b := &mockBridge{ assets: []photos.Asset{ {ID: "old", Filename: "old.jpg", CreationDate: &dateOld}, {ID: "new", Filename: "new.jpg", CreationDate: &dateNew}, }, } dir := t.TempDir() _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--since", "2024-06-01", "--no-manifest"}, b) if rc != exitOK { t.Errorf("rc = %d, stderr = %q", rc, stderr) } if !strings.Contains(stderr, "1 assets") || strings.Contains(stderr, "2 assets") { t.Errorf("expected only 1 asset after --since filter, stderr = %q", stderr) } } func TestCmdExportSinceInvalid(t *testing.T) { b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}} _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--since", "bad-date", "--no-manifest"}, b) if rc != exitErr { t.Errorf("rc = %d, want %d", rc, exitErr) } if !strings.Contains(stderr, "--since") && !strings.Contains(stderr, "date") { t.Errorf("stderr = %q", stderr) } } func TestCmdExportWithLog(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, } dir := t.TempDir() _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--no-manifest", "--log"}, b) if rc != exitOK { t.Errorf("rc = %d, stderr = %q", rc, stderr) } if _, err := os.ReadFile(manifest.LogPath(dir)); os.IsNotExist(err) { t.Error("expected export.log to exist with --log") } } func TestCmdBackupAllWithLog(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, assetsByAlbum: map[string][]photos.Asset{ "a1": {{ID: "x1", Filename: "img.jpg"}}, }, } dir := t.TempDir() _, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--no-manifest", "--log"}, b) if rc != exitOK { t.Errorf("rc = %d, stderr = %q", rc, stderr) } if _, err := os.ReadFile(manifest.LogPath(dir)); os.IsNotExist(err) { t.Error("expected export.log to exist with --log --no-manifest") } } func TestCmdBackupAllWithLogSQLite(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, assetsByAlbum: map[string][]photos.Asset{ "a1": {{ID: "x1", Filename: "img.jpg"}}, }, } dir := t.TempDir() _, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--manifest", "sqlite", "--log"}, b) if rc != exitOK { t.Errorf("rc = %d, stderr = %q", rc, stderr) } } func TestExportAssetsWithLog(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, } dir := t.TempDir() exported, failed := exportAssets(b.assets, dir, 1024, 85, 3, false, 1, io.Discard, b, "", false, manifest.FormatJSONL, true, exportOptions{}) if exported != 1 || failed != 0 { t.Errorf("exported=%d failed=%d", exported, failed) } if _, err := os.ReadFile(manifest.LogPath(dir)); os.IsNotExist(err) { t.Error("expected export.log with enableLog=true") } } func TestBackupTreeWithLog(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, assetsByAlbum: map[string][]photos.Asset{ "a1": {{ID: "x1", Filename: "img.jpg"}}, }, } dir := t.TempDir() _, _, err := backupTree(b.tree, dir, 1024, 85, 3, false, false, io.Discard, b, false, manifest.FormatJSONL, false, nil, time.Time{}, true, exportOptions{}) if err != nil { t.Errorf("unexpected error: %v", err) } if _, err := os.ReadFile(manifest.LogPath(dir)); os.IsNotExist(err) { t.Error("expected export.log with enableLog=true") } } func TestExportAssetsLogOpenError(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, } var stderr bytes.Buffer exportAssets(b.assets, "/proc/cannot-create", 1024, 85, 3, false, 1, &stderr, b, "", true, manifest.FormatJSONL, true, exportOptions{}) if !strings.Contains(stderr.String(), "could not open log writer") { t.Errorf("expected log writer warning, got: %q", stderr.String()) } } func TestBackupTreeLogOpenError(t *testing.T) { b := &mockBridge{ tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, assetsByAlbum: map[string][]photos.Asset{ "a1": {{ID: "x1", Filename: "img.jpg"}}, }, } var stderr bytes.Buffer _, _, err := backupTree(b.tree, "/proc/cannot-create", 1024, 85, 3, false, false, &stderr, b, true, manifest.FormatJSONL, false, nil, time.Time{}, true, exportOptions{}) if err != nil { t.Errorf("unexpected error: %v", err) } if !strings.Contains(stderr.String(), "could not open log writer") { t.Errorf("expected log writer warning, got: %q", stderr.String()) } } func TestNewFeatureCommandsAndOptions(t *testing.T) { oldConfigValues, oldConfigLoaded := configValues, configLoaded defer func() { configValues, configLoaded = oldConfigValues, oldConfigLoaded }() configValues, configLoaded = nil, false date := "2024-01-02T00:00:00Z" b := &mockBridge{ albums: []photos.Album{{ID: "a1", Title: "Album"}}, assets: []photos.Asset{ {ID: "x1", Filename: "fav.jpg", MediaType: "image", IsFavorite: true, PixelWidth: 10, PixelHeight: 10, CreationDate: &date}, {ID: "x2", Filename: "vid.mov", MediaType: "video", PixelWidth: 20, PixelHeight: 20}, }, tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, assetsByAlbum: map[string][]photos.Asset{ "a1": {{ID: "x1", Filename: "fav.jpg", MediaType: "image", IsFavorite: true, PixelWidth: 10, PixelHeight: 10, CreationDate: &date}}, }, } dir := t.TempDir() out, stderr, rc := runWith([]string{"export", "--album-id", "Album", "--out", dir, "--dry-run", "--json", "--only-favorites", "--media", "photos", "--format", "jpeg", "--min-size", "1", "--max-size", "1000", "--date-template", "YYYY/MM/DD"}, b) if rc != exitOK || !strings.Contains(out, "x1") || !strings.Contains(out, "total") || !strings.Contains(stderr, "dry-run") { t.Fatalf("dry-run rc=%d out=%q stderr=%q", rc, out, stderr) } out, stderr, rc = runWith([]string{"backup-all", "--out", dir, "--dry-run", "--json", "--media", "all", "--date-template", "YYYY/MM/DD"}, b) if rc != exitOK || !strings.Contains(out, "x1") || !strings.Contains(stderr, "dry-run") { t.Fatalf("backup dry-run rc=%d out=%q stderr=%q", rc, out, stderr) } _, stderr, rc = runWith([]string{"export", "--album-id", "Album", "--out", dir, "--media", "bad"}, b) if rc != exitErr || !strings.Contains(stderr, "--media") { t.Fatalf("expected media error, rc=%d stderr=%q", rc, stderr) } _, stderr, rc = runWith([]string{"export", "--album-id", "Album", "--out", dir, "--format", "bad"}, b) if rc != exitErr || !strings.Contains(stderr, "--format") { t.Fatalf("expected format error, rc=%d stderr=%q", rc, stderr) } _, stderr, rc = runWith([]string{"export", "--album-id", "Album", "--out", dir, "--retry", "bad"}, b) if rc != exitErr || !strings.Contains(stderr, "--retry") { t.Fatalf("expected retry error, rc=%d stderr=%q", rc, stderr) } } func TestReportDiffVerifyRetryFailedAndConfig(t *testing.T) { oldConfigValues, oldConfigLoaded := configValues, configLoaded defer func() { configValues, configLoaded = oldConfigValues, oldConfigLoaded }() dir := t.TempDir() cfg := filepath.Join(dir, "config.toml") if err := os.WriteFile(cfg, []byte("quality = 90\nlog = true\n"), 0644); err != nil { t.Fatal(err) } t.Setenv("PHOTOSCLI_CONFIG", cfg) configValues, configLoaded = nil, false if flagValWithDefault(nil, "--quality", "85") != "90" || !hasFlag(nil, "--log") { t.Fatal("expected config defaults") } m := manifest.LoadJSONL(dir) if err := m.OpenAppend(); err != nil { t.Fatal(err) } m.Add("x1", "file.jpg", 10, "local") if err := m.Save(); err != nil { t.Fatal(err) } m.Close() if err := os.WriteFile(filepath.Join(dir, "file.jpg"), []byte("0123456789"), 0644); err != nil { t.Fatal(err) } appendFailure(dir, pendingAsset{asset: photos.Asset{ID: "bad", Filename: "bad.jpg"}, path: dir, album: "Album"}, fmt.Errorf("boom")) out, stderr, rc := runWith([]string{"report", "--out", dir}, &mockBridge{}) if rc != exitOK || !strings.Contains(out, "entries\t1") || stderr != "" { t.Fatalf("report rc=%d out=%q stderr=%q", rc, out, stderr) } out, stderr, rc = runWith([]string{"verify", "--out", dir}, &mockBridge{}) if rc != exitOK || !strings.Contains(out, "verified") || stderr != "" { t.Fatalf("verify rc=%d out=%q stderr=%q", rc, out, stderr) } if err := os.WriteFile(filepath.Join(dir, "file.xmp"), renderXMP(xmpSidecarData{AssetID: "x1", ExportedFilename: "file.jpg"}), 0644); err != nil { t.Fatal(err) } out, stderr, rc = runWith([]string{"verify", "--out", dir, "--sidecar"}, &mockBridge{}) if rc != exitOK || !strings.Contains(out, "verified") || stderr != "" { t.Fatalf("verify sidecar rc=%d out=%q stderr=%q", rc, out, stderr) } b := &mockBridge{albums: []photos.Album{{ID: "a1", Title: "Album"}}, assets: []photos.Asset{{ID: "x1", Filename: "file.jpg"}, {ID: "x2", Filename: "missing.jpg"}}} out, _, rc = runWith([]string{"diff", "--album-id", "Album", "--out", dir}, b) if rc != exitPartial || !strings.Contains(out, "x2") { t.Fatalf("diff rc=%d out=%q", rc, out) } out, _, rc = runWith([]string{"retry-failed", "--out", dir}, b) if rc != exitOK || !strings.Contains(out, "exported") { t.Fatalf("retry-failed rc=%d out=%q", rc, out) } } func TestNewFeatureHelpers(t *testing.T) { date := "2024-03-04T00:00:00Z" a := photos.Asset{ID: "x", Filename: "x.jpg", MediaType: "video", IsFavorite: false, PixelWidth: 10, PixelHeight: 20, CreationDate: &date} if len(applyAssetFilters([]photos.Asset{a}, exportOptions{media: "videos", minSize: 100, maxSize: 300})) != 1 { t.Fatal("expected video in size range") } if len(applyAssetFilters([]photos.Asset{a}, exportOptions{media: "photos"})) != 0 { t.Fatal("expected video filtered from photos") } if got := pathWithDateTemplate("/out", a, "YYYY/MM/DD"); !strings.Contains(got, "2024") || !strings.Contains(got, "03") || !strings.Contains(got, "04") { t.Fatalf("unexpected templated path %q", got) } if got := pathWithDateTemplate("/out", photos.Asset{}, "YYYY"); got != "/out" { t.Fatalf("expected base path, got %q", got) } } func TestNewFeatureCoverageEdges(t *testing.T) { oldConfigValues, oldConfigLoaded := configValues, configLoaded defer func() { configValues, configLoaded = oldConfigValues, oldConfigLoaded }() configValues, configLoaded = map[string]string{}, true date := "bad-date" if got := pathWithDateTemplate("/out", photos.Asset{CreationDate: &date}, "YYYY"); got != "/out" { t.Fatalf("expected bad date to keep base path, got %q", got) } assets := []photos.Asset{{ID: "a", MediaType: "image", PixelWidth: 1, PixelHeight: 1}, {ID: "b", MediaType: "image", IsFavorite: true, PixelWidth: 100, PixelHeight: 100}} if len(applyAssetFilters(assets, exportOptions{media: "all", onlyFavorites: true})) != 1 { t.Fatal("expected only favorite") } if len(applyAssetFilters(assets, exportOptions{media: "videos"})) != 0 { t.Fatal("expected no videos") } if len(applyAssetFilters(assets, exportOptions{media: "all", minSize: 2})) != 1 { t.Fatal("expected min-size filter") } if len(applyAssetFilters(assets, exportOptions{media: "all", maxSize: 2})) != 1 { t.Fatal("expected max-size filter") } var stderr bytes.Buffer if _, ok := parseExportOptions([]string{"--retry", "2", "--min-size", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--min-size") { t.Fatal("expected min-size error") } stderr.Reset() if _, ok := parseExportOptions([]string{"--max-size", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--max-size") { t.Fatal("expected max-size error") } stderr.Reset() opts, ok := parseExportOptions([]string{"--retry", "2", "--min-size", "1", "--max-size", "2"}, &stderr) if !ok || opts.retry != 2 || opts.minSize != 1 || opts.maxSize != 2 { t.Fatalf("unexpected opts: %+v ok=%v stderr=%q", opts, ok, stderr.String()) } } func TestNewFeatureCommandEdges(t *testing.T) { oldConfigValues, oldConfigLoaded := configValues, configLoaded defer func() { configValues, configLoaded = oldConfigValues, oldConfigLoaded }() configValues, configLoaded = map[string]string{}, true dir := t.TempDir() b := &mockBridge{albums: []photos.Album{{ID: "a1", Title: "Album"}}, assets: []photos.Asset{{ID: "x1", Filename: "file.jpg", MediaType: "image"}}} _, stderr, rc := runWith([]string{"report"}, b) if rc != exitErr || !strings.Contains(stderr, "--out") { t.Fatalf("expected report --out error, rc=%d stderr=%q", rc, stderr) } _, stderr, rc = runWith([]string{"diff"}, b) if rc != exitErr || !strings.Contains(stderr, "--album-id") { t.Fatalf("expected diff arg error, rc=%d stderr=%q", rc, stderr) } _, stderr, rc = runWith([]string{"verify"}, b) if rc != exitErr || !strings.Contains(stderr, "--out") { t.Fatalf("expected verify --out error, rc=%d stderr=%q", rc, stderr) } _, stderr, rc = runWith([]string{"retry-failed"}, b) if rc != exitErr || !strings.Contains(stderr, "--out") { t.Fatalf("expected retry --out error, rc=%d stderr=%q", rc, stderr) } _, stderr, rc = runWith([]string{"retry-failed", "--out", filepath.Join(dir, "missing")}, b) if rc != exitErr || stderr == "" { t.Fatalf("expected missing failures error, rc=%d stderr=%q", rc, stderr) } m := manifest.LoadJSONL(dir) if err := m.OpenAppend(); err != nil { t.Fatal(err) } m.Add("x1", "missing.jpg", 1, "local") m.Add("x2", "", 1, "local") m.Close() out, _, rc := runWith([]string{"verify", "--out", dir}, b) if rc != exitPartial || !strings.Contains(out, "missing.jpg") { t.Fatalf("expected verify missing, rc=%d out=%q", rc, out) } if _, err := loadManifestEntries("/proc/cannot-create", manifest.FormatJSONL); err == nil { t.Fatal("expected loadManifestEntries open error") } b.accessErr = fmt.Errorf("denied") _, _, rc = runWith([]string{"diff", "--album-id", "Album", "--out", dir}, b) if rc != exitAuth { t.Fatalf("expected auth error, got %d", rc) } } func TestExportVerifyJSONAndBackupIncludeVideos(t *testing.T) { oldConfigValues, oldConfigLoaded := configValues, configLoaded defer func() { configValues, configLoaded = oldConfigValues, oldConfigLoaded }() configValues, configLoaded = map[string]string{}, true dir := t.TempDir() b := &mockBridge{ albums: []photos.Album{{ID: "a1", Title: "Album"}}, assets: []photos.Asset{{ID: "x1", Filename: "file.jpg", MediaType: "image"}}, tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}, {ID: "a2", Name: "Album", Kind: "album"}}, assetsByAlbum: map[string][]photos.Asset{ "a1": {{ID: "x1", Filename: "file.jpg", MediaType: "image"}}, "a2": {{ID: "x2", Filename: "vid.mov", MediaType: "video"}}, }, } out, _, rc := runWith([]string{"export", "--album-id", "Album", "--out", dir, "--json", "--verify", "--no-manifest"}, b) if rc != exitOK || !strings.Contains(out, "exported") { t.Fatalf("export json rc=%d out=%q", rc, out) } out, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--include-videos", "--json", "--verify", "--no-manifest"}, b) if rc != exitOK || !strings.Contains(out, "exported") || strings.Contains(stderr, "error") { t.Fatalf("backup json rc=%d out=%q stderr=%q", rc, out, stderr) } } func TestNewFeatureRemainingBranches(t *testing.T) { oldConfigValues, oldConfigLoaded := configValues, configLoaded defer func() { configValues, configLoaded = oldConfigValues, oldConfigLoaded }() configValues, configLoaded = map[string]string{}, true dir := t.TempDir() b := &mockBridge{albums: []photos.Album{{ID: "a1", Title: "Album"}}, assets: []photos.Asset{{ID: "x1", Filename: "file.jpg", MediaType: "image"}}} _, _, rc := runWith([]string{"export", "--album-id", "Album", "--out", dir, "--verify"}, b) if rc != exitPartial { t.Fatalf("expected verify partial export, got %d", rc) } backupDir := t.TempDir() _, _, rc = runWith([]string{"backup-all", "--out", backupDir, "--verify"}, &mockBridge{tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, assetsByAlbum: map[string][]photos.Asset{"a1": {{ID: "x1", Filename: "file.jpg"}}}}) if rc != exitPartial { t.Fatalf("expected verify partial backup, got %d", rc) } for _, cmd := range [][]string{ {"report", "--out", dir, "--manifest", "bad"}, {"diff", "--album-id", "Album", "--out", dir, "--manifest", "bad"}, {"verify", "--out", dir, "--manifest", "bad"}, } { _, stderr, rc := runWith(cmd, b) if rc != exitErr || !strings.Contains(stderr, "manifest") { t.Fatalf("expected manifest error for %v, rc=%d stderr=%q", cmd, rc, stderr) } } _, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--media", "bad"}, b) if rc != exitErr || !strings.Contains(stderr, "--media") { t.Fatalf("expected backup media error, rc=%d stderr=%q", rc, stderr) } for _, cmd := range [][]string{ {"report", "--out", "/proc/cannot-create"}, {"verify", "--out", "/proc/cannot-create"}, {"diff", "--album-id", "Album", "--out", "/proc/cannot-create"}, } { _, stderr, rc := runWith(cmd, b) if rc != exitErr || !strings.Contains(stderr, "error:") { t.Fatalf("expected load error for %v, rc=%d stderr=%q", cmd, rc, stderr) } } m := manifest.LoadJSONL(dir) if err := m.OpenAppend(); err != nil { t.Fatal(err) } m.Add("x1", "file.jpg", 1, "local") m.Close() _, _, rc = runWith([]string{"diff", "--album-id", "Album", "--out", dir}, b) if rc != exitOK { t.Fatalf("expected clean diff, got %d", rc) } badAlbum := &mockBridge{albumsErr: fmt.Errorf("list albums bad"), assetsErr: fmt.Errorf("assets bad")} _, stderr, rc = runWith([]string{"diff", "--album-id", "missing", "--out", dir}, badAlbum) if rc != exitErr || !strings.Contains(stderr, "error:") { t.Fatalf("expected resolve/list error, rc=%d stderr=%q", rc, stderr) } badAssets := &mockBridge{albums: []photos.Album{{ID: "a1", Title: "Album"}}, assetsErr: fmt.Errorf("assets bad")} _, stderr, rc = runWith([]string{"diff", "--album-id", "Album", "--out", dir}, badAssets) if rc != exitErr || !strings.Contains(stderr, "assets bad") { t.Fatalf("expected list assets error, rc=%d stderr=%q", rc, stderr) } failDir := t.TempDir() appendFailure(failDir, pendingAsset{asset: photos.Asset{ID: "bad", Filename: "bad.jpg"}, path: failDir, album: "Album"}, fmt.Errorf("boom")) failing := &mockBridge{exportPreviewFn: func(string, string, int, int, int) (photos.ExportResult, error) { return photos.ExportResult{}, fmt.Errorf("still bad") }} _, _, rc = runWith([]string{"retry-failed", "--out", failDir}, failing) if rc != exitPartial { t.Fatalf("expected retry partial, got %d", rc) } } func TestFailuresAndStatusCommands(t *testing.T) { dir := t.TempDir() m := manifest.LoadJSONL(dir) if err := m.OpenAppend(); err != nil { t.Fatal(err) } m.AddEntry(manifest.NewEntry("x1", "file.jpg", "Album/file.jpg", 4, "local")) m.Close() if err := os.MkdirAll(filepath.Join(dir, "Album"), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "Album", "file.jpg"), []byte("data"), 0644); err != nil { t.Fatal(err) } appendFailure(dir, pendingAsset{asset: photos.Asset{ID: "bad", Filename: "bad.jpg"}, root: dir, path: filepath.Join(dir, "Album"), album: "Album"}, fmt.Errorf("boom")) appendFailure(dir, pendingAsset{asset: photos.Asset{ID: "bad", Filename: "bad.jpg"}, root: dir, path: filepath.Join(dir, "Album"), album: "Album"}, fmt.Errorf("boom2")) out, stderr, rc := runWith([]string{"status", "--out", dir, "--json"}, &mockBridge{}) if rc != exitOK || !strings.Contains(out, "failures") || stderr != "" { t.Fatalf("status json rc=%d out=%q stderr=%q", rc, out, stderr) } out, stderr, rc = runWith([]string{"status", "--out", dir}, &mockBridge{}) if rc != exitOK || !strings.Contains(out, "entries\t1") || !strings.Contains(out, "failures\t1") || stderr != "" { t.Fatalf("status rc=%d out=%q stderr=%q", rc, out, stderr) } out, stderr, rc = runWith([]string{"failures", "list", "--out", dir}, &mockBridge{}) if rc != exitOK || !strings.Contains(out, "bad") || !strings.Contains(out, "2") || stderr != "" { t.Fatalf("failures list rc=%d out=%q stderr=%q", rc, out, stderr) } _, stderr, rc = runWith([]string{"failures"}, &mockBridge{}) if rc != exitErr || !strings.Contains(stderr, "requires") { t.Fatalf("failures missing subcommand rc=%d stderr=%q", rc, stderr) } _, stderr, rc = runWith([]string{"failures", "bogus", "--out", dir}, &mockBridge{}) if rc != exitErr || !strings.Contains(stderr, "unknown") { t.Fatalf("failures bogus rc=%d stderr=%q", rc, stderr) } _, stderr, rc = runWith([]string{"failures", "clear", "--out", dir}, &mockBridge{}) if rc != exitOK || stderr != "" { t.Fatalf("failures clear rc=%d stderr=%q", rc, stderr) } if len(loadFailures(dir)) != 0 { t.Fatal("expected failures cleared") } } func TestAtomicExportHelpers(t *testing.T) { dir := t.TempDir() b := &mockBridge{} b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) { if err := os.WriteFile(filepath.Join(out, "photo.jpg"), []byte("data"), 0644); err != nil { return photos.ExportResult{}, err } return photos.ExportResult{Filename: "photo.jpg", Size: 4, Cloud: "local"}, nil } pa := pendingAsset{asset: photos.Asset{ID: "x1", Filename: "photo.jpg"}, root: dir, path: filepath.Join(dir, "Album"), album: "Album"} result, err := exportOneAtomic(b, pa, 1024, 85, false, 0) if err != nil || result.Filename != "photo.jpg" { t.Fatalf("atomic export result=%+v err=%v", result, err) } if _, err := os.Stat(filepath.Join(dir, "Album", "photo.jpg")); err != nil { t.Fatalf("expected final file: %v", err) } b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) { if err := os.WriteFile(filepath.Join(out, "empty.jpg"), nil, 0644); err != nil { return photos.ExportResult{}, err } return photos.ExportResult{Filename: "empty.jpg"}, nil } if _, err := exportOneAtomic(b, pa, 1024, 85, false, 1); err == nil || !strings.Contains(err.Error(), "zero-byte") { t.Fatalf("expected zero-byte error, got %v", err) } } func TestMoreIntegrityBranches(t *testing.T) { dir := t.TempDir() m := manifest.LoadJSONL(dir) if err := m.OpenAppend(); err != nil { t.Fatal(err) } m.AddEntry(manifest.Entry{ID: "zero", Filename: "zero.jpg", Path: "zero.jpg", Size: 1, Cloud: "local", Exported: time.Now().Unix()}) m.AddEntry(manifest.Entry{ID: "mismatch", Filename: "mismatch.jpg", Path: "mismatch.jpg", Size: 10, Cloud: "local", Exported: time.Now().Unix()}) m.AddEntry(manifest.Entry{ID: "nosidecar", Filename: "nosidecar.jpg", Path: "nosidecar.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()}) m.AddEntry(manifest.Entry{ID: "zerosidecar", Filename: "zerosidecar.jpg", Path: "zerosidecar.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()}) m.Close() if err := os.WriteFile(filepath.Join(dir, "zero.jpg"), nil, 0644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "mismatch.jpg"), []byte("bad"), 0644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "nosidecar.jpg"), []byte("data"), 0644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "zerosidecar.jpg"), []byte("data"), 0644); err != nil { t.Fatal(err) } out, _, rc := runWith([]string{"verify", "--out", dir}, &mockBridge{}) if rc != exitPartial || !strings.Contains(out, "zero-byte") || !strings.Contains(out, "size-mismatch") { t.Fatalf("verify rc=%d out=%q", rc, out) } if err := os.WriteFile(filepath.Join(dir, "mismatch.xmp"), []byte("wrong asset"), 0644); err != nil { t.Fatal(err) } out, _, rc = runWith([]string{"verify", "--out", dir, "--sidecar"}, &mockBridge{}) if rc != exitPartial || !strings.Contains(out, "sidecar-missing") || !strings.Contains(out, "sidecar-asset-mismatch") { t.Fatalf("verify sidecar failures rc=%d out=%q", rc, out) } if err := os.WriteFile(filepath.Join(dir, "zerosidecar.xmp"), nil, 0644); err != nil { t.Fatal(err) } out, _, rc = runWith([]string{"verify", "--out", dir, "--sidecar"}, &mockBridge{}) if rc != exitPartial || !strings.Contains(out, "sidecar-zero-byte") { t.Fatalf("verify zero sidecar rc=%d out=%q", rc, out) } _, stderr, rc := runWith([]string{"status"}, &mockBridge{}) if rc != exitErr || !strings.Contains(stderr, "--out") { t.Fatalf("status missing out rc=%d stderr=%q", rc, stderr) } _, stderr, rc = runWith([]string{"status", "--out", dir, "--manifest", "bad"}, &mockBridge{}) if rc != exitErr || !strings.Contains(stderr, "manifest") { t.Fatalf("status bad manifest rc=%d stderr=%q", rc, stderr) } _, stderr, rc = runWith([]string{"status", "--out", "/proc/cannot-create"}, &mockBridge{}) if rc != exitErr || !strings.Contains(stderr, "error:") { t.Fatalf("status load error rc=%d stderr=%q", rc, stderr) } _, stderr, rc = runWith([]string{"failures", "list"}, &mockBridge{}) if rc != exitErr || !strings.Contains(stderr, "--out") { t.Fatalf("failures missing out rc=%d stderr=%q", rc, stderr) } } func TestVerifyDeepChecksums(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "good.jpg"), []byte("abc"), 0644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "bad.jpg"), []byte("bad"), 0644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "nocheck.jpg"), []byte("data"), 0644); err != nil { t.Fatal(err) } if err := os.Mkdir(filepath.Join(dir, "adir"), 0755); err != nil { t.Fatal(err) } m := manifest.LoadJSONL(dir) if err := m.OpenAppend(); err != nil { t.Fatal(err) } m.AddEntry(manifest.Entry{ID: "good", Filename: "good.jpg", Path: "good.jpg", Size: 3, Cloud: "local", Checksum: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", Exported: time.Now().Unix()}) m.AddEntry(manifest.Entry{ID: "bad", Filename: "bad.jpg", Path: "bad.jpg", Size: 3, Cloud: "local", Checksum: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", Exported: time.Now().Unix()}) m.AddEntry(manifest.Entry{ID: "nocheck", Filename: "nocheck.jpg", Path: "nocheck.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()}) m.AddEntry(manifest.Entry{ID: "unreadable", Filename: "adir", Path: "adir", Cloud: "local", Checksum: "abc", Exported: time.Now().Unix()}) m.Close() out, stderr, rc := runWith([]string{"verify", "--out", dir, "--deep"}, &mockBridge{}) if rc != exitPartial || stderr != "" || !strings.Contains(out, "checksum-mismatch") || !strings.Contains(out, "checksum-unreadable") || strings.Contains(out, "good.jpg") || strings.Contains(out, "nocheck") { t.Fatalf("deep verify rc=%d out=%q stderr=%q", rc, out, stderr) } out, stderr, rc = runWith([]string{"verify", "--out", dir}, &mockBridge{}) if rc != exitOK || stderr != "" || strings.Contains(out, "checksum") { t.Fatalf("plain verify rc=%d out=%q stderr=%q", rc, out, stderr) } } func TestManifestRepair(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("abc"), 0644); err != nil { t.Fatal(err) } m := manifest.LoadJSONL(dir) if err := m.OpenAppend(); err != nil { t.Fatal(err) } m.AddEntry(manifest.Entry{ID: "x1", Filename: "photo.jpg", Path: "photo.jpg", Size: 0, Cloud: "local", Exported: time.Now().Unix()}) m.AddEntry(manifest.Entry{ID: "missing", Filename: "missing.jpg", Path: "missing.jpg", Size: 0, Cloud: "local", Exported: time.Now().Unix()}) m.Close() out, stderr, rc := runWith([]string{"manifest", "repair", "--out", dir, "--checksum", "sha256", "--dry-run"}, &mockBridge{}) if rc != exitOK || stderr != "" || !strings.Contains(out, "x1\tphoto.jpg\trepaired") || !strings.Contains(out, "skipped\t1") { t.Fatalf("dry repair rc=%d out=%q stderr=%q", rc, out, stderr) } if got := manifest.LoadJSONL(dir).Entries()["x1"].Checksum; got != "" { t.Fatalf("dry run wrote checksum %q", got) } out, stderr, rc = runWith([]string{"manifest", "repair", "--out", dir, "--checksum", "sha256"}, &mockBridge{}) if rc != exitOK || stderr != "" || !strings.Contains(out, "repaired\t1") { t.Fatalf("repair rc=%d out=%q stderr=%q", rc, out, stderr) } entry := manifest.LoadJSONL(dir).Entries()["x1"] if entry.Size != 3 || entry.Checksum != "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" { t.Fatalf("entry not repaired: %+v", entry) } out, stderr, rc = runWith([]string{"manifest", "repair", "--out", dir, "--checksum", "sha256"}, &mockBridge{}) if rc != exitOK || stderr != "" || !strings.Contains(out, "repaired\t0") { t.Fatalf("second repair rc=%d out=%q stderr=%q", rc, out, stderr) } } func TestManifestRepairErrors(t *testing.T) { dir := t.TempDir() _, stderr, rc := runWith([]string{"manifest"}, &mockBridge{}) if rc != exitErr || !strings.Contains(stderr, "expected manifest repair") { t.Fatalf("manifest missing subcommand rc=%d stderr=%q", rc, stderr) } _, stderr, rc = runWith([]string{"manifest", "repair"}, &mockBridge{}) if rc != exitErr || !strings.Contains(stderr, "--out") { t.Fatalf("manifest repair missing out rc=%d stderr=%q", rc, stderr) } _, stderr, rc = runWith([]string{"manifest", "repair", "--out", dir, "--checksum", "bad"}, &mockBridge{}) if rc != exitErr || !strings.Contains(stderr, "--checksum") { t.Fatalf("manifest repair bad checksum rc=%d stderr=%q", rc, stderr) } _, stderr, rc = runWith([]string{"manifest", "repair", "--out", dir, "--manifest", "bad"}, &mockBridge{}) if rc != exitErr || !strings.Contains(stderr, "manifest") { t.Fatalf("manifest repair bad manifest rc=%d stderr=%q", rc, stderr) } fileOut := filepath.Join(dir, "file-out") if err := os.WriteFile(fileOut, []byte("x"), 0644); err != nil { t.Fatal(err) } _, stderr, rc = runWith([]string{"manifest", "repair", "--out", fileOut}, &mockBridge{}) if rc != exitErr || !strings.Contains(stderr, "error:") { t.Fatalf("manifest repair open append rc=%d stderr=%q", rc, stderr) } badDBDir := t.TempDir() if err := os.WriteFile(manifest.SQLitePath(badDBDir), []byte("not sqlite"), 0644); err != nil { t.Fatal(err) } _, stderr, rc = runWith([]string{"manifest", "repair", "--out", badDBDir}, &mockBridge{}) if rc != exitErr || !strings.Contains(stderr, "error:") { t.Fatalf("manifest repair open rc=%d stderr=%q", rc, stderr) } saveDir := t.TempDir() sm := manifest.LoadJSONL(saveDir) if err := sm.OpenAppend(); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(saveDir, "photo.jpg"), []byte("abc"), 0644); err != nil { t.Fatal(err) } sm.AddEntry(manifest.Entry{ID: "x1", Filename: "photo.jpg", Path: "photo.jpg", Exported: time.Now().Unix()}) sm.Close() oldHook := manifest.SetJSONLSaveHook(func() error { return fmt.Errorf("save") }) _, stderr, rc = runWith([]string{"manifest", "repair", "--out", saveDir}, &mockBridge{}) manifest.SetJSONLSaveHook(oldHook) if rc != exitErr || !strings.Contains(stderr, "save") { t.Fatalf("manifest repair save rc=%d stderr=%q", rc, stderr) } } func TestManifestRepairBranches(t *testing.T) { dir := t.TempDir() m := manifest.LoadJSONL(dir) if err := m.OpenAppend(); err != nil { t.Fatal(err) } m.AddEntry(manifest.Entry{ID: "empty"}) m.AddEntry(manifest.Entry{ID: "stat-only", Filename: "missing.jpg", Path: "missing.jpg", Checksum: "", Exported: time.Now().Unix()}) m.AddEntry(manifest.Entry{ID: "hash-fail", Filename: "hash.jpg", Path: "hash.jpg", Checksum: "", Exported: time.Now().Unix()}) m.Close() oldStat := statFunc statFunc = func(path string) (os.FileInfo, error) { if strings.HasSuffix(path, "hash.jpg") { return testFileInfo{size: 3}, nil } return oldStat(path) } out, stderr, rc := runWith([]string{"manifest", "repair", "--out", dir, "--checksum", "sha256", "--dry-run"}, &mockBridge{}) statFunc = oldStat if rc != exitOK || stderr != "" || !strings.Contains(out, "skipped\t3") { t.Fatalf("manifest repair branches rc=%d out=%q stderr=%q", rc, out, stderr) } } func TestCleanup(t *testing.T) { dir := t.TempDir() for _, path := range []string{"keep.jpg", "keep.xmp", "keep.json", "orphan.jpg", "export.log", "failures.jsonl", filepath.Join(".photoscli", "geocode-cache.jsonl")} { full := filepath.Join(dir, path) if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(full, []byte("data"), 0644); err != nil { t.Fatal(err) } } m := manifest.LoadJSONL(dir) if err := m.OpenAppend(); err != nil { t.Fatal(err) } m.AddEntry(manifest.Entry{ID: "x1", Filename: "keep.jpg", Path: "keep.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()}) m.AddEntry(manifest.Entry{ID: "fallback", Filename: "fallback.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()}) m.AddEntry(manifest.Entry{ID: "empty", Exported: time.Now().Unix()}) m.AddEntry(manifest.Entry{ID: "badrel", Filename: "../bad.jpg", Path: "../bad.jpg", Exported: time.Now().Unix()}) m.Close() if err := os.WriteFile(filepath.Join(dir, "fallback.jpg"), []byte("data"), 0644); err != nil { t.Fatal(err) } if err := os.Mkdir(filepath.Join(dir, "subdir"), 0755); err != nil { t.Fatal(err) } out, stderr, rc := runWith([]string{"cleanup", "--out", dir, "--dry-run"}, &mockBridge{}) if rc != exitOK || stderr != "" || !strings.Contains(out, "orphan.jpg\torphan") || !strings.Contains(out, "removed\t1") { t.Fatalf("cleanup dry rc=%d out=%q stderr=%q", rc, out, stderr) } if _, err := os.Stat(filepath.Join(dir, "orphan.jpg")); err != nil { t.Fatalf("dry-run removed orphan: %v", err) } out, stderr, rc = runWith([]string{"cleanup", "--out", dir}, &mockBridge{}) if rc != exitOK || stderr != "" || !strings.Contains(out, "removed\t1") { t.Fatalf("cleanup rc=%d out=%q stderr=%q", rc, out, stderr) } if _, err := os.Stat(filepath.Join(dir, "orphan.jpg")); !os.IsNotExist(err) { t.Fatalf("orphan still exists or bad error: %v", err) } for _, path := range []string{"keep.jpg", "keep.xmp", "keep.json", "fallback.jpg", "downloads.jsonl", "export.log", "failures.jsonl", filepath.Join(".photoscli", "geocode-cache.jsonl")} { if _, err := os.Stat(filepath.Join(dir, path)); err != nil { t.Fatalf("kept file missing %s: %v", path, err) } } } func TestCleanupErrors(t *testing.T) { dir := t.TempDir() _, stderr, rc := runWith([]string{"cleanup"}, &mockBridge{}) if rc != exitErr || !strings.Contains(stderr, "--out") { t.Fatalf("cleanup missing out rc=%d stderr=%q", rc, stderr) } _, stderr, rc = runWith([]string{"cleanup", "--out", dir, "--manifest", "bad"}, &mockBridge{}) if rc != exitErr || !strings.Contains(stderr, "manifest") { t.Fatalf("cleanup bad manifest rc=%d stderr=%q", rc, stderr) } badDBDir := t.TempDir() if err := os.WriteFile(manifest.SQLitePath(badDBDir), []byte("not sqlite"), 0644); err != nil { t.Fatal(err) } _, stderr, rc = runWith([]string{"cleanup", "--out", badDBDir, "--manifest", "sqlite"}, &mockBridge{}) if rc != exitErr || !strings.Contains(stderr, "error:") { t.Fatalf("cleanup load error rc=%d stderr=%q", rc, stderr) } _, stderr, rc = runWith([]string{"cleanup", "--out", filepath.Join(dir, "missing")}, &mockBridge{}) if rc != exitOK || stderr != "" { t.Fatalf("cleanup missing root rc=%d stderr=%q", rc, stderr) } dir = t.TempDir() oldRemove := removeFunc removeFunc = func(string) error { return fmt.Errorf("remove") } if err := os.WriteFile(filepath.Join(dir, "orphan.jpg"), []byte("data"), 0644); err != nil { t.Fatal(err) } out, stderr, rc := runWith([]string{"cleanup", "--out", dir}, &mockBridge{}) removeFunc = oldRemove if rc != exitOK || stderr != "" || !strings.Contains(out, "skipped\t1") { t.Fatalf("cleanup remove error rc=%d out=%q stderr=%q", rc, out, stderr) } } func TestDoctor(t *testing.T) { dir := t.TempDir() m := manifest.LoadJSONL(dir) if err := m.OpenAppend(); err != nil { t.Fatal(err) } m.AddEntry(manifest.Entry{ID: "x1", Filename: "photo.jpg", Path: "photo.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()}) m.Close() out, stderr, rc := runWith([]string{"doctor", "--out", dir}, &mockBridge{}) if rc != exitOK || !strings.Contains(stderr, "access granted") || !strings.Contains(out, "backup_dir\tok") || !strings.Contains(out, "entries\t1") || !strings.Contains(out, "problems\t0") { t.Fatalf("doctor rc=%d out=%q stderr=%q", rc, out, stderr) } out, stderr, rc = runWith([]string{"doctor", "--out", dir, "--json"}, &mockBridge{}) if rc != exitOK || !strings.Contains(out, `"entries":1`) || !strings.Contains(out, `"problems":0`) { t.Fatalf("doctor json rc=%d out=%q stderr=%q", rc, out, stderr) } } func TestDoctorProblems(t *testing.T) { dir := t.TempDir() appendFailure(dir, pendingAsset{asset: photos.Asset{ID: "x1", Filename: "photo.jpg"}}, fmt.Errorf("failed")) out, stderr, rc := runWith([]string{"doctor", "--out", dir}, &mockBridge{accessErr: fmt.Errorf("denied")}) if rc != exitPartial || !strings.Contains(stderr, "denied") || !strings.Contains(out, "photos_access\tdenied") || !strings.Contains(out, "failures\t1") { t.Fatalf("doctor problems rc=%d out=%q stderr=%q", rc, out, stderr) } _, stderr, rc = runWith([]string{"doctor", "--out", dir, "--manifest", "bad"}, &mockBridge{}) if rc != exitErr || !strings.Contains(stderr, "manifest") { t.Fatalf("doctor bad manifest rc=%d stderr=%q", rc, stderr) } out, stderr, rc = runWith([]string{"doctor", "--out", filepath.Join(dir, "missing")}, &mockBridge{}) if rc != exitPartial || !strings.Contains(out, "backup_dir\tmissing") { t.Fatalf("doctor missing dir rc=%d out=%q stderr=%q", rc, out, stderr) } badDBDir := t.TempDir() if err := os.WriteFile(manifest.SQLitePath(badDBDir), []byte("not sqlite"), 0644); err != nil { t.Fatal(err) } out, stderr, rc = runWith([]string{"doctor", "--out", badDBDir, "--manifest", "sqlite"}, &mockBridge{}) if rc != exitPartial || !strings.Contains(out, "manifest\terror") { t.Fatalf("doctor manifest error rc=%d out=%q stderr=%q", rc, out, stderr) } stderrBuf := &bytes.Buffer{} if rc := cmdDoctor([]string{"--json"}, errWriter{}, stderrBuf, &mockBridge{}); rc != exitErr || !strings.Contains(stderrBuf.String(), "error:") { t.Fatalf("doctor json writer error rc=%d stderr=%q", rc, stderrBuf.String()) } } func TestVerifySidecarBranches(t *testing.T) { dir := t.TempDir() subdir := filepath.Join(dir, "sub") if err := os.Mkdir(subdir, 0755); err != nil { t.Fatal(err) } xmp := filepath.Join(dir, "asset.xmp") if err := os.WriteFile(xmp, []byte(`photoscli:assetID="x1"`), 0644); err != nil { t.Fatal(err) } oldRead := readFileFunc readFileFunc = func(string) ([]byte, error) { return nil, fmt.Errorf("read") } var out bytes.Buffer if got := verifySidecar(&out, subdir, "x1", "../asset.jpg", false); got != 1 || !strings.Contains(out.String(), "sidecar-unreadable") { t.Fatalf("expected unreadable with rel fallback, got=%d out=%q", got, out.String()) } readFileFunc = oldRead } func TestVerifySidecarStrict(t *testing.T) { dir := t.TempDir() media := filepath.Join(dir, "photo.jpg") if err := os.WriteFile(media, []byte("data"), 0644); err != nil { t.Fatal(err) } if err := writeXMPSidecar(sidecarPath(media), xmpSidecarData{AssetID: "x1", ExportedFilename: "photo.jpg"}); err != nil { t.Fatal(err) } var out bytes.Buffer if got := verifySidecar(&out, dir, "x1", "photo.jpg", true); got != 0 || out.Len() != 0 { t.Fatalf("strict valid got=%d out=%q", got, out.String()) } if err := os.WriteFile(sidecarPath(media), []byte(`photoscli:assetID="x1"`), 0644); err != nil { t.Fatal(err) } out.Reset() if got := verifySidecar(&out, dir, "x1", "photo.jpg", true); got != 1 || !strings.Contains(out.String(), "sidecar-schema-missing") { t.Fatalf("strict schema got=%d out=%q", got, out.String()) } if err := os.WriteFile(sidecarPath(media), []byte(``), 0644); err != nil { t.Fatal(err) } out.Reset() if got := verifySidecar(&out, dir, "x1", "photo.jpg", true); got != 1 || !strings.Contains(out.String(), "sidecar-generator-missing") { t.Fatalf("strict generator got=%d out=%q", got, out.String()) } if err := os.WriteFile(sidecarPath(media), []byte(``), 0644); err != nil { t.Fatal(err) } out.Reset() if got := verifySidecar(&out, dir, "x1", "photo.jpg", true); got != 1 || !strings.Contains(out.String(), "sidecar-filename-mismatch") { t.Fatalf("strict filename got=%d out=%q", got, out.String()) } } func TestSidecarInspect(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "photo.xmp") if err := writeXMPSidecar(path, xmpSidecarData{AssetID: "x1", ExportedFilename: "photo.jpg", Album: "Trips"}); err != nil { t.Fatal(err) } out, stderr, rc := runWith([]string{"sidecar", "inspect", path}, &mockBridge{}) if rc != exitOK || stderr != "" || !strings.Contains(out, "assetID\tx1") || !strings.Contains(out, "album\tTrips") { t.Fatalf("inspect rc=%d out=%q stderr=%q", rc, out, stderr) } out, stderr, rc = runWith([]string{"sidecar", "inspect", path, "--json"}, &mockBridge{}) if rc != exitOK || stderr != "" || !strings.Contains(out, `"assetID":"x1"`) || !strings.Contains(out, `"exportedFilename":"photo.jpg"`) { t.Fatalf("inspect json rc=%d out=%q stderr=%q", rc, out, stderr) } _, stderr, rc = runWith([]string{"sidecar"}, &mockBridge{}) if rc != exitErr || !strings.Contains(stderr, "expected sidecar inspect") { t.Fatalf("inspect missing subcommand rc=%d stderr=%q", rc, stderr) } _, stderr, rc = runWith([]string{"sidecar", "inspect"}, &mockBridge{}) if rc != exitErr || !strings.Contains(stderr, "requires ") { t.Fatalf("inspect missing path rc=%d stderr=%q", rc, stderr) } _, stderr, rc = runWith([]string{"sidecar", "inspect", filepath.Join(dir, "missing.xmp")}, &mockBridge{}) if rc != exitErr || !strings.Contains(stderr, "error:") { t.Fatalf("inspect missing file rc=%d stderr=%q", rc, stderr) } plain := filepath.Join(dir, "plain.xmp") if err := os.WriteFile(plain, []byte(``), 0644); err != nil { t.Fatal(err) } _, stderr, rc = runWith([]string{"sidecar", "inspect", plain}, &mockBridge{}) if rc != exitErr || !strings.Contains(stderr, "no photoscli metadata") { t.Fatalf("inspect no metadata rc=%d stderr=%q", rc, stderr) } bad := inspectXMP([]byte(``)) if len(bad) != 0 { t.Fatalf("expected empty metadata on malformed XML, got %#v", bad) } stderrBuf := &bytes.Buffer{} if rc := cmdSidecar([]string{"inspect", path, "--json"}, errWriter{}, stderrBuf); rc != exitErr || !strings.Contains(stderrBuf.String(), "error:") { t.Fatalf("expected json encoder error rc=%d stderr=%q", rc, stderrBuf.String()) } } func TestXMPSidecarHelpers(t *testing.T) { if got := sidecarPath("/tmp/IMG_0001.HEIC"); got != "/tmp/IMG_0001.xmp" { t.Fatalf("sidecar path = %q", got) } xmp := string(renderXMP(xmpSidecarData{ AssetID: `id&<>"`, OriginalFilename: "IMG_0001.HEIC", ExportedFilename: "IMG_0001.jpg", Album: "A&B", AlbumPath: "/tmp/A&B", Keywords: []string{"A&B", "Trips"}, ManifestPath: "A&B/IMG_0001.jpg", MediaType: "image", MediaSubtypes: []string{"photoLive", `hdr&<>"`}, SourceType: "userLibrary", PlaybackStyle: "livePhoto", PixelWidth: 10, PixelHeight: 20, Duration: 1.25, IsFavorite: true, IsHidden: true, HasAdjustments: true, Cloud: "local", ExportMode: "preview", PhotoscliVersion: "test", ExportedAt: "2026-01-01T00:00:00Z", Size: 123, CreateDate: "2024-01-01T00:00:00Z", ModifyDate: "2024-02-01T00:00:00Z", Location: &photos.AssetLocation{Latitude: 59.3293, Longitude: 18.0686, Altitude: 10, HorizontalAccuracy: 5}, Placemark: &photos.Placemark{Country: "Sweden", CountryCode: "SE", Locality: "Stockholm", FormattedAddress: "Stockholm, Sweden", AreasOfInterest: []string{"Gamla stan"}}, BurstIdentifier: "burst1", RepresentsBurst: true, BurstSelectionTypes: []string{"autoPick"}, AdjustmentInfo: &photos.AdjustmentInfo{FormatIdentifier: "com.apple", FormatVersion: "1.0", BaseFilename: "base.heic"}, Resources: []photos.AssetResource{{Type: "photo", Filename: `res&.heic`, UTI: "public.heic", Local: true, Size: 99}}, })) for _, want := range []string{"photoscli:xmpSchemaVersion=\"2\"", "photoscli:assetID=\"id&<>"\"", "photoscli:isFavorite=\"true\"", "xmp:Rating=\"5\"", "photoscli:hasAdjustments=\"true\"", "xmp:ModifyDate=\"2024-02-01T00:00:00Z\"", "photoshop:DateCreated=\"2024-01-01T00:00:00Z\"", "exif:GPSLatitude=\"59.32930000\"", "photoscli:latitude=\"59.32930000\"", "photoscli:addressCountry=\"Sweden\"", "photoscli:adjustmentFormatIdentifier=\"com.apple\"", "", "A&B", "", "photoscli:resourceFilename=\"res&.heic\"", ""} { if !strings.Contains(xmp, want) { t.Fatalf("XMP missing %q in %s", want, xmp) } } } func TestKeywordsFromAlbumPath(t *testing.T) { got := keywordsFromAlbumPath("Album", "Trips/Album/2024") want := []string{"Album", "Trips", "2024"} if len(got) != len(want) { t.Fatalf("keywords len=%d want=%d: %#v", len(got), len(want), got) } for i := range want { if got[i] != want[i] { t.Fatalf("keywords[%d]=%q want %q in %#v", i, got[i], want[i], got) } } if got := keywordsFromAlbumPath("", "."); len(got) != 0 { t.Fatalf("expected no dot keyword, got %#v", got) } } func TestWriteXMPSidecar(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "photo.xmp") if err := writeXMPSidecar(path, xmpSidecarData{AssetID: "x1", ExportedFilename: "photo.jpg"}); err != nil { t.Fatal(err) } data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if !strings.Contains(string(data), "photoscli:assetID=\"x1\"") { t.Fatalf("unexpected xmp: %s", string(data)) } badParent := filepath.Join(t.TempDir(), "file") if err := os.WriteFile(badParent, []byte("x"), 0644); err != nil { t.Fatal(err) } if err := writeXMPSidecar(filepath.Join(badParent, "bad.xmp"), xmpSidecarData{}); err == nil { t.Fatal("expected mkdir error") } } func TestWriteJSONSidecar(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "photo.json") if got := jsonSidecarPath(filepath.Join(dir, "photo.jpg")); got != path { t.Fatalf("json sidecar path=%q", got) } if !sidecarEnabled("xmp,json", "json") || sidecarEnabled("xmp", "json") { t.Fatal("sidecarEnabled mismatch") } if err := writeJSONSidecar(path, xmpSidecarData{AssetID: "x1", ExportedFilename: "photo.jpg"}); err != nil { t.Fatal(err) } data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if !strings.Contains(string(data), `"AssetID": "x1"`) { t.Fatalf("unexpected json sidecar: %s", string(data)) } badParent := filepath.Join(t.TempDir(), "file") if err := os.WriteFile(badParent, []byte("x"), 0644); err != nil { t.Fatal(err) } if err := writeJSONSidecar(filepath.Join(badParent, "bad.json"), xmpSidecarData{}); err == nil { t.Fatal("expected mkdir error") } oldCreate := createTempFunc createTempFunc = func(string, string) (*os.File, error) { return nil, fmt.Errorf("create") } if err := writeJSONSidecar(path, xmpSidecarData{}); err == nil { t.Fatal("expected create temp error") } createTempFunc = oldCreate oldWrite := writeFileFunc writeFileFunc = func(string, []byte, os.FileMode) error { return fmt.Errorf("write") } if err := writeJSONSidecar(path, xmpSidecarData{}); err == nil { t.Fatal("expected write error") } writeFileFunc = oldWrite oldRename := renameFunc renameFunc = func(string, string) error { return fmt.Errorf("rename") } if err := writeJSONSidecar(path, xmpSidecarData{}); err == nil { t.Fatal("expected rename error") } renameFunc = oldRename } func TestSidecarExportIntegration(t *testing.T) { dir := t.TempDir() date := "2024-01-02T03:04:05Z" b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "orig&.HEIC", MediaType: "image", PixelWidth: 10, PixelHeight: 20, IsFavorite: true, CreationDate: &date}}} b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) { if err := os.WriteFile(filepath.Join(out, "photo.jpg"), []byte("data"), 0644); err != nil { return photos.ExportResult{}, err } return photos.ExportResult{Filename: "photo.jpg", Size: 4, Cloud: "local"}, nil } exported, failed := exportAssets(b.assets, dir, 1024, 85, 3, false, 1, io.Discard, b, "Album", false, manifest.FormatJSONL, false, exportOptions{sidecar: "xmp,json"}) if exported != 1 || failed != 0 { t.Fatalf("exported=%d failed=%d", exported, failed) } data, err := os.ReadFile(filepath.Join(dir, "photo.xmp")) if err != nil { t.Fatal(err) } content := string(data) for _, want := range []string{"photoscli:assetID=\"x1\"", "photoscli:originalFilename=\"orig&.HEIC\"", "photoscli:album=\"Album\"", "xmp:CreateDate=\"2024-01-02T03:04:05Z\""} { if !strings.Contains(content, want) { t.Fatalf("sidecar missing %q in %s", want, content) } } if _, err := os.Stat(filepath.Join(dir, "photo.jpg.xmp")); !os.IsNotExist(err) { t.Fatal("sidecar should use basename, not double extension") } jsonData, err := os.ReadFile(filepath.Join(dir, "photo.json")) if err != nil { t.Fatal(err) } if !strings.Contains(string(jsonData), `"AssetID": "x1"`) { t.Fatalf("json sidecar missing asset ID: %s", string(jsonData)) } } func TestSidecarReverseGeocodeCache(t *testing.T) { dir := t.TempDir() geoCalls := 0 b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "geo.jpg", Location: &photos.AssetLocation{Latitude: 59.3293, Longitude: 18.0686}}}} b.reverseGeocodeFn = func(lat, lon float64) (photos.Placemark, error) { geoCalls++ return photos.Placemark{Country: "Sweden", Locality: "Stockholm", FormattedAddress: "Stockholm, Sweden"}, nil } b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) { name := fmt.Sprintf("photo%d.jpg", geoCalls) if err := os.WriteFile(filepath.Join(out, name), []byte("data"), 0644); err != nil { return photos.ExportResult{}, err } return photos.ExportResult{Filename: name, Size: 4, Cloud: "local"}, nil } for i := 0; i < 2; i++ { exported, failed := exportAssets(b.assets, dir, 1024, 85, 1, false, 1, io.Discard, b, "Album", true, manifest.FormatJSONL, false, exportOptions{sidecar: "xmp", reverseGeocode: true}) if exported != 1 || failed != 0 { t.Fatalf("run %d exported=%d failed=%d", i, exported, failed) } } if geoCalls != 1 { t.Fatalf("expected cached geocode after first call, got %d", geoCalls) } data, err := os.ReadFile(filepath.Join(dir, "photo1.xmp")) if err != nil { t.Fatal(err) } if !strings.Contains(string(data), "photoscli:addressCity=\"Stockholm\"") || !strings.Contains(string(data), "photoscli:reverseGeocoder=\"MapKit\"") { t.Fatalf("missing geocode fields: %s", string(data)) } if _, err := os.Stat(filepath.Join(dir, ".photoscli", "geocode-cache.jsonl")); err != nil { t.Fatalf("missing geocode cache: %v", err) } } func TestGeocodeCacheBranches(t *testing.T) { dir := t.TempDir() cache := newGeocodeCache(dir) if got := cache.lookup(1, 2, &mockBridge{reverseGeocodeFn: func(float64, float64) (photos.Placemark, error) { return photos.Placemark{}, fmt.Errorf("offline") }}); got != nil { t.Fatalf("expected nil on geocode error, got %+v", got) } if got := (*geocodeCache)(nil).lookup(1, 2, &mockBridge{}); got != nil { t.Fatalf("expected nil cache lookup, got %+v", got) } oldOpen := openFileFunc openFileFunc = func(string, int, os.FileMode) (*os.File, error) { return nil, fmt.Errorf("open") } got := cache.lookup(3, 4, &mockBridge{reverseGeocodeFn: func(float64, float64) (photos.Placemark, error) { return photos.Placemark{Country: "Nowhere"}, nil }}) openFileFunc = oldOpen if got == nil || got.Country != "Nowhere" { t.Fatalf("expected placemark despite cache write error, got %+v", got) } } func TestSidecarReverseGeocodeWithoutCache(t *testing.T) { dir := t.TempDir() pa := pendingAsset{asset: photos.Asset{ID: "x1", Filename: "geo.jpg", Location: &photos.AssetLocation{Latitude: 1, Longitude: 2}}, root: dir, path: dir} if err := writeSidecarIfNeeded(pa, photos.ExportResult{Filename: "geo.jpg", Size: 1}, false, exportOptions{sidecar: "xmp", reverseGeocode: true}, nil, &mockBridge{}); err != nil { t.Fatal(err) } data, err := os.ReadFile(filepath.Join(dir, "geo.xmp")) if err != nil { t.Fatal(err) } content := string(data) if !strings.Contains(content, "photoscli:latitude=\"1.00000000\"") || strings.Contains(content, "photoscli:reverseGeocoder") { t.Fatalf("unexpected reverse geocode content: %s", content) } } func TestSidecarModificationDate(t *testing.T) { dir := t.TempDir() modified := "2024-03-04T05:06:07Z" pa := pendingAsset{asset: photos.Asset{ID: "x1", Filename: "mod.jpg", ModificationDate: &modified}, path: dir} if err := writeSidecarIfNeeded(pa, photos.ExportResult{Filename: "mod.jpg", Size: 1}, false, exportOptions{sidecar: "xmp"}, nil, &mockBridge{}); err != nil { t.Fatal(err) } data, err := os.ReadFile(filepath.Join(dir, "mod.xmp")) if err != nil { t.Fatal(err) } if !strings.Contains(string(data), "xmp:ModifyDate=\"2024-03-04T05:06:07Z\"") { t.Fatalf("missing modify date: %s", string(data)) } } func TestExportPendingReverseGeocodeNoPending(t *testing.T) { bar := newProgressBar(io.Discard, 1) done, failed := exportPending(nil, 1024, 85, false, 0, bar, &mockBridge{}, nil, 1, manifest.NoopLogWriter, exportOptions{sidecar: "xmp", reverseGeocode: true}) if done != 0 || failed != 0 { t.Fatalf("done=%d failed=%d", done, failed) } } func TestExportPendingGeocodeCacheRootFallback(t *testing.T) { dir := t.TempDir() a := photos.Asset{ID: "g1", Filename: "geo.jpg", Location: &photos.AssetLocation{Latitude: 1, Longitude: 2}} b := &mockBridge{assets: []photos.Asset{a}, reverseGeocodeFn: func(float64, float64) (photos.Placemark, error) { return photos.Placemark{Country: "Sweden"}, nil }} b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) { if err := os.WriteFile(filepath.Join(out, "geo.jpg"), []byte("data"), 0644); err != nil { return photos.ExportResult{}, err } return photos.ExportResult{Filename: "geo.jpg", Size: 4}, nil } bar := newProgressBar(io.Discard, 1) done, failed := exportPending([]pendingAsset{{asset: a, path: dir}}, 1024, 85, false, 1, bar, b, nil, 1, manifest.NoopLogWriter, exportOptions{sidecar: "xmp", reverseGeocode: true}) if done != 1 || failed != 0 { t.Fatalf("done=%d failed=%d", done, failed) } if _, err := os.Stat(filepath.Join(dir, ".photoscli", "geocode-cache.jsonl")); err != nil { t.Fatalf("expected fallback-root cache: %v", err) } } func TestExportPendingCreatesGeocodeCacheForParallel(t *testing.T) { dir := t.TempDir() assets := []photos.Asset{ {ID: "g1", Filename: "one.jpg", Location: &photos.AssetLocation{Latitude: 1, Longitude: 2}}, {ID: "g2", Filename: "two.jpg", Location: &photos.AssetLocation{Latitude: 3, Longitude: 4}}, {ID: "g3", Filename: "three.jpg", Location: &photos.AssetLocation{Latitude: 5, Longitude: 6}}, {ID: "g4", Filename: "four.jpg", Location: &photos.AssetLocation{Latitude: 7, Longitude: 8}}, } pending := make([]pendingAsset, len(assets)) for i, a := range assets { pending[i] = pendingAsset{asset: a, root: dir, path: dir, album: "Geo"} } b := &mockBridge{assets: assets, reverseGeocodeFn: func(float64, float64) (photos.Placemark, error) { return photos.Placemark{Country: "Sweden"}, nil }} b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) { name := assetID + ".jpg" if err := os.WriteFile(filepath.Join(out, name), []byte("data"), 0644); err != nil { return photos.ExportResult{}, err } return photos.ExportResult{Filename: name, Size: 4}, nil } bar := newProgressBar(io.Discard, 4) done, failed := exportPending(pending, 1024, 85, false, len(pending), bar, b, nil, 4, manifest.NoopLogWriter, exportOptions{sidecar: "xmp", reverseGeocode: true}) if done != len(pending) || failed != 0 { t.Fatalf("done=%d failed=%d", done, failed) } if _, err := os.Stat(filepath.Join(dir, ".photoscli", "geocode-cache.jsonl")); err != nil { t.Fatalf("expected geocode cache: %v", err) } } func TestSidecarConfigAndErrors(t *testing.T) { oldConfigValues, oldConfigLoaded := configValues, configLoaded defer func() { configValues, configLoaded = oldConfigValues, oldConfigLoaded }() dir := t.TempDir() cfg := filepath.Join(dir, "config.toml") if err := os.WriteFile(cfg, []byte("sidecar = \"xmp\"\n"), 0644); err != nil { t.Fatal(err) } t.Setenv("PHOTOSCLI_CONFIG", cfg) configValues, configLoaded = nil, false opts, ok := parseExportOptions(nil, io.Discard) if !ok || opts.sidecar != "xmp" { t.Fatalf("expected sidecar config, opts=%+v ok=%v", opts, ok) } var stderr bytes.Buffer if _, ok := parseExportOptions([]string{"--sidecar", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--sidecar") { t.Fatalf("expected sidecar validation error, stderr=%q", stderr.String()) } stderr.Reset() if opts, ok := parseExportOptions([]string{"--sidecar", "json"}, &stderr); !ok || opts.sidecar != "json" || stderr.Len() != 0 { t.Fatalf("expected json sidecar option, opts=%+v ok=%v stderr=%q", opts, ok, stderr.String()) } stderr.Reset() if _, ok := parseExportOptions([]string{"--sidecar", "xmp,bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--sidecar") { t.Fatalf("expected mixed sidecar validation error, stderr=%q", stderr.String()) } stderr.Reset() if _, ok := parseExportOptions([]string{"--xmp-privacy", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--xmp-privacy") { t.Fatalf("expected xmp privacy validation error, stderr=%q", stderr.String()) } stderr.Reset() if _, ok := parseExportOptions([]string{"--xmp-keywords", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--xmp-keywords") { t.Fatalf("expected xmp keywords validation error, stderr=%q", stderr.String()) } stderr.Reset() if _, ok := parseExportOptions([]string{"--xmp-rating", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--xmp-rating") { t.Fatalf("expected xmp rating validation error, stderr=%q", stderr.String()) } stderr.Reset() if _, ok := parseExportOptions([]string{"--checksum", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--checksum") { t.Fatalf("expected checksum validation error, stderr=%q", stderr.String()) } b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "photo.jpg"}}} b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) { return photos.ExportResult{Filename: "photo.jpg", Size: 4}, nil } oldRename := renameFunc renameFunc = func(string, string) error { return fmt.Errorf("sidecar rename") } exported, failed := exportAssets(b.assets, dir, 1024, 85, 3, false, 1, io.Discard, b, "", false, manifest.FormatJSONL, false, exportOptions{sidecar: "json"}) if exported != 0 || failed != 1 { t.Fatalf("expected json sidecar failure, exported=%d failed=%d", exported, failed) } exported, failed = exportAssets(b.assets, dir, 1024, 85, 3, false, 1, io.Discard, b, "", false, manifest.FormatJSONL, false, exportOptions{sidecar: "xmp"}) renameFunc = oldRename if exported != 0 || failed != 1 { t.Fatalf("expected xmp sidecar failure, exported=%d failed=%d", exported, failed) } } func TestXMPSidecarPrivacy(t *testing.T) { dir := t.TempDir() asset := photos.Asset{ID: "x1", Filename: "geo.jpg", Location: &photos.AssetLocation{Latitude: 59.3293, Longitude: 18.0686}} bridge := &mockBridge{} bridge.reverseGeocodeFn = func(float64, float64) (photos.Placemark, error) { return photos.Placemark{Country: "Sweden", Locality: "Stockholm"}, nil } pa := pendingAsset{asset: asset, root: dir, path: dir, album: "Album"} for _, tc := range []struct { privacy string wantGPS bool wantAddress bool }{ {privacy: "keep", wantGPS: true, wantAddress: true}, {privacy: "strip-address", wantGPS: true, wantAddress: false}, {privacy: "strip-location", wantGPS: false, wantAddress: false}, } { path := filepath.Join(dir, tc.privacy+".jpg") if err := os.WriteFile(path, []byte("data"), 0644); err != nil { t.Fatal(err) } if err := writeSidecarIfNeeded(pa, photos.ExportResult{Filename: filepath.Base(path), Size: 4}, false, exportOptions{sidecar: "xmp", reverseGeocode: true, xmpPrivacy: tc.privacy}, newGeocodeCache(dir), bridge); err != nil { t.Fatalf("%s write sidecar: %v", tc.privacy, err) } data, err := os.ReadFile(sidecarPath(path)) if err != nil { t.Fatal(err) } content := string(data) if strings.Contains(content, "photoscli:latitude") != tc.wantGPS { t.Fatalf("%s GPS presence mismatch in %s", tc.privacy, content) } if strings.Contains(content, "photoscli:addressCountry") != tc.wantAddress { t.Fatalf("%s address presence mismatch in %s", tc.privacy, content) } } } func TestXMPSidecarKeywordAndRatingOptions(t *testing.T) { dir := t.TempDir() asset := photos.Asset{ID: "x1", Filename: "photo.jpg", IsFavorite: true} pa := pendingAsset{asset: asset, root: dir, path: filepath.Join(dir, "Trips", "Beach"), album: "Beach"} if err := os.MkdirAll(pa.path, 0755); err != nil { t.Fatal(err) } for _, tc := range []struct { name string keywords string rating string wantTrip bool wantBeach bool wantRate bool }{ {name: "default", wantTrip: true, wantBeach: true, wantRate: true}, {name: "album", keywords: "album", wantTrip: false, wantBeach: true, wantRate: true}, {name: "none", keywords: "none", rating: "none", wantTrip: false, wantBeach: false, wantRate: false}, } { filename := tc.name + ".jpg" path := filepath.Join(pa.path, filename) if err := os.WriteFile(path, []byte("data"), 0644); err != nil { t.Fatal(err) } if err := writeSidecarIfNeeded(pa, photos.ExportResult{Filename: filename, Size: 4}, false, exportOptions{sidecar: "xmp", xmpKeywords: tc.keywords, xmpRating: tc.rating}, nil, &mockBridge{}); err != nil { t.Fatalf("%s write sidecar: %v", tc.name, err) } data, err := os.ReadFile(sidecarPath(path)) if err != nil { t.Fatal(err) } content := string(data) if strings.Contains(content, "Trips") != tc.wantTrip { t.Fatalf("%s Trips keyword mismatch in %s", tc.name, content) } if strings.Contains(content, "Beach") != tc.wantBeach { t.Fatalf("%s Beach keyword mismatch in %s", tc.name, content) } if strings.Contains(content, "xmp:Rating=\"5\"") != tc.wantRate { t.Fatalf("%s rating mismatch in %s", tc.name, content) } } } func TestMetadataOnlyExport(t *testing.T) { dir := t.TempDir() m := manifest.LoadJSONL(dir) if err := m.OpenAppend(); err != nil { t.Fatal(err) } m.AddEntry(manifest.Entry{ID: "x1", Filename: "photo.jpg", Path: "photo.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()}) m.Close() if err := os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("data"), 0644); err != nil { t.Fatal(err) } b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "orig.heic", MediaType: "image", IsFavorite: true}}} b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) { t.Fatal("metadata-only must not export media") return photos.ExportResult{}, nil } out, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--sidecar", "xmp", "--metadata-only"}, b) if rc != exitOK || out != "" || !strings.Contains(stderr, "wrote 1 metadata sidecars") { t.Fatalf("metadata-only rc=%d out=%q stderr=%q", rc, out, stderr) } data, err := os.ReadFile(filepath.Join(dir, "photo.xmp")) if err != nil { t.Fatal(err) } if !strings.Contains(string(data), "photoscli:assetID=\"x1\"") || !strings.Contains(string(data), "xmp:Rating=\"5\"") { t.Fatalf("unexpected metadata sidecar: %s", string(data)) } } func TestMetadataOnlyExportErrors(t *testing.T) { dir := t.TempDir() b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "photo.jpg"}}} _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--metadata-only"}, b) if rc != exitErr || !strings.Contains(stderr, "--metadata-only requires --sidecar") { t.Fatalf("expected sidecar requirement rc=%d stderr=%q", rc, stderr) } _, stderr, rc = runWith([]string{"export", "--album-id", "x", "--out", dir, "--sidecar", "xmp", "--metadata-only", "--no-manifest"}, b) if rc != exitErr || !strings.Contains(stderr, "requires a manifest") { t.Fatalf("expected manifest requirement rc=%d stderr=%q", rc, stderr) } _, stderr, rc = runWith([]string{"backup-all", "--out", dir, "--sidecar", "xmp", "--metadata-only", "--no-manifest"}, &mockBridge{}) if rc != exitErr || !strings.Contains(stderr, "requires a manifest") { t.Fatalf("expected backup manifest requirement rc=%d stderr=%q", rc, stderr) } } func TestMetadataOnlyHelperBranches(t *testing.T) { dir := t.TempDir() pa := pendingAsset{asset: photos.Asset{ID: "x1", Filename: "photo.jpg"}, path: dir} if err := writeMetadataOnlySidecar(pa, manifest.Entry{ID: "x1"}, false, exportOptions{sidecar: "xmp"}, nil, &mockBridge{}); err == nil || !strings.Contains(err.Error(), "no path") { t.Fatalf("expected no path error, got %v", err) } if err := writeMetadataOnlySidecar(pa, manifest.Entry{ID: "x1", Path: "missing.jpg"}, false, exportOptions{sidecar: "xmp"}, nil, &mockBridge{}); err == nil || !strings.Contains(err.Error(), "missing") { t.Fatalf("expected missing error, got %v", err) } if err := os.WriteFile(filepath.Join(dir, "zero.jpg"), nil, 0644); err != nil { t.Fatal(err) } if err := writeMetadataOnlySidecar(pa, manifest.Entry{ID: "x1", Path: "zero.jpg"}, false, exportOptions{sidecar: "xmp"}, nil, &mockBridge{}); err == nil || !strings.Contains(err.Error(), "zero-byte") { t.Fatalf("expected zero-byte error, got %v", err) } if entries := manifestEntries(noEntryManifest{}); entries != nil { t.Fatalf("expected nil entries, got %#v", entries) } written, failed := metadataOnlyPending([]pendingAsset{{asset: photos.Asset{ID: "x1"}, path: dir}}, map[string]manifest.Entry{"x1": {ID: "x1", Path: "missing.jpg"}}, false, exportOptions{sidecar: "xmp"}, &mockBridge{}) if written != 0 || failed != 1 { t.Fatalf("expected failed metadata pending, written=%d failed=%d", written, failed) } if err := os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("data"), 0644); err != nil { t.Fatal(err) } written, failed = metadataOnlyPending([]pendingAsset{{asset: photos.Asset{ID: "x2", Location: &photos.AssetLocation{Latitude: 1, Longitude: 2}}, path: dir}}, map[string]manifest.Entry{"x2": {ID: "x2", Path: "photo.jpg"}}, false, exportOptions{sidecar: "xmp", reverseGeocode: true}, &mockBridge{reverseGeocodeFn: func(float64, float64) (photos.Placemark, error) { return photos.Placemark{Country: "Sweden"}, nil }}) if written != 1 || failed != 0 { t.Fatalf("expected reverse geocode metadata success, written=%d failed=%d", written, failed) } } func TestMetadataOnlyBackupAll(t *testing.T) { dir := t.TempDir() m := manifest.LoadJSONL(dir) if err := m.OpenAppend(); err != nil { t.Fatal(err) } m.AddEntry(manifest.Entry{ID: "x1", Filename: "photo.jpg", Path: "Album/photo.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()}) m.Close() albumDir := filepath.Join(dir, "Album") if err := os.Mkdir(albumDir, 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(albumDir, "photo.jpg"), []byte("data"), 0644); err != nil { t.Fatal(err) } b := &mockBridge{ tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, assetsByAlbum: map[string][]photos.Asset{"a1": {{ID: "x1", Filename: "orig.heic", MediaType: "image"}, {ID: "x2", Filename: "missing.heic", MediaType: "image"}}}, } b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) { t.Fatal("metadata-only backup-all must not export media") return photos.ExportResult{}, nil } _, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--sidecar", "xmp", "--metadata-only"}, b) if rc != exitOK || !strings.Contains(stderr, "wrote 1 metadata sidecars") { t.Fatalf("metadata-only backup rc=%d stderr=%q", rc, stderr) } if _, err := os.Stat(filepath.Join(albumDir, "photo.xmp")); err != nil { t.Fatalf("expected metadata-only sidecar: %v", err) } } func TestSidecarAdditionalBranches(t *testing.T) { dir := t.TempDir() oldCreateTemp := createTempFunc createTempFunc = func(string, string) (*os.File, error) { return nil, fmt.Errorf("createtemp") } if err := writeXMPSidecar(filepath.Join(dir, "bad.xmp"), xmpSidecarData{}); err == nil || !strings.Contains(err.Error(), "createtemp") { t.Fatalf("expected create temp error, got %v", err) } createTempFunc = oldCreateTemp oldWriteFile := writeFileFunc writeFileFunc = func(string, []byte, os.FileMode) error { return fmt.Errorf("writefile") } if err := writeXMPSidecar(filepath.Join(dir, "badwrite.xmp"), xmpSidecarData{}); err == nil || !strings.Contains(err.Error(), "writefile") { t.Fatalf("expected write file error, got %v", err) } writeFileFunc = oldWriteFile pa := pendingAsset{asset: photos.Asset{ID: "x1", Filename: "orig.HEIC"}, path: dir, album: "Album"} if err := writeSidecarIfNeeded(pa, photos.ExportResult{Filename: "out.jpg", Size: 1, Cloud: "local"}, true, exportOptions{sidecar: "xmp"}, nil, &mockBridge{}); err != nil { t.Fatal(err) } data, err := os.ReadFile(filepath.Join(dir, "out.xmp")) if err != nil { t.Fatal(err) } content := string(data) if !strings.Contains(content, "photoscli:exportMode=\"original\"") || !strings.Contains(content, "photoscli:manifestPath=\"out.jpg\"") { t.Fatalf("unexpected sidecar: %s", content) } otherRoot := filepath.Join(dir, "other") if err := writeSidecarIfNeeded(pendingAsset{asset: photos.Asset{ID: "x2"}, root: otherRoot, path: dir}, photos.ExportResult{Filename: "fallback.jpg"}, false, exportOptions{sidecar: "xmp"}, nil, &mockBridge{}); err != nil { t.Fatal(err) } data, err = os.ReadFile(filepath.Join(dir, "fallback.xmp")) if err != nil { t.Fatal(err) } if !strings.Contains(string(data), "photoscli:manifestPath=\"fallback.jpg\"") { t.Fatalf("expected fallback manifest path, got %s", string(data)) } } func TestParallelSidecarExport(t *testing.T) { dir := t.TempDir() assets := []photos.Asset{ {ID: "x1", Filename: "one.jpg"}, {ID: "x2", Filename: "two.jpg"}, {ID: "x3", Filename: "three.jpg"}, {ID: "x4", Filename: "four.jpg"}, } b := &mockBridge{assets: assets} b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) { name := fmt.Sprintf("%s.jpg", assetID) if err := os.WriteFile(filepath.Join(out, name), []byte("data"), 0644); err != nil { return photos.ExportResult{}, err } return photos.ExportResult{Filename: name, Size: 4, Cloud: "local"}, nil } exported, failed := exportAssets(assets, dir, 1024, 85, 4, false, len(assets), io.Discard, b, "", true, manifest.FormatJSONL, false, exportOptions{sidecar: "xmp"}) if exported != 4 || failed != 0 { t.Fatalf("exported=%d failed=%d", exported, failed) } if _, err := os.Stat(filepath.Join(dir, "x4.xmp")); err != nil { t.Fatalf("expected parallel sidecar: %v", err) } } func TestParallelSidecarFailure(t *testing.T) { dir := t.TempDir() assets := []photos.Asset{{ID: "x1", Filename: "one.jpg"}, {ID: "x2", Filename: "two.jpg"}, {ID: "x3", Filename: "three.jpg"}, {ID: "x4", Filename: "four.jpg"}} b := &mockBridge{assets: assets} b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) { return photos.ExportResult{Filename: assetID + ".jpg", Size: 4, Cloud: "local"}, nil } oldWriteFile := writeFileFunc writeFileFunc = func(string, []byte, os.FileMode) error { return fmt.Errorf("parallel sidecar") } exported, failed := exportAssets(assets, dir, 1024, 85, 4, false, len(assets), io.Discard, b, "", true, manifest.FormatJSONL, false, exportOptions{sidecar: "xmp"}) writeFileFunc = oldWriteFile if exported != 0 || failed != 4 { t.Fatalf("expected sidecar failures, exported=%d failed=%d", exported, failed) } } func TestRetryFailedClearOnSuccess(t *testing.T) { dir := t.TempDir() appendFailure(dir, pendingAsset{asset: photos.Asset{ID: "bad", Filename: "bad.jpg"}, root: dir, path: dir, album: "Album"}, fmt.Errorf("boom")) out, _, rc := runWith([]string{"retry-failed", "--out", dir, "--clear-on-success"}, &mockBridge{}) if rc != exitOK || !strings.Contains(out, "exported") { t.Fatalf("retry clear rc=%d out=%q", rc, out) } if len(loadFailures(dir)) != 0 { t.Fatal("expected successful retry to clear failure") } } func TestAtomicSlotExportHelper(t *testing.T) { dir := t.TempDir() b := &mockBridge{} b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) { if err := os.WriteFile(filepath.Join(out, "slot.jpg"), []byte("data"), 0644); err != nil { return photos.ExportResult{}, err } return photos.ExportResult{Filename: "slot.jpg", Size: 4, Cloud: "local"}, nil } pa := pendingAsset{asset: photos.Asset{ID: "slot", Filename: "slot.jpg"}, root: dir, path: filepath.Join(dir, "Album"), album: "Album"} result, err := exportOneWithSlotAtomic(b, pa, 1024, 85, false, 0, 0) if err != nil || result.Filename != "slot.jpg" { t.Fatalf("slot atomic result=%+v err=%v", result, err) } } func TestInjectedErrorBranchesForCoverage(t *testing.T) { dir := t.TempDir() b := &mockBridge{} pa := pendingAsset{asset: photos.Asset{ID: "x", Filename: "x.jpg"}, root: dir, path: filepath.Join(dir, "Album")} oldMkdirTemp := mkdirTempFunc mkdirTempFunc = func(string, string) (string, error) { return "", fmt.Errorf("mkdirtemp") } if _, err := exportOneAtomic(b, pa, 1024, 85, false, 0); err == nil || !strings.Contains(err.Error(), "mkdirtemp") { t.Fatalf("expected mkdirtemp error, got %v", err) } mkdirTempFunc = oldMkdirTemp b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) { if err := os.WriteFile(filepath.Join(out, "file.jpg"), []byte("data"), 0644); err != nil { return photos.ExportResult{}, err } return photos.ExportResult{Filename: "file.jpg", Size: 4}, nil } oldRename := renameFunc renameFunc = func(string, string) error { return fmt.Errorf("rename") } if _, err := exportOneAtomic(b, pa, 1024, 85, false, 0); err == nil || !strings.Contains(err.Error(), "rename") { t.Fatalf("expected rename error, got %v", err) } renameFunc = oldRename oldOpen := openFileFunc openFileFunc = func(string, int, os.FileMode) (*os.File, error) { return nil, fmt.Errorf("open") } if err := saveFailures(dir, map[string]failureEntry{"x": {ID: "x"}}); err == nil || !strings.Contains(err.Error(), "open") { t.Fatalf("expected open error, got %v", err) } openFileFunc = oldOpen oldRemove := removeFunc removeFunc = func(string) error { return fmt.Errorf("remove") } _, stderr, rc := runWith([]string{"failures", "clear", "--out", dir}, &mockBridge{}) if rc != exitErr || !strings.Contains(stderr, "remove") { t.Fatalf("expected remove error, rc=%d stderr=%q", rc, stderr) } removeFunc = oldRemove mf := &mockManifest{} addManifestEntry(mf, pendingAsset{asset: photos.Asset{ID: "x"}, root: filepath.Join(dir, "other"), path: dir}, photos.ExportResult{Filename: "file.jpg", Size: 1}, "") if mf.last.Path != "file.jpg" { t.Fatalf("expected fallback rel path, got %+v", mf.last) } badPathRoot := t.TempDir() badPath := filepath.Join(badPathRoot, "notdir") if err := os.WriteFile(badPath, []byte("x"), 0644); err != nil { t.Fatal(err) } pa = pendingAsset{asset: photos.Asset{ID: "x2", Filename: "x2.jpg"}, root: badPathRoot, path: badPath} b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) { if err := os.WriteFile(filepath.Join(out, "file.jpg"), []byte("data"), 0644); err != nil { return photos.ExportResult{}, err } return photos.ExportResult{Filename: "file.jpg", Size: 4}, nil } if _, err := exportOneAtomic(b, pa, 1024, 85, false, 0); err == nil { t.Fatal("expected final mkdir error") } slotRootFile := filepath.Join(t.TempDir(), "rootfile") if err := os.WriteFile(slotRootFile, []byte("x"), 0644); err != nil { t.Fatal(err) } pa = pendingAsset{asset: photos.Asset{ID: "slotfallback", Filename: "slot.jpg"}, root: slotRootFile, path: t.TempDir()} if _, err := exportOneWithSlotAtomic(b, pa, 1024, 85, true, 0, 0); err != nil { t.Fatalf("unexpected original fallback error: %v", err) } if _, err := exportOneWithSlotAtomic(b, pa, 1024, 85, false, 0, 0); err != nil { t.Fatalf("unexpected preview fallback error: %v", err) } pa = pendingAsset{asset: photos.Asset{ID: "slotzero", Filename: "slot.jpg"}, root: dir, path: filepath.Join(dir, "Slot")} b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) { if err := os.WriteFile(filepath.Join(out, "empty.jpg"), nil, 0644); err != nil { return photos.ExportResult{}, err } return photos.ExportResult{Filename: "empty.jpg"}, nil } if _, err := exportOneWithSlotAtomic(b, pa, 1024, 85, false, 0, 0); err == nil || !strings.Contains(err.Error(), "zero-byte") { t.Fatalf("expected slot zero-byte error, got %v", err) } b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) { if err := os.WriteFile(filepath.Join(out, "file.jpg"), []byte("data"), 0644); err != nil { return photos.ExportResult{}, err } return photos.ExportResult{Filename: "file.jpg", Size: 4}, nil } renameFunc = func(string, string) error { return fmt.Errorf("slot rename") } if _, err := exportOneWithSlotAtomic(b, pa, 1024, 85, false, 0, 0); err == nil || !strings.Contains(err.Error(), "slot rename") { t.Fatalf("expected slot rename error, got %v", err) } renameFunc = oldRename mkdirTempFunc = func(string, string) (string, error) { return "", fmt.Errorf("slot mkdirtemp") } if _, err := exportOneWithSlotAtomic(b, pa, 1024, 85, false, 0, 0); err == nil || !strings.Contains(err.Error(), "slot mkdirtemp") { t.Fatalf("expected slot mkdirtemp error, got %v", err) } mkdirTempFunc = oldMkdirTemp badSlotRoot := t.TempDir() badSlotPath := filepath.Join(badSlotRoot, "notdir") if err := os.WriteFile(badSlotPath, []byte("x"), 0644); err != nil { t.Fatal(err) } pa = pendingAsset{asset: photos.Asset{ID: "slotbadpath", Filename: "slot.jpg"}, root: badSlotRoot, path: badSlotPath} b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) { if err := os.WriteFile(filepath.Join(out, "file.jpg"), []byte("data"), 0644); err != nil { return photos.ExportResult{}, err } return photos.ExportResult{Filename: "file.jpg", Size: 4}, nil } if _, err := exportOneWithSlotAtomic(b, pa, 1024, 85, false, 0, 0); err == nil { t.Fatal("expected slot final mkdir error") } } type mockManifest struct{ last manifest.Entry } func (m *mockManifest) Has(string) bool { return false } func (m *mockManifest) Add(id string, filename string, size int64, cloud string) { m.last = manifest.NewEntry(id, filename, filename, size, cloud) } func (m *mockManifest) AddEntry(e manifest.Entry) { m.last = e } func (m *mockManifest) Save() error { return nil } func (m *mockManifest) Close() {} func (m *mockManifest) OpenAppend() error { return nil } func (m *mockManifest) Entries() map[string]manifest.Entry { return nil }