//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) cancelled atomic.Bool } 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) 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 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 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("x"), 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) } 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) } }