//go:build test package main import ( "bytes" "fmt" "strings" "sync/atomic" "testing" "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 tree []photos.CollectionNode treeErr error exportPreviewFn func(string, string, int, int) (photos.ExportResult, error) exportOrigFn func(string, string, int) (photos.ExportResult, error) cancelled 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.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), m.assetsErr } func (m *mockBridge) ListTree() ([]photos.CollectionNode, error) { return m.tree, m.treeErr } func (m *mockBridge) ExportPreview(assetID, out string, targetSize, index int) (photos.ExportResult, error) { if m.exportPreviewFn != nil { return m.exportPreviewFn(assetID, out, targetSize, 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, index, slotIndex int) (photos.ExportResult, error) { return m.ExportPreview(assetID, out, targetSize, 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 = true } func (m *mockBridge) IsCancelled() bool { return m.cancelled } 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 != 1 { t.Errorf("rc = %d, want 1", rc) } 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 != 1 { t.Errorf("rc = %d, want 1", rc) } 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 != 1 { t.Errorf("rc = %d", rc) } 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 != 1 { t.Errorf("rc = %d", rc) } 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 != 1 { t.Errorf("rc = %d, want 1", rc) } 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 != 1 { t.Errorf("rc = %d, want 1", rc) } 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"}, 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"}, 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"}, &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"}, 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"}, 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) (photos.ExportResult, error) { return photos.ExportResult{}, fmt.Errorf("disk full") }, } _, stderr, rc := runWith([]string{"backup-all", "--out", "/backup"}, 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"}, &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"}, &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"}, &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"}, &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"}, 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"}, 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"}, b) if rc != 1 { t.Errorf("rc = %d", rc) } 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) (photos.ExportResult, error) { return photos.ExportResult{}, fmt.Errorf("disk full") }, } _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp"}, 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: disk full") { t.Errorf("stderr should contain failure detail, got: %q", stderr) } } 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"}, 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"}, 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"}, 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"}, 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"}, 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) (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"}, b) if rc != 0 { t.Errorf("rc = %d, stderr = %q", rc, 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"}, 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) (photos.ExportResult, error) { return photos.ExportResult{}, fmt.Errorf("error") }, } _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp"}, 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"}, b) if rc != 1 { t.Errorf("rc = %d, want 1", rc) } 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"}, 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"}, 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"}, 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, 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"}, b) if rc != 0 { t.Errorf("rc = %d, want 0 (partial success)", rc) } 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"}, 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"}, 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"}, 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) } }