//go:build test package main import ( "bytes" "fmt" "strings" "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 exportN int exportErr error exportOrigN int exportOrigErr error exportPreviewFn func(string, string, int) (int, error) exportOrigFn func(string, string) (int, error) backupAllN int backupAllErr error backupAllFn func(string, int, bool) (int, error) cancelled bool exportPreviewFn2 func(string, string, int, int) (photos.ExportResult, error) exportOrigFn2 func(string, string, int) (photos.ExportResult, error) } 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) ExportAlbumPreviews(albumID, out string, size int) (int, error) { if m.exportPreviewFn != nil { return m.exportPreviewFn(albumID, out, size) } return m.exportN, m.exportErr } func (m *mockBridge) ExportAlbumOriginals(albumID, out string) (int, error) { if m.exportOrigFn != nil { return m.exportOrigFn(albumID, out) } return m.exportOrigN, m.exportOrigErr } func (m *mockBridge) ExportPreview(assetID, out string, targetSize, index int) (photos.ExportResult, error) { if m.exportPreviewFn2 != nil { return m.exportPreviewFn2(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.exportOrigFn2 != nil { return m.exportOrigFn2(assetID, out, index) } return photos.ExportResult{Filename: "test.jpg", Size: 2048, Cloud: "cloud"}, nil } func (m *mockBridge) BackupAll(out string, size int, originals bool) (int, error) { if m.backupAllFn != nil { return m.backupAllFn(out, size, originals) } return m.backupAllN, m.backupAllErr } func (m *mockBridge) Cancel() { m.cancelled = true } 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 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"}, {ID: "as2", Filename: "IMG_0002.JPG", Cloud: true}}, } out, _, rc := runWith([]string{"photos", "--album-id", "x"}, b) if rc != 0 { t.Errorf("rc = %d", rc) } expected := "as1\tIMG_0001.JPG\tlocal\nas2\tIMG_0002.JPG\tcloud\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{assetsErr: 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) } } 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) } } 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"}}, }, exportPreviewFn2: 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) } _ = 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"}}, exportPreviewFn2: 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) } } 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"}}, exportOrigFn2: 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) } } 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"}}, }, } 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"}}, }, } 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"}}, }, } 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) } }