Files
photocli/cmd/photoscli/main_test.go
T

1089 lines
31 KiB
Go

//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)
}
}