//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 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) 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 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"}, {ID: "as2", Filename: "IMG_0002.JPG", Cloud: "cloud"}}, } 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{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, "failed: 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, "failed: 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, "failed: 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, "failed: 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, "skipped 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()) } }