Files
photocli/cmd/photoscli/main_test.go
T

626 lines
18 KiB
Go

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