Files
photocli/cmd/photoscli/main_test.go
T
Ein Anderssono 98320c8235
pipeline / test (push) Has been cancelled
pipeline / build (push) Has been cancelled
v0.9.3: add cleanup command
2026-06-15 02:40:12 +02:00

5415 lines
189 KiB
Go

//go:build test
package main
import (
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"sync/atomic"
"testing"
"time"
"gitea.k3s.k0.nu/tools/photocli/internal/manifest"
"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
assetsByAlbumErr map[string]error
listAssetsFn func(albumID string) ([]photos.Asset, int, error)
tree []photos.CollectionNode
treeErr error
exportPreviewFn func(string, string, int, int, int) (photos.ExportResult, error)
exportOrigFn func(string, string, int) (photos.ExportResult, error)
reverseGeocodeFn func(float64, float64) (photos.Placemark, error)
cancelled atomic.Bool
}
type errWriter struct{}
func (errWriter) Write([]byte) (int, error) { return 0, fmt.Errorf("write") }
type testFileInfo struct{ size int64 }
func (t testFileInfo) Name() string { return "test" }
func (t testFileInfo) Size() int64 { return t.size }
func (t testFileInfo) Mode() os.FileMode { return 0644 }
func (t testFileInfo) ModTime() time.Time { return time.Time{} }
func (t testFileInfo) IsDir() bool { return false }
func (t testFileInfo) Sys() any { return nil }
type noEntryManifest struct{}
func (noEntryManifest) Has(string) bool { return false }
func (noEntryManifest) Add(string, string, int64, string) {}
func (noEntryManifest) AddEntry(manifest.Entry) {}
func (noEntryManifest) Save() error { return nil }
func (noEntryManifest) Close() {}
func (noEntryManifest) OpenAppend() error { return nil }
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.listAssetsFn != nil {
return m.listAssetsFn(albumID)
}
if m.assetsErr != nil {
return nil, 0, m.assetsErr
}
if m.assetsByAlbumErr != nil {
if err, ok := m.assetsByAlbumErr[albumID]; ok {
return nil, 0, err
}
}
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), nil
}
func (m *mockBridge) ListTree() ([]photos.CollectionNode, error) { return m.tree, m.treeErr }
func (m *mockBridge) ReverseGeocode(lat, lon float64) (photos.Placemark, error) {
if m.reverseGeocodeFn != nil {
return m.reverseGeocodeFn(lat, lon)
}
return photos.Placemark{}, nil
}
func (m *mockBridge) ExportPreview(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
if m.exportPreviewFn != nil {
return m.exportPreviewFn(assetID, out, targetSize, quality, 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, quality, index, slotIndex int) (photos.ExportResult, error) {
return m.ExportPreview(assetID, out, targetSize, quality, 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.Store(true) }
func (m *mockBridge) IsCancelled() bool { return m.cancelled.Load() }
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 != exitAuth {
t.Errorf("rc = %d, want %d", rc, exitAuth)
}
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 != exitErr {
t.Errorf("rc = %d, want %d", rc, exitErr)
}
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 != exitAuth {
t.Errorf("rc = %d, want %d", rc, exitAuth)
}
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 != exitErr {
t.Errorf("rc = %d, want %d", rc, exitErr)
}
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 != exitAuth {
t.Errorf("rc = %d, want %d", rc, exitAuth)
}
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 != exitErr {
t.Errorf("rc = %d, want %d", rc, exitErr)
}
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", "--no-manifest"}, 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", "--no-manifest"}, 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", "--no-manifest"}, &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", "--no-manifest"}, 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", "--no-manifest"}, 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, int) (photos.ExportResult, error) {
return photos.ExportResult{}, fmt.Errorf("disk full")
},
}
_, stderr, rc := runWith([]string{"backup-all", "--out", "/backup", "--no-manifest"}, 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", "--no-manifest"}, &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", "--no-manifest"}, &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", "--no-manifest"}, &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", "--no-manifest"}, &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", "--no-manifest"}, 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", "--no-manifest"}, 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", "--no-manifest"}, b)
if rc != exitAuth {
t.Errorf("rc = %d, want %d", rc, exitAuth)
}
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, int) (photos.ExportResult, error) {
return photos.ExportResult{}, fmt.Errorf("disk full")
},
}
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--no-manifest"}, b)
if rc != exitErr {
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 TestCmdExportPartialFailureSimple(t *testing.T) {
assets := []photos.Asset{
{ID: "ok1", Filename: "ok.jpg"},
{ID: "fail1", Filename: "bad.jpg"},
}
b := &mockBridge{assets: assets}
var callCount int64
b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) {
n := atomic.AddInt64(&callCount, 1)
if n == 2 {
return photos.ExportResult{}, fmt.Errorf("timeout")
}
return photos.ExportResult{Filename: "ok.jpg", Size: 1024, Cloud: "local"}, nil
}
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--no-manifest"}, b)
if rc != exitPartial {
t.Errorf("rc = %d, want %d (partial failure), stderr = %q", rc, exitPartial, stderr)
}
if !strings.Contains(stderr, "1 failed") {
t.Errorf("stderr should mention partial failure, got: %q", stderr)
}
}
func TestCmdExportAllFailedExitCode(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{{ID: "a1", Filename: "img.jpg"}},
exportPreviewFn: func(string, string, int, int, int) (photos.ExportResult, error) {
return photos.ExportResult{}, fmt.Errorf("error")
},
}
_, _, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--no-manifest"}, b)
if rc != exitErr {
t.Errorf("rc = %d, want %d (all failed)", rc, exitErr)
}
}
func TestExitCodeConstants(t *testing.T) {
if exitOK != 0 {
t.Errorf("exitOK = %d, want 0", exitOK)
}
if exitErr != 1 {
t.Errorf("exitErr = %d, want 1", exitErr)
}
if exitPartial != 2 {
t.Errorf("exitPartial = %d, want 2", exitPartial)
}
if exitAuth != 3 {
t.Errorf("exitAuth = %d, want 3", exitAuth)
}
}
func TestCmdExportQualityInvalid(t *testing.T) {
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}}
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--quality", "0", "--no-manifest"}, b)
if rc != exitErr {
t.Errorf("rc = %d, want %d", rc, exitErr)
}
if !strings.Contains(stderr, "--quality") {
t.Errorf("stderr = %q", stderr)
}
}
func TestCmdExportQualityValid(t *testing.T) {
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}}
dir := t.TempDir()
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--quality", "90", "--no-manifest"}, b)
if rc != exitOK {
t.Errorf("rc = %d, stderr = %q", rc, stderr)
}
}
func TestCmdBackupAllQualityInvalid(t *testing.T) {
b := &mockBridge{tree: []photos.CollectionNode{}}
_, stderr, rc := runWith([]string{"backup-all", "--out", "/tmp", "--quality", "abc", "--no-manifest"}, b)
if rc != exitErr {
t.Errorf("rc = %d, want %d", rc, exitErr)
}
if !strings.Contains(stderr, "--quality") {
t.Errorf("stderr = %q", stderr)
}
}
func TestCmdBackupAllQualityValid(t *testing.T) {
b := &mockBridge{tree: []photos.CollectionNode{}}
dir := t.TempDir()
_, _, rc := runWith([]string{"backup-all", "--out", dir, "--quality", "70", "--no-manifest"}, b)
if rc != exitOK {
t.Errorf("rc = %d", rc)
}
}
func TestCmdExportConcurrencyInvalid(t *testing.T) {
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}}
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--concurrency", "0", "--no-manifest"}, b)
if rc != exitErr {
t.Errorf("rc = %d, want %d", rc, exitErr)
}
if !strings.Contains(stderr, "--concurrency") {
t.Errorf("stderr = %q", stderr)
}
}
func TestCmdBackupAllConcurrencyInvalid(t *testing.T) {
b := &mockBridge{tree: []photos.CollectionNode{}}
_, stderr, rc := runWith([]string{"backup-all", "--out", "/tmp", "--concurrency", "abc", "--no-manifest"}, b)
if rc != exitErr {
t.Errorf("rc = %d, want %d", rc, exitErr)
}
if !strings.Contains(stderr, "--concurrency") {
t.Errorf("stderr = %q", stderr)
}
}
func TestCmdExportConcurrencyCapped(t *testing.T) {
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}}
dir := t.TempDir()
_, _, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--concurrency", "100", "--no-manifest"}, b)
if rc != exitOK {
t.Errorf("rc = %d, want 0 (concurrency should be capped)", rc)
}
}
func TestCmdBackupAllConcurrencyCapped(t *testing.T) {
b := &mockBridge{tree: []photos.CollectionNode{}}
dir := t.TempDir()
_, _, rc := runWith([]string{"backup-all", "--out", dir, "--concurrency", "100", "--no-manifest"}, b)
if rc != exitOK {
t.Errorf("rc = %d, want 0 (concurrency should be capped)", rc)
}
}
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", "--no-manifest"}, 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", "--no-manifest"}, 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", "--no-manifest"}, 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", "--no-manifest"}, 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", "--no-manifest"}, 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, 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", "--no-manifest"}, b)
if rc != exitPartial {
t.Errorf("rc = %d, want %d (partial failure), stderr = %q", rc, exitPartial, 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", "--no-manifest"}, 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, int) (photos.ExportResult, error) {
return photos.ExportResult{}, fmt.Errorf("error")
},
}
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--no-manifest"}, 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", "--no-manifest"}, b)
if rc != exitAuth {
t.Errorf("rc = %d, want %d", rc, exitAuth)
}
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", "--no-manifest"}, 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", "--no-manifest"}, 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", "--no-manifest"}, 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, _ 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", "--no-manifest"}, b)
if rc != exitPartial {
t.Errorf("rc = %d, want %d (partial success)", rc, exitPartial)
}
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", "--no-manifest"}, 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", "--no-manifest"}, 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", "--no-manifest"}, 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)
}
}
func TestManifestLoadEmpty(t *testing.T) {
dir := t.TempDir()
mf := manifest.LoadJSONL(dir)
if mf == nil {
t.Fatal("loadManifest should not return nil")
}
if mf.Has("nonexistent") {
t.Error("empty manifest should not have any entries")
}
}
func TestManifestAddAndHas(t *testing.T) {
dir := t.TempDir()
mf := manifest.LoadJSONL(dir)
if err := mf.OpenAppend(); err != nil {
t.Fatal(err)
}
defer mf.Close()
mf.Add("id1", "photo.jpg", 1024, "local")
if !mf.Has("id1") {
t.Error("manifest should have id1 after add")
}
if mf.Has("id2") {
t.Error("manifest should not have id2")
}
}
func TestManifestSaveAndReload(t *testing.T) {
dir := t.TempDir()
mf := manifest.LoadJSONL(dir)
if err := mf.OpenAppend(); err != nil {
t.Fatal(err)
}
mf.Add("id1", "photo.jpg", 1024, "local")
mf.Add("id2", "cloud.heic", 4096, "cloud")
if err := mf.Save(); err != nil {
t.Fatal(err)
}
mf.Close()
mf2 := manifest.LoadJSONL(dir)
if !mf2.Has("id1") {
t.Error("reloaded manifest should have id1")
}
if !mf2.Has("id2") {
t.Error("reloaded manifest should have id2")
}
}
func TestManifestOpenAppendCreatesDir(t *testing.T) {
dir := t.TempDir()
subdir := filepath.Join(dir, "sub", "deep")
mf := manifest.LoadJSONL(subdir)
if err := mf.OpenAppend(); err != nil {
t.Fatal(err)
}
mf.Add("id1", "photo.jpg", 512, "local")
mf.Close()
if _, err := os.Stat(filepath.Join(subdir, "downloads.jsonl")); err != nil {
t.Errorf("manifest file should exist: %v", err)
}
}
func TestManifestCloseIdempotent(t *testing.T) {
dir := t.TempDir()
mf := manifest.LoadJSONL(dir)
if err := mf.OpenAppend(); err != nil {
t.Fatal(err)
}
mf.Add("id1", "photo.jpg", 1024, "local")
mf.Close()
mf.Close()
}
func TestManifestNilSafe(t *testing.T) {
var mf manifest.Manifest
if mf != nil && mf.Has("anything") {
t.Error("nil manifest should not have entries")
}
}
func TestCollectNodesWithManifest(t *testing.T) {
dir := t.TempDir()
mf := manifest.LoadJSONL(dir)
if err := mf.OpenAppend(); err != nil {
t.Fatal(err)
}
defer mf.Close()
mf.Add("x1", "img.jpg", 1024, "local")
b := &mockBridge{
tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}},
assetsByAlbum: map[string][]photos.Asset{
"a1": {{ID: "x1", Filename: "img.jpg"}, {ID: "x2", Filename: "img2.jpg"}},
},
}
var items []pendingAsset
var skipped int
collectNodes(b.tree, dir, b, false, false, &items, &skipped, nil, mf, nil, exportOptions{})
if skipped != 1 {
t.Errorf("expected 1 skipped, got %d", skipped)
}
if len(items) != 1 {
t.Errorf("expected 1 item, got %d", len(items))
}
if len(items) > 0 && items[0].asset.ID != "x2" {
t.Errorf("expected x2, got %s", items[0].asset.ID)
}
}
func TestBackupTreeWithManifest(t *testing.T) {
dir := t.TempDir()
mf := manifest.LoadJSONL(dir)
if err := mf.OpenAppend(); err != nil {
t.Fatal(err)
}
mf.Add("as1", "img1.jpg", 1024, "local")
mf.Close()
b := &mockBridge{
tree: []photos.CollectionNode{
{ID: "a1", Name: "Album", Kind: "album"},
{ID: "a2", Name: "Other", Kind: "album"},
},
assetsByAlbum: map[string][]photos.Asset{
"a1": {{ID: "as1", Filename: "img1.jpg"}},
"a2": {{ID: "as2", Filename: "img2.jpg"}},
},
}
var stderr bytes.Buffer
exported, failed, err := backupTree(b.tree, dir, 1024, 85, 3, false, false, &stderr, b, false, manifest.FormatJSONL, false, nil, time.Time{}, false, exportOptions{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if exported != 1 {
t.Errorf("expected 1 exported (as1 skipped), got %d", exported)
}
if failed != 0 {
t.Errorf("expected 0 failed, got %d", failed)
}
if !strings.Contains(stderr.String(), "1 skipped") {
t.Errorf("stderr should mention skipped, got: %q", stderr.String())
}
}
func TestBackupTreeNoManifest(t *testing.T) {
dir := t.TempDir()
b := &mockBridge{
tree: []photos.CollectionNode{
{ID: "a1", Name: "Album", Kind: "album"},
},
assetsByAlbum: map[string][]photos.Asset{
"a1": {{ID: "x1", Filename: "img.jpg"}},
},
}
var stderr bytes.Buffer
exported, failed, err := backupTree(b.tree, dir, 1024, 85, 3, false, false, &stderr, b, true, manifest.FormatJSONL, false, nil, time.Time{}, false, exportOptions{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if exported != 1 {
t.Errorf("expected 1 exported, got %d", exported)
}
if failed != 0 {
t.Errorf("expected 0 failed, got %d", failed)
}
if strings.Contains(stderr.String(), "manifest") {
t.Errorf("should not mention manifest with --no-manifest, got: %q", stderr.String())
}
}
func TestCmdBackupAllWithManifest(t *testing.T) {
dir := t.TempDir()
b := &mockBridge{
tree: []photos.CollectionNode{
{ID: "a1", Name: "Album", Kind: "album"},
},
assetsByAlbum: map[string][]photos.Asset{
"a1": {{ID: "x1", Filename: "img.jpg"}},
},
}
_, stderr, rc := runWith([]string{"backup-all", "--out", dir}, b)
if rc != 0 {
t.Fatalf("rc = %d, stderr = %q", rc, stderr)
}
manifestPath := filepath.Join(dir, "downloads.jsonl")
data, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatalf("manifest file should exist: %v", err)
}
if len(data) == 0 {
t.Error("manifest file should not be empty")
}
}
func TestCmdBackupAllNoManifest(t *testing.T) {
dir := t.TempDir()
b := &mockBridge{
tree: []photos.CollectionNode{
{ID: "a1", Name: "Album", Kind: "album"},
},
assetsByAlbum: map[string][]photos.Asset{
"a1": {{ID: "x1", Filename: "img.jpg"}},
},
}
_, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--no-manifest"}, b)
if rc != 0 {
t.Fatalf("rc = %d, stderr = %q", rc, stderr)
}
manifestPath := filepath.Join(dir, "downloads.jsonl")
if _, err := os.Stat(manifestPath); !os.IsNotExist(err) {
t.Error("manifest file should not exist with --no-manifest")
}
}
func TestCmdExportWithManifest(t *testing.T) {
dir := t.TempDir()
b := &mockBridge{
assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}},
}
_, _, rc := runWith([]string{"export", "--album-id", "x", "--out", dir}, b)
if rc != 0 {
t.Fatalf("rc = 0 expected, got %d", rc)
}
manifestPath := filepath.Join(dir, "downloads.jsonl")
if _, err := os.Stat(manifestPath); err != nil {
t.Fatalf("manifest file should exist: %v", err)
}
}
func TestCmdExportNoManifest(t *testing.T) {
dir := t.TempDir()
b := &mockBridge{
assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}},
}
_, _, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--no-manifest"}, b)
if rc != 0 {
t.Fatalf("rc = 0 expected, got %d", rc)
}
manifestPath := filepath.Join(dir, "downloads.jsonl")
if _, err := os.Stat(manifestPath); !os.IsNotExist(err) {
t.Error("manifest file should not exist with --no-manifest")
}
}
func TestManifestOpenAppendIdempotent(t *testing.T) {
dir := t.TempDir()
mf := manifest.LoadJSONL(dir)
if err := mf.OpenAppend(); err != nil {
t.Fatal(err)
}
if err := mf.OpenAppend(); err != nil {
t.Error("second openAppend should be idempotent")
}
mf.Close()
}
func TestManifestOpenAppendMkdirError(t *testing.T) {
mf := manifest.LoadJSONL("/proc/cannot-create-dir-here")
if err := mf.OpenAppend(); err == nil {
t.Error("expected error from openAppend on read-only path")
}
}
func TestManifestSaveWithNoFile(t *testing.T) {
dir := t.TempDir()
mf := manifest.LoadJSONL(dir)
if err := mf.Save(); err != nil {
t.Errorf("save with no file open should return nil, got %v", err)
}
}
func TestManifestLoadFromExistingFile(t *testing.T) {
dir := t.TempDir()
mf := manifest.LoadJSONL(dir)
if err := mf.OpenAppend(); err != nil {
t.Fatal(err)
}
mf.Add("id1", "photo.jpg", 1024, "local")
mf.Add("id2", "cloud.heic", 4096, "cloud")
mf.Close()
loaded := manifest.LoadJSONL(dir)
if !loaded.Has("id1") {
t.Error("loaded manifest should have id1")
}
if !loaded.Has("id2") {
t.Error("loaded manifest should have id2")
}
}
func TestCollectNodesCancelled(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}},
assetsByAlbum: map[string][]photos.Asset{
"a1": {{ID: "x1", Filename: "img.jpg"}},
},
}
b.Cancel()
var items []pendingAsset
var skipped int
collectNodes(b.tree, "/out", b, false, false, &items, &skipped, nil, nil, nil, exportOptions{})
if len(items) != 0 {
t.Errorf("cancelled collectNodes should return 0 items, got %d", len(items))
}
}
func TestCollectNodesAlbumWithEmptyID(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{{Name: "Empty", Kind: "album", ID: ""}},
}
var items []pendingAsset
var skipped int
collectNodes(b.tree, "/out", b, false, false, &items, &skipped, nil, nil, nil, exportOptions{})
if len(items) != 0 {
t.Errorf("album with empty ID should be skipped, got %d items", len(items))
}
}
func TestCollectNodesListAssetsError(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}},
assetsErr: fmt.Errorf("fetch error"),
}
var progressCalls []collectProgress
var items []pendingAsset
var skipped int
collectNodes(b.tree, "/out", b, false, false, &items, &skipped, func(p collectProgress) {
progressCalls = append(progressCalls, p)
}, nil, nil, exportOptions{})
if len(progressCalls) != 1 || progressCalls[0].err == nil {
t.Errorf("expected error progress call, got %v", progressCalls)
}
}
func TestCollectNodesNilProgress(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}},
assetsByAlbum: map[string][]photos.Asset{
"a1": {{ID: "x1", Filename: "img.jpg"}},
},
}
var items []pendingAsset
var skipped int
collectNodes(b.tree, "/out", b, false, false, &items, &skipped, nil, nil, nil, exportOptions{})
if len(items) != 1 {
t.Errorf("expected 1 item, got %d", len(items))
}
}
func TestCmdBackupAllOriginalsWithFailures(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{
{ID: "a1", Name: "Album", Kind: "album"},
},
assetsByAlbum: map[string][]photos.Asset{
"a1": {
{ID: "ok1", Filename: "good.jpg"},
{ID: "bad1", Filename: "bad.jpg"},
},
},
exportOrigFn: func(id string, _ string, _ int) (photos.ExportResult, error) {
if id == "bad1" {
return photos.ExportResult{}, fmt.Errorf("write error")
}
return photos.ExportResult{Filename: "good.jpg", Size: 1024, Cloud: "local"}, nil
},
}
dir := t.TempDir()
_, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--originals"}, b)
if rc != exitPartial {
t.Errorf("rc = %d, want %d, stderr = %q", rc, exitPartial, stderr)
}
if !strings.Contains(stderr, "1 failed") {
t.Errorf("should report failed count, got: %q", stderr)
}
}
func TestCmdExportOriginalsWithFailures(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{
{ID: "ok1", Filename: "good.jpg"},
{ID: "bad1", Filename: "bad.jpg"},
},
exportOrigFn: func(id string, _ string, _ int) (photos.ExportResult, error) {
if id == "bad1" {
return photos.ExportResult{}, fmt.Errorf("export error")
}
return photos.ExportResult{Filename: "good.heic", Size: 8192, Cloud: "local"}, nil
},
}
dir := t.TempDir()
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--originals"}, b)
if rc != exitPartial {
t.Errorf("rc = %d, want %d, stderr = %q", rc, exitPartial, stderr)
}
if !strings.Contains(stderr, "1 failed") {
t.Errorf("should report failed count, got: %q", stderr)
}
}
func TestCmdExportAllFailuresOriginals(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{{ID: "bad1", Filename: "bad.jpg"}},
exportOrigFn: func(string, string, int) (photos.ExportResult, error) {
return photos.ExportResult{}, fmt.Errorf("export error")
},
}
dir := t.TempDir()
_, _, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--originals"}, b)
if rc != 1 {
t.Errorf("expected rc=1 when all exports fail, got %d", rc)
}
}
func TestExportAssetsManifestError(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}},
}
var stderr bytes.Buffer
exported, _ := exportAssets(b.assets, "/proc/cannot-create", 1024, 85, 3, false, 1, &stderr, b, "", false, manifest.FormatJSONL, false, exportOptions{})
if exported != 1 {
t.Errorf("expected 1 exported, got %d", exported)
}
if !strings.Contains(stderr.String(), "could not open manifest") {
t.Errorf("expected manifest warning, got: %q", stderr.String())
}
}
func TestBackupTreeManifestOpenError(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}},
assetsByAlbum: map[string][]photos.Asset{
"a1": {{ID: "x1", Filename: "img.jpg"}},
},
}
var stderr bytes.Buffer
_, _, err := backupTree(b.tree, "/proc/cannot-create", 1024, 85, 3, false, false, &stderr, b, false, manifest.FormatJSONL, false, nil, time.Time{}, false, exportOptions{})
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if !strings.Contains(stderr.String(), "could not open manifest") {
t.Errorf("expected manifest warning, got: %q", stderr.String())
}
}
func TestBackupTreeCancelledAfterCollect(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}},
assetsByAlbum: map[string][]photos.Asset{
"a1": {{ID: "x1", Filename: "img.jpg"}},
},
}
b.Cancel()
var stderr bytes.Buffer
_, _, err := backupTree(b.tree, "/tmp", 1024, 85, 3, false, false, &stderr, b, true, manifest.FormatJSONL, false, nil, time.Time{}, false, exportOptions{})
if err == nil {
t.Error("cancelled backupTree should return error")
}
}
func TestExportPendingSerialSkipped(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}},
}
b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) {
return photos.ExportResult{Filename: "img.jpg", Size: 0, Skipped: true, Cloud: "local"}, nil
}
pending := []pendingAsset{{asset: b.assets[0], path: "/tmp", album: "test"}}
var buf bytes.Buffer
bar := newProgressBar(&buf, 1)
done, failed := exportPendingSerial(pending, 1024, 85, false, 1, bar, b, nil, manifest.NoopLogWriter, exportOptions{})
if done != 0 {
t.Errorf("expected 0 done for skipped, got %d", done)
}
if failed != 0 {
t.Errorf("expected 0 failed for skipped, got %d", failed)
}
}
func TestExportPendingSerialCloudDownload(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{{ID: "c1", Filename: "cloud.jpg", Cloud: "cloud"}},
}
b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) {
return photos.ExportResult{Filename: "cloud.jpg", Size: 2048, Cloud: "cloud"}, nil
}
pending := []pendingAsset{{asset: b.assets[0], path: "/tmp", album: "test"}}
var buf bytes.Buffer
bar := newProgressBar(&buf, 1)
done, _ := exportPendingSerial(pending, 1024, 85, false, 1, bar, b, nil, manifest.NoopLogWriter, exportOptions{})
if done != 1 {
t.Errorf("expected 1 done, got %d", done)
}
}
func TestExportPendingSerialWithManifest(t *testing.T) {
dir := t.TempDir()
mf := manifest.LoadJSONL(dir)
if err := mf.OpenAppend(); err != nil {
t.Fatal(err)
}
b := &mockBridge{
assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}},
}
b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) {
return photos.ExportResult{Filename: "img.jpg", Size: 1024, Cloud: "local"}, nil
}
pending := []pendingAsset{{asset: b.assets[0], path: dir, album: "test"}}
var buf bytes.Buffer
bar := newProgressBar(&buf, 1)
done, _ := exportPendingSerial(pending, 1024, 85, false, 1, bar, b, mf, manifest.NoopLogWriter, exportOptions{})
if done != 1 {
t.Errorf("expected 1 done, got %d", done)
}
mf.Close()
if !mf.Has("x1") {
t.Error("manifest should have x1 after serial export")
}
}
func TestExportPendingSerialSkippedWritesToManifest(t *testing.T) {
dir := t.TempDir()
mf := manifest.LoadJSONL(dir)
if err := mf.OpenAppend(); err != nil {
t.Fatal(err)
}
b := &mockBridge{
assets: []photos.Asset{{ID: "s1", Filename: "skip.jpg"}},
}
b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) {
return photos.ExportResult{Filename: "skip.jpg", Size: 512, Skipped: true, Cloud: "local"}, nil
}
pending := []pendingAsset{{asset: b.assets[0], path: dir, album: "test"}}
var buf bytes.Buffer
bar := newProgressBar(&buf, 1)
done, failed := exportPendingSerial(pending, 1024, 85, false, 1, bar, b, mf, manifest.NoopLogWriter, exportOptions{})
if done != 0 {
t.Errorf("expected 0 done for skipped, got %d", done)
}
if failed != 0 {
t.Errorf("expected 0 failed, got %d", failed)
}
mf.Close()
if !mf.Has("s1") {
t.Error("manifest should have s1 after skipped serial export")
}
}
func TestPhotosNilCreationDateWithDuration(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{
{ID: "1", Filename: "video.mov", Duration: 30.5},
},
}
var buf bytes.Buffer
rc := cmdPhotos([]string{"--album-id", "x"}, &buf, &buf, b)
if rc != 0 {
t.Errorf("rc = %d", rc)
}
}
func TestPhotosNilCreationDateWithFavorite(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{
{ID: "1", Filename: "photo.jpg", IsFavorite: true},
},
}
var buf bytes.Buffer
rc := cmdPhotos([]string{"--album-id", "x"}, &buf, &buf, b)
if rc != 0 {
t.Errorf("rc = %d", rc)
}
if !strings.Contains(buf.String(), "*") {
t.Errorf("expected favorite marker *, got: %q", buf.String())
}
}
func TestFormatDuration(t *testing.T) {
tests := []struct {
d time.Duration
want string
}{
{5 * time.Second, "5s"},
{90 * time.Second, "1m30s"},
{3661 * time.Second, "61m01s"},
}
for _, tt := range tests {
got := formatDuration(tt.d)
if got != tt.want {
t.Errorf("formatDuration(%v) = %q, want %q", tt.d, got, tt.want)
}
}
}
func TestRuneWidth(t *testing.T) {
tests := []struct {
s string
want int
}{
{"abc", 3},
{"hello world", 11},
{"\u4e16\u754c", 4},
{"\u1100", 2},
{"\u2329", 2},
{"\u232a", 2},
{"\uac00", 2},
{"\uf900", 2},
{"\ufe30", 2},
{"\uff01", 2},
{"\uffe0", 2},
{"", 0},
}
for _, tt := range tests {
got := runeWidth(tt.s)
if got != tt.want {
t.Errorf("runeWidth(%q) = %d, want %d", tt.s, got, tt.want)
}
}
}
func TestTruncateOrPad(t *testing.T) {
tests := []struct {
s string
width int
want string
}{
{"hi", 5, "hi "},
{"hello world", 20, "hello world "},
{"a very long string that exceeds the width", 10, "a very ..."},
{"short", 6, "short "},
}
for _, tt := range tests {
got := truncateOrPad(tt.s, tt.width)
if got != tt.want {
t.Errorf("truncateOrPad(%q, %d) = %q, want %q", tt.s, tt.width, got, tt.want)
}
}
}
func TestTruncateOrPadWideChars(t *testing.T) {
got := truncateOrPad("\u4e16\u754c", 10)
if !strings.HasSuffix(got, " ") {
t.Errorf("expected trailing spaces for wide chars, got %q", got)
}
got2 := truncateOrPad("\u4e16\u754c\u4e16\u754c\u4e16\u754c", 6)
if !strings.Contains(got2, "...") {
t.Errorf("expected truncation for wide chars, got %q", got2)
}
}
func TestRenderWorkerLineStatuses(t *testing.T) {
tests := []struct {
name string
ws workerSlot
want string
}{
{"fail status", workerSlot{status: "FAIL", filename: "bad.jpg"}, "\u274c bad.jpg"},
{"skipped status", workerSlot{status: "skipped", filename: "skip.jpg"}, "\u23ed skip.jpg"},
{"skipped with size", workerSlot{status: "skipped", filename: "skip.jpg", size: 1024}, "\u23ed skip.jpg 1.0 KB"},
{"local completed", workerSlot{status: "", filename: "local.jpg", cloud: "local", size: 1024}, "\u2705 local.jpg 1.0 KB copied"},
{"local no size", workerSlot{status: "", filename: "local.jpg", cloud: "local", size: 0}, "\u2705 local.jpg copied"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := renderWorkerLine(tt.ws, 80)
if !strings.Contains(got, tt.want) {
t.Errorf("renderWorkerLine(%+v) = %q, want containing %q", tt.ws, got, tt.want)
}
})
}
}
func TestRenderWorkerLineCloudCompleted(t *testing.T) {
ws := workerSlot{status: "", filename: "cloud.jpg", cloud: "cloud", size: 2048, speed: 512 * 1024}
got := renderWorkerLine(ws, 80)
if !strings.Contains(got, "\u2601 cloud.jpg 2.0 KB downloaded 512.0 KB/s") {
t.Errorf("expected cloud completed line, got %q", got)
}
}
func TestRenderWorkerLineCloudProgress(t *testing.T) {
ws := workerSlot{status: "", filename: "cloud.jpg", cloud: "cloud", progress: 0.5, bytesTotal: 4096, bytesDone: 2048, speed: 1024 * 1024}
got := renderWorkerLine(ws, 80)
if !strings.Contains(got, "\u2601 cloud.jpg") {
t.Errorf("expected cloud marker, got %q", got)
}
if !strings.Contains(got, "50%") {
t.Errorf("expected 50%% progress, got %q", got)
}
}
func TestUpdateWorkerProgress(t *testing.T) {
var buf bytes.Buffer
p := newProgressBar(&buf, 2)
p.updateWorkerProgress(0, 0.5, 1024, 4096)
p.mu.Lock()
if p.workerState[0].progress != 0.5 {
t.Errorf("expected progress 0.5, got %f", p.workerState[0].progress)
}
if p.workerState[0].bytesDone != 1024 {
t.Errorf("expected bytesDone 1024, got %d", p.workerState[0].bytesDone)
}
p.mu.Unlock()
}
func TestUpdateWorkerProgressNegativeIndex(t *testing.T) {
var buf bytes.Buffer
p := newProgressBar(&buf, 1)
p.updateWorkerProgress(-1, 0.5, 1024, 4096)
p.mu.Lock()
if p.workerState[0].progress != 0 {
t.Error("negative index should be ignored")
}
p.mu.Unlock()
}
func TestEnsureScrollRegionSmallTerminal(t *testing.T) {
var buf bytes.Buffer
p := newProgressBar(&buf, 10)
p.width = 40
p.termH = 5
p.ensureScrollRegion()
if !p.scrollSet {
t.Error("scrollSet should be true after ensureScrollRegion")
}
}
func TestRenderBarPctAbove50(t *testing.T) {
bar := renderBar(75, 20)
if !strings.Contains(bar, "\x1b[") {
t.Error("expected ANSI color codes in bar")
}
}
func TestRenderBarFull(t *testing.T) {
bar := renderBar(100, 20)
if !strings.Contains(bar, "\u2588") {
t.Error("expected full blocks in 100% bar")
}
}
func TestRenderBarZero(t *testing.T) {
bar := renderBar(0, 20)
if !strings.Contains(bar, "\u2591") {
t.Error("expected empty blocks in 0% bar")
}
}
func TestRenderBarNegativeWidth(t *testing.T) {
bar := renderBar(50, 0)
if bar != "" {
t.Errorf("expected empty bar for zero width, got %q", bar)
}
}
func TestRenderLineWithETA(t *testing.T) {
b := barLine{current: 50, total: 100, label: "Total", detail: ""}
got := renderLine(b, 2*time.Second, 80)
if !strings.Contains(got, "2s") {
t.Errorf("expected duration in line, got %q", got)
}
}
func TestRenderLineNoCounter(t *testing.T) {
b := barLine{current: 0, total: 0, label: "Total", detail: "Venice"}
got := renderLine(b, 0, 80)
if !strings.Contains(got, "Venice") {
t.Errorf("expected detail in line, got %q", got)
}
}
func TestRenderLineAlbumLabel(t *testing.T) {
b := barLine{current: 3, total: 5, label: "Album", detail: "Venice"}
got := renderLine(b, 0, 80)
if !strings.Contains(got, "Venice 3/5") {
t.Errorf("expected Album format with detail and counter, got %q", got)
}
}
func TestCmdBackupAllSortNewest(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}},
assetsByAlbum: map[string][]photos.Asset{
"a1": {{ID: "x1", Filename: "img.jpg"}, {ID: "x2", Filename: "img2.jpg"}},
},
}
dir := t.TempDir()
_, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--sort", "newest", "--no-manifest"}, b)
if rc != 0 {
t.Fatalf("rc = %d, stderr = %q", rc, stderr)
}
if !strings.Contains(stderr, "exported") {
t.Errorf("expected export output, got: %q", stderr)
}
}
func TestCmdBackupAllSortInvalid(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}},
assetsByAlbum: map[string][]photos.Asset{
"a1": {{ID: "x1", Filename: "img.jpg"}},
},
}
_, stderr, rc := runWith([]string{"backup-all", "--out", "/tmp", "--sort", "invalid", "--no-manifest"}, b)
if rc != 1 {
t.Errorf("expected rc=1 for invalid --sort, got %d", rc)
}
if !strings.Contains(stderr, "--sort must be newest or oldest") {
t.Errorf("expected sort error, got: %q", stderr)
}
}
func TestCmdExportSortNewest(t *testing.T) {
dateNew := "2024-06-01"
dateOld := "2024-01-01"
b := &mockBridge{
assets: []photos.Asset{
{ID: "x1", Filename: "old.jpg", CreationDate: &dateOld},
{ID: "x2", Filename: "new.jpg", CreationDate: &dateNew},
{ID: "x3", Filename: "nil.jpg"},
},
}
dir := t.TempDir()
_, _, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--sort", "newest", "--no-manifest"}, b)
if rc != 0 {
t.Fatalf("rc = %d", rc)
}
}
func TestCmdExportSortInvalid(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}},
}
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--sort", "bad"}, b)
if rc != 1 {
t.Errorf("expected rc=1 for invalid --sort, got %d", rc)
}
if !strings.Contains(stderr, "--sort must be newest or oldest") {
t.Errorf("expected sort error, got: %q", stderr)
}
}
func TestCollectPendingAssetsSortNewest(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}},
assetsByAlbum: map[string][]photos.Asset{
"a1": {
{ID: "old", Filename: "old.jpg", CreationDate: strPtr("2020-01-01T00:00:00Z")},
{ID: "new", Filename: "new.jpg", CreationDate: strPtr("2024-06-01T00:00:00Z")},
},
},
}
var progressCalls []collectProgress
items, _ := collectPendingAssets(b.tree, "/out", b, false, false, func(p collectProgress) {
progressCalls = append(progressCalls, p)
}, nil, true, nil, time.Time{}, exportOptions{})
if len(items) != 2 {
t.Fatalf("expected 2 items, got %d", len(items))
}
if items[0].asset.ID != "new" {
t.Errorf("expected newest first, got %s then %s", items[0].asset.ID, items[1].asset.ID)
}
}
func TestCollectPendingAssetsSortOldest(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}},
assetsByAlbum: map[string][]photos.Asset{
"a1": {
{ID: "new", Filename: "new.jpg", CreationDate: strPtr("2024-06-01T00:00:00Z")},
{ID: "old", Filename: "old.jpg", CreationDate: strPtr("2020-01-01T00:00:00Z")},
},
},
}
items, _ := collectPendingAssets(b.tree, "/out", b, false, false, nil, nil, false, nil, time.Time{}, exportOptions{})
if len(items) != 2 {
t.Fatalf("expected 2 items, got %d", len(items))
}
if items[0].asset.ID != "new" {
t.Errorf("oldest sort should preserve order, got %s first", items[0].asset.ID)
}
}
func strPtr(s string) *string {
return &s
}
func TestCmdExportManifestSQLite(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}},
}
dir := t.TempDir()
_, _, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--manifest", "sqlite"}, b)
if rc != 0 {
t.Fatalf("rc = %d", rc)
}
if _, err := os.Stat(filepath.Join(dir, "downloads.db")); err != nil {
t.Errorf("sqlite manifest should exist: %v", err)
}
}
func TestCmdBackupAllManifestSQLite(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}},
assetsByAlbum: map[string][]photos.Asset{
"a1": {{ID: "x1", Filename: "img.jpg"}},
},
}
dir := t.TempDir()
_, _, rc := runWith([]string{"backup-all", "--out", dir, "--manifest", "sqlite"}, b)
if rc != 0 {
t.Fatalf("rc = %d", rc)
}
if _, err := os.Stat(filepath.Join(dir, "downloads.db")); err != nil {
t.Errorf("sqlite manifest should exist: %v", err)
}
}
func TestCmdExportInvalidManifestFormat(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}},
}
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--manifest", "csv", "--no-manifest"}, b)
if rc != 1 {
t.Errorf("expected rc=1 for invalid manifest format, got %d", rc)
}
if !strings.Contains(stderr, "unknown manifest format") {
t.Errorf("expected format error, got: %q", stderr)
}
}
func TestCmdBackupAllInvalidManifestFormat(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{},
}
_, stderr, rc := runWith([]string{"backup-all", "--out", "/tmp", "--manifest", "csv"}, b)
if rc != 1 {
t.Errorf("expected rc=1 for invalid manifest format, got %d", rc)
}
if !strings.Contains(stderr, "unknown manifest format") {
t.Errorf("expected format error, got: %q", stderr)
}
}
func TestCmdExportResolveAlbumIDError(t *testing.T) {
b := &mockBridge{
albums: []photos.Album{{ID: "a1", Title: "Album"}},
assetsByAlbum: map[string][]photos.Asset{},
}
_, stderr, rc := runWith([]string{"export", "--album-id", "Nonexistent", "--out", "/tmp", "--no-manifest"}, b)
if rc != 1 {
t.Errorf("expected rc=1, got %d", rc)
}
if !strings.Contains(stderr, "error") {
t.Errorf("expected error in stderr, got: %q", stderr)
}
}
func TestCmdExportListAssetsAfterResolveErr(t *testing.T) {
b := &mockBridge{
albums: []photos.Album{{ID: "x", Title: "Album"}},
assetsByAlbumErr: map[string]error{"x": fmt.Errorf("fetch failed")},
}
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--no-manifest"}, b)
if rc != 1 {
t.Errorf("expected rc=1, got %d", rc)
}
if !strings.Contains(stderr, "error") {
t.Errorf("expected error, got: %q", stderr)
}
}
func TestSortNewestNilCreationDate(t *testing.T) {
assets := []photos.Asset{
{ID: "a", Filename: "a.jpg"},
{ID: "b", Filename: "b.jpg"},
}
sort.Slice(assets, func(i, j int) bool {
di := assets[i].CreationDate
dj := assets[j].CreationDate
if di == nil && dj == nil {
return assets[i].ID < assets[j].ID
}
if di == nil {
return false
}
if dj == nil {
return true
}
return *di > *dj
})
if assets[0].ID != "a" {
t.Errorf("both nil: expected sorted by ID, got %s first", assets[0].ID)
}
}
func TestSortNewestMixedCreationDate(t *testing.T) {
assets := []photos.Asset{
{ID: "a", Filename: "a.jpg"},
{ID: "b", Filename: "b.jpg", CreationDate: strPtr("2024-01-01T00:00:00Z")},
}
sort.Slice(assets, func(i, j int) bool {
di := assets[i].CreationDate
dj := assets[j].CreationDate
if di == nil && dj == nil {
return assets[i].ID < assets[j].ID
}
if di == nil {
return false
}
if dj == nil {
return true
}
return *di > *dj
})
if assets[0].ID != "b" {
t.Errorf("newest first: expected b (has date) before a (nil), got %s", assets[0].ID)
}
}
func TestSortNewestSecondNil(t *testing.T) {
assets := []photos.Asset{
{ID: "a", Filename: "a.jpg", CreationDate: strPtr("2024-01-01T00:00:00Z")},
{ID: "b", Filename: "b.jpg"},
}
sort.Slice(assets, func(i, j int) bool {
di := assets[i].CreationDate
dj := assets[j].CreationDate
if di == nil && dj == nil {
return assets[i].ID < assets[j].ID
}
if di == nil {
return false
}
if dj == nil {
return true
}
return *di > *dj
})
if assets[0].ID != "a" {
t.Errorf("newest first: expected a (has date) before b (nil), got %s", assets[0].ID)
}
}
func TestCollectNodesNilProgressListAssetsError(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}},
assetsErr: fmt.Errorf("fetch error"),
}
var items []pendingAsset
var skipped int
collectNodes(b.tree, "/out", b, false, false, &items, &skipped, nil, nil, nil, exportOptions{})
if len(items) != 0 {
t.Errorf("expected 0 items on error, got %d", len(items))
}
}
func TestBackupTreeManifestOpenAppendError(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}},
assetsByAlbum: map[string][]photos.Asset{
"a1": {{ID: "x1", Filename: "img.jpg"}},
},
}
var stderr bytes.Buffer
_, _, err := backupTree(b.tree, "/tmp/test-backup-manifest-err", 1024, 85, 3, false, false, &stderr, b, false, manifest.FormatJSONL, false, nil, time.Time{}, false, exportOptions{})
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
func TestExportAssetsSaveError(t *testing.T) {
dir := t.TempDir()
b := &mockBridge{
assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}},
}
var stderr bytes.Buffer
exported, failed := exportAssets(b.assets, dir, 1024, 85, 3, false, 1, &stderr, b, "", false, manifest.FormatJSONL, false, exportOptions{})
if exported != 1 {
t.Errorf("expected 1 exported, got %d", exported)
}
if failed != 0 {
t.Errorf("expected 0 failed, got %d", failed)
}
}
func TestExportPendingSerialCancelled(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}, {ID: "x2", Filename: "img2.jpg"}},
}
b.Cancel()
pending := []pendingAsset{{asset: b.assets[0], path: "/tmp", album: "test"}}
var buf bytes.Buffer
bar := newProgressBar(&buf, 1)
done, _ := exportPendingSerial(pending, 1024, 85, false, 1, bar, b, nil, manifest.NoopLogWriter, exportOptions{})
if done != 0 {
t.Errorf("expected 0 done when cancelled, got %d", done)
}
}
func TestExportPendingSerialSkippedNoProgress(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}},
}
b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) {
return photos.ExportResult{Filename: "img.jpg", Size: 0, Skipped: true, Cloud: "local"}, nil
}
pending := []pendingAsset{{asset: b.assets[0], path: "/tmp", album: "test"}}
var buf bytes.Buffer
bar := newProgressBar(&buf, 1)
done, failed := exportPendingSerial(pending, 1024, 85, false, 1, bar, b, nil, manifest.NoopLogWriter, exportOptions{})
if done != 0 {
t.Errorf("expected 0 done for skipped, got %d", done)
}
if failed != 0 {
t.Errorf("expected 0 failed for skipped, got %d", failed)
}
}
func TestCmdExportNoManifestFlag(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}},
}
dir := t.TempDir()
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--no-manifest"}, b)
if rc != 0 {
t.Fatalf("rc = %d, stderr = %q", rc, stderr)
}
if strings.Contains(stderr, "manifest") {
t.Errorf("should not mention manifest with --no-manifest, got: %q", stderr)
}
}
func TestRunMainNormal(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}},
}
dir := t.TempDir()
rc := runMain([]string{"export", "--album-id", "x", "--out", dir, "--no-manifest"}, os.Stdout, os.Stderr, b)
if rc != 0 {
t.Errorf("expected rc 0, got %d", rc)
}
}
func TestRunMainCancelled(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}},
}
b.Cancel()
dir := t.TempDir()
rc := runMain([]string{"export", "--album-id", "x", "--out", dir, "--no-manifest"}, os.Stdout, os.Stderr, b)
if rc != 0 {
t.Errorf("expected rc 0 for cancelled (still returns 0), got %d", rc)
}
}
func TestRunMainNoArgs(t *testing.T) {
rc := runMain(nil, os.Stdout, os.Stderr, &mockBridge{})
if rc != 1 {
t.Errorf("expected rc 1 for no args, got %d", rc)
}
}
func TestRunMainSignal(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}},
}
b.Cancel()
sigCh := make(chan struct{})
close(sigCh)
rc := runMainWithSignal([]string{"export", "--album-id", "x", "--out", t.TempDir(), "--no-manifest"}, os.Stdout, os.Stderr, b, sigCh)
if rc != 0 {
t.Errorf("expected rc 0, got %d", rc)
}
}
func TestCmdPhotosListAssetsError(t *testing.T) {
b := &mockBridge{
albums: []photos.Album{{ID: "x", Title: "Album"}},
assetsByAlbumErr: map[string]error{"x": fmt.Errorf("fetch failed")},
}
var buf bytes.Buffer
rc := cmdPhotos([]string{"--album-id", "x"}, &buf, &buf, b)
if rc != 1 {
t.Errorf("expected rc 1, got %d", rc)
}
}
func TestCmdExportListAssetsAfterResolveError(t *testing.T) {
b := &mockBridge{
albums: []photos.Album{{ID: "x", Title: "Album"}},
assetsByAlbumErr: map[string]error{"x": fmt.Errorf("fetch failed")},
}
var buf bytes.Buffer
rc := cmdExport([]string{"--album-id", "x", "--out", "/tmp", "--no-manifest"}, &buf, &buf, b)
if rc != 1 {
t.Errorf("expected rc 1, got %d", rc)
}
}
func TestCmdExportOriginalsFlag(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{{ID: "x1", Filename: "img.heic"}},
}
dir := t.TempDir()
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--originals", "--no-manifest"}, b)
if rc != 0 {
t.Fatalf("rc = %d, stderr = %q", rc, stderr)
}
if !strings.Contains(stderr, "originals") {
t.Errorf("expected originals in output, got: %s", stderr)
}
}
func TestCmdExportBadSize(t *testing.T) {
b := &mockBridge{}
var buf bytes.Buffer
rc := cmdExport([]string{"--album-id", "x", "--out", "/tmp", "--size", "abc"}, &buf, &buf, b)
if rc != 1 {
t.Errorf("expected rc 1 for invalid size, got %d", rc)
}
}
func TestCmdExportZeroSize(t *testing.T) {
b := &mockBridge{}
var buf bytes.Buffer
rc := cmdExport([]string{"--album-id", "x", "--out", "/tmp", "--size", "0"}, &buf, &buf, b)
if rc != 1 {
t.Errorf("expected rc 1 for zero size, got %d", rc)
}
}
func TestCmdExportBadSort(t *testing.T) {
b := &mockBridge{}
var buf bytes.Buffer
rc := cmdExport([]string{"--album-id", "x", "--out", "/tmp", "--sort", "invalid"}, &buf, &buf, b)
if rc != 1 {
t.Errorf("expected rc 1 for invalid sort, got %d", rc)
}
}
func TestCmdExportAllExportsFail(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}},
}
b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) {
return photos.ExportResult{}, fmt.Errorf("export error")
}
dir := t.TempDir()
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--no-manifest"}, b)
if rc != 1 {
t.Errorf("expected rc 1 when all exports fail, got %d", rc)
}
if !strings.Contains(stderr, "all exports failed") {
t.Errorf("expected 'all exports failed' in stderr, got: %s", stderr)
}
}
func TestCmdBackupAllCancelledTree(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{
{Name: "Album1", Kind: "album", ID: "a1", Children: []photos.CollectionNode{
{Kind: "asset", ID: "x1", Name: "photo.jpg"},
}},
},
}
b.Cancel()
var stderr bytes.Buffer
_, _, err := backupTree(b.tree, "/tmp/test-backup-cancelled", 1024, 85, 3, false, false, &stderr, b, true, manifest.FormatJSONL, false, nil, time.Time{}, false, exportOptions{})
if err == nil {
t.Error("cancelled backupTree should return error")
}
}
func TestCmdBackupAllOriginalsFlag(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{
{Name: "Album1", Kind: "album", ID: "a1"},
},
}
dir := t.TempDir()
_, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--originals", "--no-manifest"}, b)
if rc != 0 {
t.Errorf("rc = %d, stderr = %q", rc, stderr)
}
if !strings.Contains(stderr, "original files") {
t.Errorf("expected 'original files' in output, got: %s", stderr)
}
}
func TestCmdBackupAllBadSort(t *testing.T) {
b := &mockBridge{tree: []photos.CollectionNode{}}
var buf bytes.Buffer
rc := cmdBackupAll([]string{"--out", "/tmp", "--sort", "invalid", "--no-manifest"}, &buf, &buf, b)
if rc != 1 {
t.Errorf("expected rc 1 for invalid sort, got %d", rc)
}
}
func TestCmdBackupAllBadSize(t *testing.T) {
b := &mockBridge{tree: []photos.CollectionNode{}}
var buf bytes.Buffer
rc := cmdBackupAll([]string{"--out", "/tmp", "--size", "abc"}, &buf, &buf, b)
if rc != 1 {
t.Errorf("expected rc 1 for invalid size, got %d", rc)
}
}
func TestCmdBackupAllTreeErr(t *testing.T) {
b := &mockBridge{treeErr: fmt.Errorf("tree error")}
var buf bytes.Buffer
rc := cmdBackupAll([]string{"--out", "/tmp", "--no-manifest"}, &buf, &buf, b)
if rc != 1 {
t.Errorf("expected rc 1, got %d", rc)
}
}
func TestCollectPendingAssetsCancelledDuringCollect(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{
{Name: "Root", Kind: "folder", Children: []photos.CollectionNode{
{Name: "Album1", Kind: "album", ID: "a1"},
}},
},
}
b.Cancel()
items, _ := collectPendingAssets(b.tree, "/tmp", b, false, false, nil, nil, false, nil, time.Time{}, exportOptions{})
if len(items) != 0 {
t.Errorf("expected 0 items when cancelled, got %d", len(items))
}
}
func TestCollectPendingAssetsAlbumErr(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{
{Name: "Album1", Kind: "album", ID: "a1"},
},
assetsErr: fmt.Errorf("album error"),
}
var progressErrors []string
items, _ := collectPendingAssets(b.tree, "/tmp", b, false, false, func(p collectProgress) {
if p.err != nil {
progressErrors = append(progressErrors, p.err.Error())
}
}, nil, false, nil, time.Time{}, exportOptions{})
if len(progressErrors) == 0 {
t.Error("expected progress error for album error")
}
if len(items) != 0 {
t.Errorf("expected 0 items, got %d", len(items))
}
}
func TestExportPendingParallelSmoke(t *testing.T) {
assets := make([]photos.Asset, 5)
for i := range assets {
assets[i] = photos.Asset{ID: fmt.Sprintf("x%d", i), Filename: fmt.Sprintf("img%d.jpg", i)}
}
b := &mockBridge{assets: assets}
pending := make([]pendingAsset, len(assets))
for i, a := range assets {
pending[i] = pendingAsset{asset: a, path: "/tmp", album: "test"}
}
var buf bytes.Buffer
bar := newProgressBar(&buf, 3)
done, failed := exportPendingParallel(pending, 1024, 85, false, len(pending), bar, b, 3, nil, manifest.NoopLogWriter, exportOptions{})
if done != 5 {
t.Errorf("expected 5 done, got %d", done)
}
if failed != 0 {
t.Errorf("expected 0 failed, got %d", failed)
}
}
func TestExportPendingParallelManifestAdd(t *testing.T) {
dir := t.TempDir()
mf := manifest.LoadJSONL(dir)
if err := mf.OpenAppend(); err != nil {
t.Fatal(err)
}
assets := make([]photos.Asset, 5)
for i := range assets {
assets[i] = photos.Asset{ID: fmt.Sprintf("m%d", i), Filename: fmt.Sprintf("img%d.jpg", i)}
}
b := &mockBridge{assets: assets}
pending := make([]pendingAsset, len(assets))
for i, a := range assets {
pending[i] = pendingAsset{asset: a, path: dir, album: "test"}
}
var buf bytes.Buffer
bar := newProgressBar(&buf, 3)
done, _ := exportPendingParallel(pending, 1024, 85, false, len(pending), bar, b, 3, mf, manifest.NoopLogWriter, exportOptions{})
if done != 5 {
t.Errorf("expected 5 done, got %d", done)
}
mf.Close()
for i := range assets {
if !mf.Has(assets[i].ID) {
t.Errorf("manifest should have %s", assets[i].ID)
}
}
}
func TestExportPendingParallelChecksumError(t *testing.T) {
dir := t.TempDir()
mf := manifest.LoadJSONL(dir)
if err := mf.OpenAppend(); err != nil {
t.Fatal(err)
}
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
return photos.ExportResult{Filename: "missing.jpg", Size: 3}, nil
}
bar := newProgressBar(io.Discard, 1)
done, failed := exportPendingParallel([]pendingAsset{{asset: b.assets[0], path: dir}}, 1024, 85, false, 1, bar, b, 1, mf, manifest.NoopLogWriter, exportOptions{checksum: "sha256"})
if done != 0 || failed != 1 {
t.Fatalf("done=%d failed=%d", done, failed)
}
}
func TestExportPendingParallelCancel(t *testing.T) {
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}}
b.Cancel()
pending := []pendingAsset{{asset: b.assets[0], path: "/tmp", album: "test"}}
var buf bytes.Buffer
bar := newProgressBar(&buf, 3)
done, _ := exportPendingParallel(pending, 1024, 85, false, 1, bar, b, 3, nil, manifest.NoopLogWriter, exportOptions{})
if done != 0 {
t.Errorf("expected 0 done when cancelled, got %d", done)
}
}
func TestExportPendingParallelErr(t *testing.T) {
assets := make([]photos.Asset, 5)
for i := range assets {
assets[i] = photos.Asset{ID: fmt.Sprintf("e%d", i), Filename: fmt.Sprintf("img%d.jpg", i)}
}
b := &mockBridge{assets: assets}
b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) {
return photos.ExportResult{}, fmt.Errorf("export error")
}
pending := make([]pendingAsset, len(assets))
for i, a := range assets {
pending[i] = pendingAsset{asset: a, path: "/tmp", album: "test"}
}
var buf bytes.Buffer
bar := newProgressBar(&buf, 3)
done, failed := exportPendingParallel(pending, 1024, 85, false, len(pending), bar, b, 3, nil, manifest.NoopLogWriter, exportOptions{})
if failed != 5 {
t.Errorf("expected 5 failed, got %d", failed)
}
if done != 0 {
t.Errorf("expected 0 done, got %d", done)
}
}
func TestExportPendingParallelSkipped(t *testing.T) {
assets := []photos.Asset{
{ID: "s1", Filename: "skip.jpg"},
{ID: "c1", Filename: "cloud.jpg", Cloud: "cloud"},
}
b := &mockBridge{assets: assets}
var callCount int64
b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) {
n := atomic.AddInt64(&callCount, 1)
if n == 1 {
return photos.ExportResult{Filename: "skip.jpg", Size: 0, Skipped: true, Cloud: "local"}, nil
}
return photos.ExportResult{Filename: "cloud.jpg", Size: 2048, Cloud: "cloud"}, nil
}
pending := []pendingAsset{
{asset: assets[0], path: "/tmp", album: "test"},
{asset: assets[1], path: "/tmp", album: "test"},
}
var buf bytes.Buffer
bar := newProgressBar(&buf, 3)
done, failed := exportPendingParallel(pending, 1024, 85, false, 2, bar, b, 3, nil, manifest.NoopLogWriter, exportOptions{})
if done != 1 {
t.Errorf("expected 1 done (cloud only), got %d", done)
}
if failed != 0 {
t.Errorf("expected 0 failed, got %d", failed)
}
}
func TestExportPendingParallelSkippedWritesToManifest(t *testing.T) {
dir := t.TempDir()
mf := manifest.LoadJSONL(dir)
if err := mf.OpenAppend(); err != nil {
t.Fatal(err)
}
assets := []photos.Asset{
{ID: "s1", Filename: "skip.jpg"},
{ID: "c1", Filename: "cloud.jpg", Cloud: "cloud"},
}
b := &mockBridge{assets: assets}
var callCount int64
b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) {
n := atomic.AddInt64(&callCount, 1)
if n == 1 {
return photos.ExportResult{Filename: "skip.jpg", Size: 512, Skipped: true, Cloud: "local"}, nil
}
return photos.ExportResult{Filename: "cloud.jpg", Size: 2048, Cloud: "cloud"}, nil
}
pending := []pendingAsset{
{asset: assets[0], path: dir, album: "test"},
{asset: assets[1], path: dir, album: "test"},
}
var buf bytes.Buffer
bar := newProgressBar(&buf, 3)
done, failed := exportPendingParallel(pending, 1024, 85, false, 2, bar, b, 3, mf, manifest.NoopLogWriter, exportOptions{})
if done != 1 {
t.Errorf("expected 1 done (cloud only), got %d", done)
}
if failed != 0 {
t.Errorf("expected 0 failed, got %d", failed)
}
mf.Close()
if !mf.Has("s1") {
t.Error("manifest should have s1 after skipped parallel export")
}
if !mf.Has("c1") {
t.Error("manifest should have c1 after parallel export")
}
}
func TestExportPendingParallelOrig(t *testing.T) {
assets := make([]photos.Asset, 5)
for i := range assets {
assets[i] = photos.Asset{ID: fmt.Sprintf("o%d", i), Filename: fmt.Sprintf("img%d.heic", i)}
}
b := &mockBridge{assets: assets}
pending := make([]pendingAsset, len(assets))
for i, a := range assets {
pending[i] = pendingAsset{asset: a, path: "/tmp", album: "test"}
}
var buf bytes.Buffer
bar := newProgressBar(&buf, 3)
done, failed := exportPendingParallel(pending, 1024, 85, true, len(pending), bar, b, 3, nil, manifest.NoopLogWriter, exportOptions{})
if done != 5 {
t.Errorf("expected 5 done, got %d", done)
}
if failed != 0 {
t.Errorf("expected 0 failed, got %d", failed)
}
}
func TestExportAssetsManifestWrite(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}},
}
dir := t.TempDir()
var buf bytes.Buffer
done, failed := exportAssets(b.assets, dir, 1024, 85, 3, false, 1, &buf, b, "", false, manifest.FormatJSONL, false, exportOptions{})
if done != 1 {
t.Errorf("expected 1 done, got %d", done)
}
if failed != 0 {
t.Errorf("expected 0 failed, got %d", failed)
}
}
func TestExportAssetsChecksum(t *testing.T) {
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}}
dir := t.TempDir()
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
if err := os.WriteFile(filepath.Join(out, "img.jpg"), []byte("abc"), 0644); err != nil {
return photos.ExportResult{}, err
}
return photos.ExportResult{Filename: "img.jpg", Size: 3, Cloud: "local"}, nil
}
done, failed := exportAssets(b.assets, dir, 1024, 85, 1, false, 1, io.Discard, b, "", false, manifest.FormatJSONL, false, exportOptions{checksum: "sha256"})
if done != 1 || failed != 0 {
t.Fatalf("done=%d failed=%d", done, failed)
}
entry := manifest.LoadJSONL(dir).Entries()["x1"]
if entry.Checksum != "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" {
t.Fatalf("checksum=%q", entry.Checksum)
}
got, err := fileSHA256(filepath.Join(dir, "img.jpg"))
if err != nil || got != entry.Checksum {
t.Fatalf("fileSHA256 got=%q err=%v", got, err)
}
if _, err := fileSHA256(filepath.Join(dir, "missing.jpg")); err == nil {
t.Fatal("expected missing checksum error")
}
if _, err := fileSHA256(dir); err == nil {
t.Fatal("expected directory checksum error")
}
if err := addManifestEntryForResult(nil, pendingAsset{}, photos.ExportResult{}, exportOptions{}); err != nil {
t.Fatalf("nil manifest add error: %v", err)
}
if err := addManifestEntryForResult(manifest.LoadJSONL(dir), pendingAsset{asset: photos.Asset{ID: "skip"}, path: dir}, photos.ExportResult{Filename: "missing.jpg", Skipped: true}, exportOptions{checksum: "sha256"}); err != nil {
t.Fatalf("skipped checksum add error: %v", err)
}
}
func TestExportAssetsChecksumErrors(t *testing.T) {
dir := t.TempDir()
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
return photos.ExportResult{Filename: "missing.jpg", Size: 3}, nil
}
done, failed := exportAssets(b.assets, dir, 1024, 85, 1, false, 1, io.Discard, b, "", false, manifest.FormatJSONL, false, exportOptions{checksum: "sha256"})
if done != 0 || failed != 1 {
t.Fatalf("serial checksum error done=%d failed=%d", done, failed)
}
dir = t.TempDir()
done, failed = exportAssets(b.assets, dir, 1024, 85, 2, false, 2, io.Discard, b, "", false, manifest.FormatJSONL, false, exportOptions{checksum: "sha256"})
if done != 0 || failed != 1 {
t.Fatalf("parallel checksum error done=%d failed=%d", done, failed)
}
}
func TestBackupTreeManifestOpenErr(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{
{Name: "Album1", Kind: "album", ID: "a1"},
},
assetsByAlbum: map[string][]photos.Asset{
"a1": {{ID: "x1", Filename: "img.jpg"}},
},
}
dir := t.TempDir()
dbPath := manifest.SQLitePath(dir)
os.WriteFile(dbPath, []byte("not a sqlite file"), 0644)
var buf bytes.Buffer
_, _, err := backupTree(b.tree, dir, 1024, 85, 3, false, false, &buf, b, false, manifest.FormatJSONL, false, nil, time.Time{}, false, exportOptions{})
if err != nil {
t.Logf("backupTree returned: %v", err)
}
if !strings.Contains(buf.String(), "could not open manifest") {
t.Errorf("expected manifest warning, got: %q", buf.String())
}
}
func TestCmdExportResolveAlbumErr(t *testing.T) {
b := &mockBridge{albumsErr: fmt.Errorf("album error")}
var buf bytes.Buffer
rc := cmdExport([]string{"--album-id", "x", "--out", "/tmp", "--no-manifest"}, &buf, &buf, b)
if rc != 1 {
t.Errorf("expected rc 1, got %d", rc)
}
}
func TestCmdBackupAllAuthErr(t *testing.T) {
b := &mockBridge{accessErr: fmt.Errorf("access denied")}
var buf bytes.Buffer
rc := cmdBackupAll([]string{"--out", "/tmp", "--no-manifest"}, &buf, &buf, b)
if rc != exitAuth {
t.Errorf("expected rc %d, got %d", exitAuth, rc)
}
}
func TestCmdExportAuthErr(t *testing.T) {
b := &mockBridge{accessErr: fmt.Errorf("access denied")}
var buf bytes.Buffer
rc := cmdExport([]string{"--album-id", "x", "--out", "/tmp", "--no-manifest"}, &buf, &buf, b)
if rc != exitAuth {
t.Errorf("expected rc %d, got %d", exitAuth, rc)
}
}
func TestCmdPhotosAuthErr(t *testing.T) {
b := &mockBridge{accessErr: fmt.Errorf("access denied")}
var buf bytes.Buffer
rc := cmdPhotos([]string{"--album-id", "x"}, &buf, &buf, b)
if rc != exitAuth {
t.Errorf("expected rc %d, got %d", exitAuth, rc)
}
}
func TestCollectPendingAssetsSorted(t *testing.T) {
dateOld := "2024-01-01"
dateNew := "2024-06-01"
b := &mockBridge{
tree: []photos.CollectionNode{
{Name: "Album1", Kind: "album", ID: "a1"},
},
assets: []photos.Asset{
{ID: "old", Filename: "old.jpg", CreationDate: &dateOld},
{ID: "new", Filename: "new.jpg", CreationDate: &dateNew},
},
}
items, _ := collectPendingAssets(b.tree, "/tmp", b, false, false, nil, nil, true, nil, time.Time{}, exportOptions{})
if len(items) != 2 {
t.Fatalf("expected 2 items, got %d", len(items))
}
if items[0].asset.ID != "new" {
t.Errorf("expected newest first, got %s", items[0].asset.ID)
}
}
func TestCollectPendingAssetsManifestSkip(t *testing.T) {
dir := t.TempDir()
mf := manifest.LoadJSONL(dir)
if err := mf.OpenAppend(); err != nil {
t.Fatal(err)
}
mf.Add("x1", "img.jpg", 1024, "local")
mf.Close()
b := &mockBridge{
tree: []photos.CollectionNode{
{Name: "Album1", Kind: "album", ID: "a1"},
},
assets: []photos.Asset{
{ID: "x1", Filename: "img.jpg"},
{ID: "x2", Filename: "new.jpg"},
},
}
items, skipped := collectPendingAssets(b.tree, "/tmp", b, false, false, nil, mf, false, nil, time.Time{}, exportOptions{})
if skipped != 1 {
t.Errorf("expected 1 skipped, got %d", skipped)
}
if len(items) != 1 {
t.Errorf("expected 1 item, got %d", len(items))
}
}
func TestExportPendingChoiceParallelPath(t *testing.T) {
assets := make([]photos.Asset, 5)
for i := range assets {
assets[i] = photos.Asset{ID: fmt.Sprintf("p%d", i), Filename: fmt.Sprintf("img%d.jpg", i)}
}
b := &mockBridge{assets: assets}
pending := make([]pendingAsset, len(assets))
for i, a := range assets {
pending[i] = pendingAsset{asset: a, path: "/tmp", album: "test"}
}
var buf bytes.Buffer
bar := newProgressBar(&buf, 3)
done, failed := exportPending(pending, 1024, 85, false, len(pending), bar, b, nil, 3, manifest.NoopLogWriter, exportOptions{})
if done != 5 {
t.Errorf("expected 5 done, got %d", done)
}
if failed != 0 {
t.Errorf("expected 0 failed, got %d", failed)
}
}
func TestCmdExportNoAlbumIDArg(t *testing.T) {
b := &mockBridge{}
var buf bytes.Buffer
rc := cmdExport([]string{"--out", "/tmp"}, &buf, &buf, b)
if rc != 1 {
t.Errorf("expected rc 1 for missing --album-id, got %d", rc)
}
}
func TestCmdBackupAllNoOutArg(t *testing.T) {
b := &mockBridge{}
var buf bytes.Buffer
rc := cmdBackupAll([]string{}, &buf, &buf, b)
if rc != 1 {
t.Errorf("expected rc 1 for missing --out, got %d", rc)
}
}
func TestRenderLineETADisplay(t *testing.T) {
line := renderLine(barLine{current: 50, total: 100, label: "Total", detail: "test"}, 10*time.Second, 80)
if !strings.Contains(line, "Total") {
t.Error("expected Total in line")
}
}
func TestRenderLineZeroWidthDisplay(t *testing.T) {
line := renderLine(barLine{current: 50, total: 100, label: "Total"}, 0, 0)
if !strings.Contains(line, "Total") {
t.Error("expected Total in line with zero width")
}
}
func TestRenderLineAlbumWithCounterDisplay(t *testing.T) {
line := renderLine(barLine{current: 5, total: 10, label: "Album", detail: "Vacation"}, 5*time.Second, 80)
if !strings.Contains(line, "Vacation") {
t.Error("expected Vacation in line")
}
}
func TestTruncateOrPadLong(t *testing.T) {
longStr := strings.Repeat("x", 100)
result := truncateOrPad(longStr, 20)
if !strings.HasSuffix(result, "...") {
t.Error("expected truncation with ...")
}
}
func TestTruncateOrPadZero(t *testing.T) {
result := truncateOrPad("hello", 0)
if result == "" {
t.Error("expected non-empty result for zero width")
}
}
func TestRenderWorkerLineStatusTable(t *testing.T) {
tests := []struct {
name string
ws workerSlot
contains string
}{
{"fail status", workerSlot{filename: "test.jpg", status: "FAIL"}, "\u274c"},
{"skipped status", workerSlot{filename: "test.jpg", status: "skipped", size: 1024}, "\u23ed"},
{"cloud in progress", workerSlot{filename: "test.jpg", cloud: "cloud", progress: 0.5, bytesTotal: 2048, bytesDone: 1024, speed: 1024.0}, "\u2601"},
{"cloud downloaded", workerSlot{filename: "test.jpg", cloud: "cloud", size: 2048}, "\u2601"},
{"local copied", workerSlot{filename: "test.jpg", size: 1024}, "\u2705"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
line := renderWorkerLine(tt.ws, 80)
if !strings.Contains(line, tt.contains) {
t.Errorf("expected %q in %q", tt.contains, line)
}
})
}
}
func TestRenderWorkerLineZeroWidthDisplay(t *testing.T) {
line := renderWorkerLine(workerSlot{filename: "test.jpg", size: 100}, 0)
if !strings.Contains(line, "test.jpg") {
t.Error("expected filename in line")
}
}
func TestRenderWorkerLineCloudProgressWithSpeedDisplay(t *testing.T) {
ws := workerSlot{filename: "big.jpg", cloud: "cloud", progress: 0.75, bytesTotal: 4096, bytesDone: 3072, speed: 512.0}
line := renderWorkerLine(ws, 80)
if !strings.Contains(line, "75%") {
t.Errorf("expected 75%% in line, got: %s", line)
}
}
func TestEnsureScrollRegionDisplay(t *testing.T) {
var buf bytes.Buffer
bar := newProgressBar(&buf, 3)
bar.ensureScrollRegion()
if !bar.scrollSet {
t.Error("expected scrollSet to be true after ensureScrollRegion")
}
}
func TestDrawFooterLockedEmptyWorkerDisplay(t *testing.T) {
var buf bytes.Buffer
bar := newProgressBar(&buf, 3)
bar.setTotal(0, 10, "")
bar.draw()
bar.clear()
}
func TestCmdExportIncludeVideosFlag(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{
{ID: "1", Filename: "photo.jpg", MediaType: "image"},
{ID: "2", Filename: "vid.mov", MediaType: "video"},
},
}
dir := t.TempDir()
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--include-videos", "--no-manifest"}, b)
if rc != 0 {
t.Errorf("rc = %d, stderr = %q", rc, stderr)
}
}
func TestCmdExportWithManifestFlag(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}},
}
dir := t.TempDir()
_, _, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--manifest", "jsonl"}, b)
if rc != 0 {
t.Fatalf("expected rc 0")
}
if _, err := os.Stat(filepath.Join(dir, "downloads.jsonl")); err != nil {
t.Errorf("expected manifest file to exist: %v", err)
}
}
func TestCmdExportFailedExports(t *testing.T) {
b := &mockBridge{
albums: []photos.Album{{ID: "x", Title: "Album"}},
assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}},
}
b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) {
return photos.ExportResult{}, fmt.Errorf("export error")
}
var buf bytes.Buffer
rc := cmdExport([]string{"--album-id", "x", "--out", "/tmp", "--no-manifest"}, &buf, &buf, b)
if rc != 1 {
t.Errorf("expected rc 1 when all exports fail, got %d", rc)
}
}
func TestCmdExportOriginalsMode(t *testing.T) {
b := &mockBridge{
albums: []photos.Album{{ID: "x", Title: "Album"}},
assets: []photos.Asset{{ID: "x1", Filename: "img.heic"}},
}
dir := t.TempDir()
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--originals", "--no-manifest"}, b)
if rc != 0 {
t.Fatalf("rc = %d, stderr = %q", rc, stderr)
}
if !strings.Contains(stderr, "originals") {
t.Errorf("expected 'originals' in output")
}
}
func TestCmdBackupAllFailedManifestSave(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{
{Name: "Album1", Kind: "album", ID: "a1"},
},
}
dir := t.TempDir()
_, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--manifest", "jsonl"}, b)
if rc != 0 {
t.Fatalf("rc = %d, stderr = %q", rc, stderr)
}
_ = stderr
}
func TestCollectPendingAssetsSortNilDates(t *testing.T) {
dateNew := "2024-06-01"
b := &mockBridge{
tree: []photos.CollectionNode{
{Name: "Album1", Kind: "album", ID: "a1"},
},
assets: []photos.Asset{
{ID: "x1", Filename: "nil.jpg"},
{ID: "x2", Filename: "new.jpg", CreationDate: &dateNew},
},
}
items, _ := collectPendingAssets(b.tree, "/tmp", b, false, false, nil, nil, true, nil, time.Time{}, exportOptions{})
if len(items) != 2 {
t.Fatalf("expected 2 items, got %d", len(items))
}
if items[0].asset.ID != "x2" {
t.Errorf("expected newest (non-nil) first, got %s", items[0].asset.ID)
}
}
func TestBackupTreeManifestOpenAppendErr(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{
{Name: "Album1", Kind: "album", ID: "a1"},
},
assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}},
}
dir := t.TempDir()
var buf bytes.Buffer
_, _, err := backupTree(b.tree, dir, 1024, 85, 3, false, false, &buf, b, false, manifest.FormatJSONL, false, nil, time.Time{}, false, exportOptions{})
if err != nil {
t.Errorf("backupTree should succeed even with manifest error, got %v", err)
}
}
func TestRenderLineSmallBarWidth(t *testing.T) {
line := renderLine(barLine{current: 50, total: 100, label: "Total"}, 30*time.Second, 20)
if !strings.Contains(line, "Total") {
t.Error("expected Total in line")
}
}
func TestEnsureScrollRegionResize(t *testing.T) {
var buf bytes.Buffer
bar := newProgressBar(&buf, 1)
bar.draw()
bar.draw()
bar.clear()
}
func TestProgressLogCompleted(t *testing.T) {
var buf bytes.Buffer
bar := newProgressBar(&buf, 1)
bar.logCompleted("test completed")
bar.clear()
}
func TestProgressSetWorkerNegIdx(t *testing.T) {
var buf bytes.Buffer
bar := newProgressBar(&buf, 2)
bar.setWorker(-1, "test", 0, "", "exporting")
bar.draw()
bar.clear()
}
func TestBackupTreeManifestSaveFail(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{
{Name: "Album1", Kind: "album", ID: "a1"},
},
assetsByAlbum: map[string][]photos.Asset{
"a1": {{ID: "x1", Filename: "img.jpg"}},
},
}
dir := t.TempDir()
oldHook := manifest.SetJSONLSaveHook(func() error { return fmt.Errorf("sync failed") })
defer manifest.SetJSONLSaveHook(oldHook)
var buf bytes.Buffer
_, _, err := backupTree(b.tree, dir, 1024, 85, 3, false, false, &buf, b, false, manifest.FormatJSONL, false, nil, time.Time{}, false, exportOptions{})
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if !strings.Contains(buf.String(), "could not save manifest") {
t.Errorf("expected save manifest warning, got: %s", buf.String())
}
}
func TestBackupTreeManifestOpenAppendFail(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{
{Name: "Album1", Kind: "album", ID: "a1"},
},
assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}},
}
dir := t.TempDir()
jsonlPath := manifest.JSONLPath(dir)
os.WriteFile(jsonlPath, []byte("{}\n"), 0644)
os.Chmod(jsonlPath, 0444)
defer os.Chmod(jsonlPath, 0644)
var buf bytes.Buffer
_, _, err := backupTree(b.tree, dir, 1024, 85, 3, false, false, &buf, b, false, manifest.FormatJSONL, false, nil, time.Time{}, false, exportOptions{})
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if !strings.Contains(buf.String(), "could not open manifest") {
t.Errorf("expected open append warning, got: %s", buf.String())
}
}
func TestExportAssetsManifestSaveFail(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}},
}
dir := t.TempDir()
oldHook := manifest.SetJSONLSaveHook(func() error { return fmt.Errorf("sync failed") })
defer manifest.SetJSONLSaveHook(oldHook)
var buf bytes.Buffer
done, failed := exportAssets(b.assets, dir, 1024, 85, 3, false, 1, &buf, b, "", false, manifest.FormatJSONL, false, exportOptions{})
if done != 1 {
t.Errorf("expected 1 done, got %d", done)
}
if failed != 0 {
t.Errorf("expected 0 failed, got %d", failed)
}
if !strings.Contains(buf.String(), "could not save manifest") {
t.Errorf("expected save manifest warning, got: %s", buf.String())
}
}
func TestCmdBackupAllCancelledCmd(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{
{Name: "Album1", Kind: "album", ID: "a1"},
},
}
b.Cancel()
var buf bytes.Buffer
rc := cmdBackupAll([]string{"--out", "/tmp", "--no-manifest"}, &buf, &buf, b)
if rc != 1 {
t.Errorf("expected rc 1 for cancelled, got %d", rc)
}
}
func TestCmdExportSortNewestMixedDates(t *testing.T) {
dateOld := "2024-01-01"
dateNew := "2024-06-01"
b := &mockBridge{
albums: []photos.Album{{ID: "x", Title: "Album"}},
assetsByAlbum: map[string][]photos.Asset{
"x": {
{ID: "old", Filename: "old.jpg", CreationDate: &dateOld},
{ID: "new", Filename: "new.jpg", CreationDate: &dateNew},
{ID: "nil", Filename: "nil.jpg"},
},
},
}
dir := t.TempDir()
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--sort", "newest", "--no-manifest"}, b)
if rc != 0 {
t.Fatalf("rc = %d, stderr = %q", rc, stderr)
}
}
func TestProgressDrawSmallTerminal(t *testing.T) {
var buf bytes.Buffer
bar := newProgressBar(&buf, 30)
bar.setTotal(5, 10, "test")
bar.draw()
bar.clear()
}
func TestCmdPhotosListAssetsErrorAfterResolve(t *testing.T) {
b := &mockBridge{
albums: []photos.Album{{ID: "x", Title: "x"}},
listAssetsFn: func(albumID string) ([]photos.Asset, int, error) {
return nil, 0, fmt.Errorf("list failed")
},
}
var buf bytes.Buffer
rc := cmdPhotos([]string{"--album-id", "x"}, &buf, &buf, b)
if rc != 1 {
t.Errorf("expected rc 1, got %d", rc)
}
if !strings.Contains(buf.String(), "list failed") {
t.Errorf("expected error message in output, got: %s", buf.String())
}
}
func TestCmdExportListAssetsErrorAfterResolve(t *testing.T) {
b := &mockBridge{
albums: []photos.Album{{ID: "x", Title: "x"}},
listAssetsFn: func(albumID string) ([]photos.Asset, int, error) {
return nil, 0, fmt.Errorf("list failed")
},
}
var buf bytes.Buffer
rc := cmdExport([]string{"--album-id", "x", "--out", t.TempDir(), "--no-manifest"}, &buf, &buf, b)
if rc != 1 {
t.Errorf("expected rc 1, got %d", rc)
}
if !strings.Contains(buf.String(), "list failed") {
t.Errorf("expected error message in output, got: %s", buf.String())
}
}
func TestCmdExportSortNewestBothNilDates(t *testing.T) {
b := &mockBridge{
albums: []photos.Album{{ID: "x", Title: "Album"}},
assetsByAlbum: map[string][]photos.Asset{
"x": {
{ID: "b", Filename: "b.jpg"},
{ID: "a", Filename: "a.jpg"},
},
},
}
dir := t.TempDir()
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--sort", "newest", "--no-manifest"}, b)
if rc != 0 {
t.Fatalf("rc = %d, stderr = %q", rc, stderr)
}
}
func TestCmdExportSortNewestOneNilDate(t *testing.T) {
dateOld := "2024-01-01"
dateNew := "2024-06-01"
b := &mockBridge{
albums: []photos.Album{{ID: "x", Title: "Album"}},
assetsByAlbum: map[string][]photos.Asset{
"x": {
{ID: "no-date", Filename: "nodate.jpg"},
{ID: "has-date-old", Filename: "old.jpg", CreationDate: &dateOld},
{ID: "has-date-new", Filename: "new.jpg", CreationDate: &dateNew},
},
},
}
dir := t.TempDir()
_, _, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--sort", "newest", "--no-manifest"}, b)
if rc != 0 {
t.Errorf("expected rc 0, got %d", rc)
}
}
func TestCollectPendingAssetsSortNewestNilDates(t *testing.T) {
dateOld := "2024-01-01"
b := &mockBridge{
tree: []photos.CollectionNode{
{Kind: "album", ID: "a1", Name: "Album1"},
},
assetsByAlbum: map[string][]photos.Asset{
"a1": {
{ID: "dated", Filename: "dated.jpg", CreationDate: &dateOld},
{ID: "nil1", Filename: "nil1.jpg"},
{ID: "nil2", Filename: "nil2.jpg"},
},
},
}
items, _ := collectPendingAssets(b.tree, t.TempDir(), b, false, false, nil, nil, true, nil, time.Time{}, exportOptions{})
if len(items) != 3 {
t.Fatalf("expected 3 items, got %d", len(items))
}
if items[0].asset.ID != "dated" {
t.Errorf("expected dated first, got %s", items[0].asset.ID)
}
}
func TestExportPendingParallelCancelledWorker(t *testing.T) {
b := &mockBridge{}
var callCount atomic.Int32
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
if callCount.Add(1) >= 1 {
b.Cancel()
}
return photos.ExportResult{Filename: "test.jpg", Size: 100, Cloud: "local"}, nil
}
dir := t.TempDir()
pending := []pendingAsset{
{asset: photos.Asset{ID: "x1", Filename: "img1.jpg", Cloud: "local"}, path: dir, album: "test"},
{asset: photos.Asset{ID: "x2", Filename: "img2.jpg", Cloud: "local"}, path: dir, album: "test"},
}
bar := newProgressBar(io.Discard, 1)
done, _ := exportPendingParallel(pending, 1024, 85, false, 1, bar, b, 1, nil, manifest.NoopLogWriter, exportOptions{})
if done > 2 {
t.Errorf("expected at most 2 done, got %d", done)
}
}
func TestBackupTreeManifestOpenConvertErr(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{
{Name: "Album1", Kind: "album", ID: "a1"},
},
assetsByAlbum: map[string][]photos.Asset{
"a1": {{ID: "x1", Filename: "img.jpg"}},
},
}
dir := t.TempDir()
dbPath := manifest.SQLitePath(dir)
os.WriteFile(dbPath, []byte("not a sqlite file"), 0644)
var buf bytes.Buffer
_, _, err := backupTree(b.tree, dir, 1024, 85, 3, false, false, &buf, b, false, manifest.FormatJSONL, false, nil, time.Time{}, false, exportOptions{})
if err != nil {
t.Logf("backupTree: %v", err)
}
}
func TestExportAssetsManifestOpenConvertErr(t *testing.T) {
assets := []photos.Asset{{ID: "x1", Filename: "img.jpg", Cloud: "local"}}
b := &mockBridge{}
dir := t.TempDir()
dbPath := manifest.SQLitePath(dir)
os.WriteFile(dbPath, []byte("not a sqlite file"), 0644)
var buf bytes.Buffer
exportAssets(assets, dir, 1024, 85, 3, false, 1, &buf, b, "", false, manifest.FormatJSONL, false, exportOptions{})
}
func TestExportPendingParallelProgressUpdate(t *testing.T) {
origPollInterval := progressPollInterval
progressPollInterval = 1 * time.Millisecond
defer func() { progressPollInterval = origPollInterval }()
b := &mockBridge{
exportPreviewFn: func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
time.Sleep(20 * time.Millisecond)
return photos.ExportResult{Filename: "test.jpg", Size: 100, Cloud: "local"}, nil
},
}
dir := t.TempDir()
pending := []pendingAsset{
{asset: photos.Asset{ID: "x1", Filename: "img.jpg", Cloud: "local"}, path: dir, album: "test"},
}
bar := newProgressBar(io.Discard, 1)
done, failed := exportPendingParallel(pending, 1024, 85, false, 1, bar, b, 1, nil, manifest.NoopLogWriter, exportOptions{})
if done != 1 {
t.Errorf("expected 1 done, got %d", done)
}
if failed != 0 {
t.Errorf("expected 0 failed, got %d", failed)
}
}
func TestExportPendingParallelTimeout(t *testing.T) {
origTimeout := exportTimeout
exportTimeout = 10 * time.Millisecond
defer func() { exportTimeout = origTimeout }()
b := &mockBridge{
exportPreviewFn: func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
time.Sleep(30 * time.Millisecond)
return photos.ExportResult{Filename: "test.jpg", Size: 100, Cloud: "local"}, nil
},
}
dir := t.TempDir()
pending := []pendingAsset{
{asset: photos.Asset{ID: "x1", Filename: "img.jpg", Cloud: "local"}, path: dir, album: "test"},
}
bar := newProgressBar(io.Discard, 1)
done, failed := exportPendingParallel(pending, 1024, 85, false, 1, bar, b, 1, nil, manifest.NoopLogWriter, exportOptions{})
t.Logf("done=%d, failed=%d", done, failed)
}
func TestIsExcluded(t *testing.T) {
tests := []struct {
name string
exclude []string
want bool
}{
{"no patterns", nil, false},
{"exact match", []string{"Photos"}, true},
{"no match", []string{"Videos"}, false},
{"glob match", []string{"Recent*"}, true},
{"glob no match", []string{"Recent*"}, false},
}
for _, tt := range tests {
t.Run(tt.name+"_"+tt.name, func(t *testing.T) {
name := tt.name
if tt.name == "glob match" {
name = "Recently Deleted"
}
if tt.name == "glob no match" {
name = "Favorites"
}
if tt.name == "exact match" {
name = "Photos"
}
if tt.name == "no match" {
name = "Photos"
}
if tt.name == "no patterns" {
name = "Photos"
}
got := isExcluded(name, tt.exclude)
if got != tt.want {
t.Errorf("isExcluded(%q, %v) = %v, want %v", name, tt.exclude, got, tt.want)
}
})
}
}
func TestFlagVals(t *testing.T) {
args := []string{"--exclude-album", "Photos", "--exclude-album", "Recent*", "--out", "/tmp"}
vals := flagVals(args, "--exclude-album")
if len(vals) != 2 || vals[0] != "Photos" || vals[1] != "Recent*" {
t.Errorf("flagVals = %v, want [Photos Recent*]", vals)
}
empty := flagVals(args, "--nonexistent")
if len(empty) != 0 {
t.Errorf("flagVals for nonexistent = %v, want []", empty)
}
}
func TestCmdBackupAllExcludeAlbum(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{
{ID: "a1", Name: "Photos", Kind: "album"},
{ID: "a2", Name: "Trips", Kind: "album"},
},
assetsByAlbum: map[string][]photos.Asset{
"a1": {{ID: "p1", Filename: "photo1.jpg"}},
"a2": {{ID: "t1", Filename: "trip1.jpg"}},
},
}
dir := t.TempDir()
_, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--exclude-album", "Photos", "--no-manifest"}, b)
if rc != exitOK {
t.Errorf("rc = %d, stderr = %q", rc, stderr)
}
if strings.Contains(stderr, "Photos") {
t.Errorf("should not export excluded album Photos, stderr = %q", stderr)
}
}
func TestCollectNodesExcludeAlbum(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{
{ID: "a1", Name: "Recently Deleted", Kind: "album"},
{ID: "a2", Name: "Favorites", Kind: "album"},
},
assetsByAlbum: map[string][]photos.Asset{
"a2": {{ID: "x1", Filename: "fav.jpg"}},
},
}
var items []pendingAsset
var skipped int
collectNodes(b.tree, "/out", b, false, false, &items, &skipped, nil, nil, []string{"Recently Deleted"}, exportOptions{})
if len(items) != 1 || items[0].asset.ID != "x1" {
t.Errorf("expected 1 item (Favorites), got %d items", len(items))
}
}
func TestFilterBySince(t *testing.T) {
oldDate := "2023-01-01T00:00:00Z"
newDate := "2025-01-01T00:00:00Z"
assets := []photos.Asset{
{ID: "old", Filename: "old.jpg", CreationDate: &oldDate},
{ID: "new", Filename: "new.jpg", CreationDate: &newDate},
{ID: "nil", Filename: "nil.jpg"},
}
since, _ := time.Parse("2006-01-02", "2024-06-01")
filtered := filterBySince(assets, since)
if len(filtered) != 2 {
t.Errorf("expected 2 assets after since filter, got %d", len(filtered))
}
if filtered[0].ID != "new" && filtered[1].ID != "new" {
t.Errorf("expected 'new' asset to remain")
}
}
func TestParseSinceDate(t *testing.T) {
t1, err := parseSinceDate("2024-01-15")
if err != nil || t1.Year() != 2024 {
t.Errorf("expected 2024, got %v, err %v", t1, err)
}
t2, err := parseSinceDate("2024-06-01T10:30:00Z")
if err != nil || t2.Year() != 2024 {
t.Errorf("expected 2024, got %v, err %v", t2, err)
}
_, err = parseSinceDate("not-a-date")
if err == nil {
t.Error("expected error for invalid date")
}
}
func TestCollectPendingAssetsWithSince(t *testing.T) {
oldDate := "2023-01-01T00:00:00Z"
newDate := "2025-01-01T00:00:00Z"
b := &mockBridge{
tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}},
assetsByAlbum: map[string][]photos.Asset{
"a1": {
{ID: "old", Filename: "old.jpg", CreationDate: &oldDate},
{ID: "new", Filename: "new.jpg", CreationDate: &newDate},
},
},
}
since, _ := time.Parse("2006-01-02", "2024-06-01")
items, skipped := collectPendingAssets(b.tree, "/out", b, false, false, nil, nil, false, nil, since, exportOptions{})
if len(items) != 1 {
t.Errorf("expected 1 item after since filter, got %d", len(items))
}
if items[0].asset.ID != "new" {
t.Errorf("expected 'new' asset, got %s", items[0].asset.ID)
}
if skipped != 1 {
t.Errorf("expected 1 skipped (old asset), got %d", skipped)
}
}
func TestCmdBackupAllSinceFlag(t *testing.T) {
b := &mockBridge{tree: []photos.CollectionNode{}}
dir := t.TempDir()
_, _, rc := runWith([]string{"backup-all", "--out", dir, "--since", "2024-01-01", "--no-manifest"}, b)
if rc != exitOK {
t.Errorf("rc = %d", rc)
}
}
func TestCmdBackupAllSinceInvalid(t *testing.T) {
b := &mockBridge{tree: []photos.CollectionNode{}}
_, stderr, rc := runWith([]string{"backup-all", "--out", "/tmp", "--since", "bad-date", "--no-manifest"}, b)
if rc != exitErr {
t.Errorf("rc = %d, want %d", rc, exitErr)
}
if !strings.Contains(stderr, "date") {
t.Errorf("stderr = %q", stderr)
}
}
func TestCmdExportSinceFlag(t *testing.T) {
dateNew := "2025-01-01T00:00:00Z"
dateOld := "2023-01-01T00:00:00Z"
b := &mockBridge{
assets: []photos.Asset{
{ID: "old", Filename: "old.jpg", CreationDate: &dateOld},
{ID: "new", Filename: "new.jpg", CreationDate: &dateNew},
},
}
dir := t.TempDir()
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--since", "2024-06-01", "--no-manifest"}, b)
if rc != exitOK {
t.Errorf("rc = %d, stderr = %q", rc, stderr)
}
if !strings.Contains(stderr, "1 assets") || strings.Contains(stderr, "2 assets") {
t.Errorf("expected only 1 asset after --since filter, stderr = %q", stderr)
}
}
func TestCmdExportSinceInvalid(t *testing.T) {
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}}
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--since", "bad-date", "--no-manifest"}, b)
if rc != exitErr {
t.Errorf("rc = %d, want %d", rc, exitErr)
}
if !strings.Contains(stderr, "--since") && !strings.Contains(stderr, "date") {
t.Errorf("stderr = %q", stderr)
}
}
func TestCmdExportWithLog(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}},
}
dir := t.TempDir()
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--no-manifest", "--log"}, b)
if rc != exitOK {
t.Errorf("rc = %d, stderr = %q", rc, stderr)
}
if _, err := os.ReadFile(manifest.LogPath(dir)); os.IsNotExist(err) {
t.Error("expected export.log to exist with --log")
}
}
func TestCmdBackupAllWithLog(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}},
assetsByAlbum: map[string][]photos.Asset{
"a1": {{ID: "x1", Filename: "img.jpg"}},
},
}
dir := t.TempDir()
_, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--no-manifest", "--log"}, b)
if rc != exitOK {
t.Errorf("rc = %d, stderr = %q", rc, stderr)
}
if _, err := os.ReadFile(manifest.LogPath(dir)); os.IsNotExist(err) {
t.Error("expected export.log to exist with --log --no-manifest")
}
}
func TestCmdBackupAllWithLogSQLite(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}},
assetsByAlbum: map[string][]photos.Asset{
"a1": {{ID: "x1", Filename: "img.jpg"}},
},
}
dir := t.TempDir()
_, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--manifest", "sqlite", "--log"}, b)
if rc != exitOK {
t.Errorf("rc = %d, stderr = %q", rc, stderr)
}
}
func TestExportAssetsWithLog(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}},
}
dir := t.TempDir()
exported, failed := exportAssets(b.assets, dir, 1024, 85, 3, false, 1, io.Discard, b, "", false, manifest.FormatJSONL, true, exportOptions{})
if exported != 1 || failed != 0 {
t.Errorf("exported=%d failed=%d", exported, failed)
}
if _, err := os.ReadFile(manifest.LogPath(dir)); os.IsNotExist(err) {
t.Error("expected export.log with enableLog=true")
}
}
func TestBackupTreeWithLog(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}},
assetsByAlbum: map[string][]photos.Asset{
"a1": {{ID: "x1", Filename: "img.jpg"}},
},
}
dir := t.TempDir()
_, _, err := backupTree(b.tree, dir, 1024, 85, 3, false, false, io.Discard, b, false, manifest.FormatJSONL, false, nil, time.Time{}, true, exportOptions{})
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if _, err := os.ReadFile(manifest.LogPath(dir)); os.IsNotExist(err) {
t.Error("expected export.log with enableLog=true")
}
}
func TestExportAssetsLogOpenError(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}},
}
var stderr bytes.Buffer
exportAssets(b.assets, "/proc/cannot-create", 1024, 85, 3, false, 1, &stderr, b, "", true, manifest.FormatJSONL, true, exportOptions{})
if !strings.Contains(stderr.String(), "could not open log writer") {
t.Errorf("expected log writer warning, got: %q", stderr.String())
}
}
func TestBackupTreeLogOpenError(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}},
assetsByAlbum: map[string][]photos.Asset{
"a1": {{ID: "x1", Filename: "img.jpg"}},
},
}
var stderr bytes.Buffer
_, _, err := backupTree(b.tree, "/proc/cannot-create", 1024, 85, 3, false, false, &stderr, b, true, manifest.FormatJSONL, false, nil, time.Time{}, true, exportOptions{})
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if !strings.Contains(stderr.String(), "could not open log writer") {
t.Errorf("expected log writer warning, got: %q", stderr.String())
}
}
func TestNewFeatureCommandsAndOptions(t *testing.T) {
oldConfigValues, oldConfigLoaded := configValues, configLoaded
defer func() { configValues, configLoaded = oldConfigValues, oldConfigLoaded }()
configValues, configLoaded = nil, false
date := "2024-01-02T00:00:00Z"
b := &mockBridge{
albums: []photos.Album{{ID: "a1", Title: "Album"}},
assets: []photos.Asset{
{ID: "x1", Filename: "fav.jpg", MediaType: "image", IsFavorite: true, PixelWidth: 10, PixelHeight: 10, CreationDate: &date},
{ID: "x2", Filename: "vid.mov", MediaType: "video", PixelWidth: 20, PixelHeight: 20},
},
tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}},
assetsByAlbum: map[string][]photos.Asset{
"a1": {{ID: "x1", Filename: "fav.jpg", MediaType: "image", IsFavorite: true, PixelWidth: 10, PixelHeight: 10, CreationDate: &date}},
},
}
dir := t.TempDir()
out, stderr, rc := runWith([]string{"export", "--album-id", "Album", "--out", dir, "--dry-run", "--json", "--only-favorites", "--media", "photos", "--format", "jpeg", "--min-size", "1", "--max-size", "1000", "--date-template", "YYYY/MM/DD"}, b)
if rc != exitOK || !strings.Contains(out, "x1") || !strings.Contains(out, "total") || !strings.Contains(stderr, "dry-run") {
t.Fatalf("dry-run rc=%d out=%q stderr=%q", rc, out, stderr)
}
out, stderr, rc = runWith([]string{"backup-all", "--out", dir, "--dry-run", "--json", "--media", "all", "--date-template", "YYYY/MM/DD"}, b)
if rc != exitOK || !strings.Contains(out, "x1") || !strings.Contains(stderr, "dry-run") {
t.Fatalf("backup dry-run rc=%d out=%q stderr=%q", rc, out, stderr)
}
_, stderr, rc = runWith([]string{"export", "--album-id", "Album", "--out", dir, "--media", "bad"}, b)
if rc != exitErr || !strings.Contains(stderr, "--media") {
t.Fatalf("expected media error, rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"export", "--album-id", "Album", "--out", dir, "--format", "bad"}, b)
if rc != exitErr || !strings.Contains(stderr, "--format") {
t.Fatalf("expected format error, rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"export", "--album-id", "Album", "--out", dir, "--retry", "bad"}, b)
if rc != exitErr || !strings.Contains(stderr, "--retry") {
t.Fatalf("expected retry error, rc=%d stderr=%q", rc, stderr)
}
}
func TestReportDiffVerifyRetryFailedAndConfig(t *testing.T) {
oldConfigValues, oldConfigLoaded := configValues, configLoaded
defer func() { configValues, configLoaded = oldConfigValues, oldConfigLoaded }()
dir := t.TempDir()
cfg := filepath.Join(dir, "config.toml")
if err := os.WriteFile(cfg, []byte("quality = 90\nlog = true\n"), 0644); err != nil {
t.Fatal(err)
}
t.Setenv("PHOTOSCLI_CONFIG", cfg)
configValues, configLoaded = nil, false
if flagValWithDefault(nil, "--quality", "85") != "90" || !hasFlag(nil, "--log") {
t.Fatal("expected config defaults")
}
m := manifest.LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Add("x1", "file.jpg", 10, "local")
if err := m.Save(); err != nil {
t.Fatal(err)
}
m.Close()
if err := os.WriteFile(filepath.Join(dir, "file.jpg"), []byte("0123456789"), 0644); err != nil {
t.Fatal(err)
}
appendFailure(dir, pendingAsset{asset: photos.Asset{ID: "bad", Filename: "bad.jpg"}, path: dir, album: "Album"}, fmt.Errorf("boom"))
out, stderr, rc := runWith([]string{"report", "--out", dir}, &mockBridge{})
if rc != exitOK || !strings.Contains(out, "entries\t1") || stderr != "" {
t.Fatalf("report rc=%d out=%q stderr=%q", rc, out, stderr)
}
out, stderr, rc = runWith([]string{"verify", "--out", dir}, &mockBridge{})
if rc != exitOK || !strings.Contains(out, "verified") || stderr != "" {
t.Fatalf("verify rc=%d out=%q stderr=%q", rc, out, stderr)
}
if err := os.WriteFile(filepath.Join(dir, "file.xmp"), renderXMP(xmpSidecarData{AssetID: "x1", ExportedFilename: "file.jpg"}), 0644); err != nil {
t.Fatal(err)
}
out, stderr, rc = runWith([]string{"verify", "--out", dir, "--sidecar"}, &mockBridge{})
if rc != exitOK || !strings.Contains(out, "verified") || stderr != "" {
t.Fatalf("verify sidecar rc=%d out=%q stderr=%q", rc, out, stderr)
}
b := &mockBridge{albums: []photos.Album{{ID: "a1", Title: "Album"}}, assets: []photos.Asset{{ID: "x1", Filename: "file.jpg"}, {ID: "x2", Filename: "missing.jpg"}}}
out, _, rc = runWith([]string{"diff", "--album-id", "Album", "--out", dir}, b)
if rc != exitPartial || !strings.Contains(out, "x2") {
t.Fatalf("diff rc=%d out=%q", rc, out)
}
out, _, rc = runWith([]string{"retry-failed", "--out", dir}, b)
if rc != exitOK || !strings.Contains(out, "exported") {
t.Fatalf("retry-failed rc=%d out=%q", rc, out)
}
}
func TestNewFeatureHelpers(t *testing.T) {
date := "2024-03-04T00:00:00Z"
a := photos.Asset{ID: "x", Filename: "x.jpg", MediaType: "video", IsFavorite: false, PixelWidth: 10, PixelHeight: 20, CreationDate: &date}
if len(applyAssetFilters([]photos.Asset{a}, exportOptions{media: "videos", minSize: 100, maxSize: 300})) != 1 {
t.Fatal("expected video in size range")
}
if len(applyAssetFilters([]photos.Asset{a}, exportOptions{media: "photos"})) != 0 {
t.Fatal("expected video filtered from photos")
}
if got := pathWithDateTemplate("/out", a, "YYYY/MM/DD"); !strings.Contains(got, "2024") || !strings.Contains(got, "03") || !strings.Contains(got, "04") {
t.Fatalf("unexpected templated path %q", got)
}
if got := pathWithDateTemplate("/out", photos.Asset{}, "YYYY"); got != "/out" {
t.Fatalf("expected base path, got %q", got)
}
}
func TestNewFeatureCoverageEdges(t *testing.T) {
oldConfigValues, oldConfigLoaded := configValues, configLoaded
defer func() { configValues, configLoaded = oldConfigValues, oldConfigLoaded }()
configValues, configLoaded = map[string]string{}, true
date := "bad-date"
if got := pathWithDateTemplate("/out", photos.Asset{CreationDate: &date}, "YYYY"); got != "/out" {
t.Fatalf("expected bad date to keep base path, got %q", got)
}
assets := []photos.Asset{{ID: "a", MediaType: "image", PixelWidth: 1, PixelHeight: 1}, {ID: "b", MediaType: "image", IsFavorite: true, PixelWidth: 100, PixelHeight: 100}}
if len(applyAssetFilters(assets, exportOptions{media: "all", onlyFavorites: true})) != 1 {
t.Fatal("expected only favorite")
}
if len(applyAssetFilters(assets, exportOptions{media: "videos"})) != 0 {
t.Fatal("expected no videos")
}
if len(applyAssetFilters(assets, exportOptions{media: "all", minSize: 2})) != 1 {
t.Fatal("expected min-size filter")
}
if len(applyAssetFilters(assets, exportOptions{media: "all", maxSize: 2})) != 1 {
t.Fatal("expected max-size filter")
}
var stderr bytes.Buffer
if _, ok := parseExportOptions([]string{"--retry", "2", "--min-size", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--min-size") {
t.Fatal("expected min-size error")
}
stderr.Reset()
if _, ok := parseExportOptions([]string{"--max-size", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--max-size") {
t.Fatal("expected max-size error")
}
stderr.Reset()
opts, ok := parseExportOptions([]string{"--retry", "2", "--min-size", "1", "--max-size", "2"}, &stderr)
if !ok || opts.retry != 2 || opts.minSize != 1 || opts.maxSize != 2 {
t.Fatalf("unexpected opts: %+v ok=%v stderr=%q", opts, ok, stderr.String())
}
}
func TestNewFeatureCommandEdges(t *testing.T) {
oldConfigValues, oldConfigLoaded := configValues, configLoaded
defer func() { configValues, configLoaded = oldConfigValues, oldConfigLoaded }()
configValues, configLoaded = map[string]string{}, true
dir := t.TempDir()
b := &mockBridge{albums: []photos.Album{{ID: "a1", Title: "Album"}}, assets: []photos.Asset{{ID: "x1", Filename: "file.jpg", MediaType: "image"}}}
_, stderr, rc := runWith([]string{"report"}, b)
if rc != exitErr || !strings.Contains(stderr, "--out") {
t.Fatalf("expected report --out error, rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"diff"}, b)
if rc != exitErr || !strings.Contains(stderr, "--album-id") {
t.Fatalf("expected diff arg error, rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"verify"}, b)
if rc != exitErr || !strings.Contains(stderr, "--out") {
t.Fatalf("expected verify --out error, rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"retry-failed"}, b)
if rc != exitErr || !strings.Contains(stderr, "--out") {
t.Fatalf("expected retry --out error, rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"retry-failed", "--out", filepath.Join(dir, "missing")}, b)
if rc != exitErr || stderr == "" {
t.Fatalf("expected missing failures error, rc=%d stderr=%q", rc, stderr)
}
m := manifest.LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Add("x1", "missing.jpg", 1, "local")
m.Add("x2", "", 1, "local")
m.Close()
out, _, rc := runWith([]string{"verify", "--out", dir}, b)
if rc != exitPartial || !strings.Contains(out, "missing.jpg") {
t.Fatalf("expected verify missing, rc=%d out=%q", rc, out)
}
if _, err := loadManifestEntries("/proc/cannot-create", manifest.FormatJSONL); err == nil {
t.Fatal("expected loadManifestEntries open error")
}
b.accessErr = fmt.Errorf("denied")
_, _, rc = runWith([]string{"diff", "--album-id", "Album", "--out", dir}, b)
if rc != exitAuth {
t.Fatalf("expected auth error, got %d", rc)
}
}
func TestExportVerifyJSONAndBackupIncludeVideos(t *testing.T) {
oldConfigValues, oldConfigLoaded := configValues, configLoaded
defer func() { configValues, configLoaded = oldConfigValues, oldConfigLoaded }()
configValues, configLoaded = map[string]string{}, true
dir := t.TempDir()
b := &mockBridge{
albums: []photos.Album{{ID: "a1", Title: "Album"}},
assets: []photos.Asset{{ID: "x1", Filename: "file.jpg", MediaType: "image"}},
tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}, {ID: "a2", Name: "Album", Kind: "album"}},
assetsByAlbum: map[string][]photos.Asset{
"a1": {{ID: "x1", Filename: "file.jpg", MediaType: "image"}},
"a2": {{ID: "x2", Filename: "vid.mov", MediaType: "video"}},
},
}
out, _, rc := runWith([]string{"export", "--album-id", "Album", "--out", dir, "--json", "--verify", "--no-manifest"}, b)
if rc != exitOK || !strings.Contains(out, "exported") {
t.Fatalf("export json rc=%d out=%q", rc, out)
}
out, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--include-videos", "--json", "--verify", "--no-manifest"}, b)
if rc != exitOK || !strings.Contains(out, "exported") || strings.Contains(stderr, "error") {
t.Fatalf("backup json rc=%d out=%q stderr=%q", rc, out, stderr)
}
}
func TestNewFeatureRemainingBranches(t *testing.T) {
oldConfigValues, oldConfigLoaded := configValues, configLoaded
defer func() { configValues, configLoaded = oldConfigValues, oldConfigLoaded }()
configValues, configLoaded = map[string]string{}, true
dir := t.TempDir()
b := &mockBridge{albums: []photos.Album{{ID: "a1", Title: "Album"}}, assets: []photos.Asset{{ID: "x1", Filename: "file.jpg", MediaType: "image"}}}
_, _, rc := runWith([]string{"export", "--album-id", "Album", "--out", dir, "--verify"}, b)
if rc != exitPartial {
t.Fatalf("expected verify partial export, got %d", rc)
}
backupDir := t.TempDir()
_, _, rc = runWith([]string{"backup-all", "--out", backupDir, "--verify"}, &mockBridge{tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, assetsByAlbum: map[string][]photos.Asset{"a1": {{ID: "x1", Filename: "file.jpg"}}}})
if rc != exitPartial {
t.Fatalf("expected verify partial backup, got %d", rc)
}
for _, cmd := range [][]string{
{"report", "--out", dir, "--manifest", "bad"},
{"diff", "--album-id", "Album", "--out", dir, "--manifest", "bad"},
{"verify", "--out", dir, "--manifest", "bad"},
} {
_, stderr, rc := runWith(cmd, b)
if rc != exitErr || !strings.Contains(stderr, "manifest") {
t.Fatalf("expected manifest error for %v, rc=%d stderr=%q", cmd, rc, stderr)
}
}
_, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--media", "bad"}, b)
if rc != exitErr || !strings.Contains(stderr, "--media") {
t.Fatalf("expected backup media error, rc=%d stderr=%q", rc, stderr)
}
for _, cmd := range [][]string{
{"report", "--out", "/proc/cannot-create"},
{"verify", "--out", "/proc/cannot-create"},
{"diff", "--album-id", "Album", "--out", "/proc/cannot-create"},
} {
_, stderr, rc := runWith(cmd, b)
if rc != exitErr || !strings.Contains(stderr, "error:") {
t.Fatalf("expected load error for %v, rc=%d stderr=%q", cmd, rc, stderr)
}
}
m := manifest.LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Add("x1", "file.jpg", 1, "local")
m.Close()
_, _, rc = runWith([]string{"diff", "--album-id", "Album", "--out", dir}, b)
if rc != exitOK {
t.Fatalf("expected clean diff, got %d", rc)
}
badAlbum := &mockBridge{albumsErr: fmt.Errorf("list albums bad"), assetsErr: fmt.Errorf("assets bad")}
_, stderr, rc = runWith([]string{"diff", "--album-id", "missing", "--out", dir}, badAlbum)
if rc != exitErr || !strings.Contains(stderr, "error:") {
t.Fatalf("expected resolve/list error, rc=%d stderr=%q", rc, stderr)
}
badAssets := &mockBridge{albums: []photos.Album{{ID: "a1", Title: "Album"}}, assetsErr: fmt.Errorf("assets bad")}
_, stderr, rc = runWith([]string{"diff", "--album-id", "Album", "--out", dir}, badAssets)
if rc != exitErr || !strings.Contains(stderr, "assets bad") {
t.Fatalf("expected list assets error, rc=%d stderr=%q", rc, stderr)
}
failDir := t.TempDir()
appendFailure(failDir, pendingAsset{asset: photos.Asset{ID: "bad", Filename: "bad.jpg"}, path: failDir, album: "Album"}, fmt.Errorf("boom"))
failing := &mockBridge{exportPreviewFn: func(string, string, int, int, int) (photos.ExportResult, error) {
return photos.ExportResult{}, fmt.Errorf("still bad")
}}
_, _, rc = runWith([]string{"retry-failed", "--out", failDir}, failing)
if rc != exitPartial {
t.Fatalf("expected retry partial, got %d", rc)
}
}
func TestFailuresAndStatusCommands(t *testing.T) {
dir := t.TempDir()
m := manifest.LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.AddEntry(manifest.NewEntry("x1", "file.jpg", "Album/file.jpg", 4, "local"))
m.Close()
if err := os.MkdirAll(filepath.Join(dir, "Album"), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "Album", "file.jpg"), []byte("data"), 0644); err != nil {
t.Fatal(err)
}
appendFailure(dir, pendingAsset{asset: photos.Asset{ID: "bad", Filename: "bad.jpg"}, root: dir, path: filepath.Join(dir, "Album"), album: "Album"}, fmt.Errorf("boom"))
appendFailure(dir, pendingAsset{asset: photos.Asset{ID: "bad", Filename: "bad.jpg"}, root: dir, path: filepath.Join(dir, "Album"), album: "Album"}, fmt.Errorf("boom2"))
out, stderr, rc := runWith([]string{"status", "--out", dir, "--json"}, &mockBridge{})
if rc != exitOK || !strings.Contains(out, "failures") || stderr != "" {
t.Fatalf("status json rc=%d out=%q stderr=%q", rc, out, stderr)
}
out, stderr, rc = runWith([]string{"status", "--out", dir}, &mockBridge{})
if rc != exitOK || !strings.Contains(out, "entries\t1") || !strings.Contains(out, "failures\t1") || stderr != "" {
t.Fatalf("status rc=%d out=%q stderr=%q", rc, out, stderr)
}
out, stderr, rc = runWith([]string{"failures", "list", "--out", dir}, &mockBridge{})
if rc != exitOK || !strings.Contains(out, "bad") || !strings.Contains(out, "2") || stderr != "" {
t.Fatalf("failures list rc=%d out=%q stderr=%q", rc, out, stderr)
}
_, stderr, rc = runWith([]string{"failures"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "requires") {
t.Fatalf("failures missing subcommand rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"failures", "bogus", "--out", dir}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "unknown") {
t.Fatalf("failures bogus rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"failures", "clear", "--out", dir}, &mockBridge{})
if rc != exitOK || stderr != "" {
t.Fatalf("failures clear rc=%d stderr=%q", rc, stderr)
}
if len(loadFailures(dir)) != 0 {
t.Fatal("expected failures cleared")
}
}
func TestAtomicExportHelpers(t *testing.T) {
dir := t.TempDir()
b := &mockBridge{}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
if err := os.WriteFile(filepath.Join(out, "photo.jpg"), []byte("data"), 0644); err != nil {
return photos.ExportResult{}, err
}
return photos.ExportResult{Filename: "photo.jpg", Size: 4, Cloud: "local"}, nil
}
pa := pendingAsset{asset: photos.Asset{ID: "x1", Filename: "photo.jpg"}, root: dir, path: filepath.Join(dir, "Album"), album: "Album"}
result, err := exportOneAtomic(b, pa, 1024, 85, false, 0)
if err != nil || result.Filename != "photo.jpg" {
t.Fatalf("atomic export result=%+v err=%v", result, err)
}
if _, err := os.Stat(filepath.Join(dir, "Album", "photo.jpg")); err != nil {
t.Fatalf("expected final file: %v", err)
}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
if err := os.WriteFile(filepath.Join(out, "empty.jpg"), nil, 0644); err != nil {
return photos.ExportResult{}, err
}
return photos.ExportResult{Filename: "empty.jpg"}, nil
}
if _, err := exportOneAtomic(b, pa, 1024, 85, false, 1); err == nil || !strings.Contains(err.Error(), "zero-byte") {
t.Fatalf("expected zero-byte error, got %v", err)
}
}
func TestMoreIntegrityBranches(t *testing.T) {
dir := t.TempDir()
m := manifest.LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.AddEntry(manifest.Entry{ID: "zero", Filename: "zero.jpg", Path: "zero.jpg", Size: 1, Cloud: "local", Exported: time.Now().Unix()})
m.AddEntry(manifest.Entry{ID: "mismatch", Filename: "mismatch.jpg", Path: "mismatch.jpg", Size: 10, Cloud: "local", Exported: time.Now().Unix()})
m.AddEntry(manifest.Entry{ID: "nosidecar", Filename: "nosidecar.jpg", Path: "nosidecar.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()})
m.AddEntry(manifest.Entry{ID: "zerosidecar", Filename: "zerosidecar.jpg", Path: "zerosidecar.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()})
m.Close()
if err := os.WriteFile(filepath.Join(dir, "zero.jpg"), nil, 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "mismatch.jpg"), []byte("bad"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "nosidecar.jpg"), []byte("data"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "zerosidecar.jpg"), []byte("data"), 0644); err != nil {
t.Fatal(err)
}
out, _, rc := runWith([]string{"verify", "--out", dir}, &mockBridge{})
if rc != exitPartial || !strings.Contains(out, "zero-byte") || !strings.Contains(out, "size-mismatch") {
t.Fatalf("verify rc=%d out=%q", rc, out)
}
if err := os.WriteFile(filepath.Join(dir, "mismatch.xmp"), []byte("wrong asset"), 0644); err != nil {
t.Fatal(err)
}
out, _, rc = runWith([]string{"verify", "--out", dir, "--sidecar"}, &mockBridge{})
if rc != exitPartial || !strings.Contains(out, "sidecar-missing") || !strings.Contains(out, "sidecar-asset-mismatch") {
t.Fatalf("verify sidecar failures rc=%d out=%q", rc, out)
}
if err := os.WriteFile(filepath.Join(dir, "zerosidecar.xmp"), nil, 0644); err != nil {
t.Fatal(err)
}
out, _, rc = runWith([]string{"verify", "--out", dir, "--sidecar"}, &mockBridge{})
if rc != exitPartial || !strings.Contains(out, "sidecar-zero-byte") {
t.Fatalf("verify zero sidecar rc=%d out=%q", rc, out)
}
_, stderr, rc := runWith([]string{"status"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "--out") {
t.Fatalf("status missing out rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"status", "--out", dir, "--manifest", "bad"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "manifest") {
t.Fatalf("status bad manifest rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"status", "--out", "/proc/cannot-create"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "error:") {
t.Fatalf("status load error rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"failures", "list"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "--out") {
t.Fatalf("failures missing out rc=%d stderr=%q", rc, stderr)
}
}
func TestVerifyDeepChecksums(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "good.jpg"), []byte("abc"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "bad.jpg"), []byte("bad"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "nocheck.jpg"), []byte("data"), 0644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(dir, "adir"), 0755); err != nil {
t.Fatal(err)
}
m := manifest.LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.AddEntry(manifest.Entry{ID: "good", Filename: "good.jpg", Path: "good.jpg", Size: 3, Cloud: "local", Checksum: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", Exported: time.Now().Unix()})
m.AddEntry(manifest.Entry{ID: "bad", Filename: "bad.jpg", Path: "bad.jpg", Size: 3, Cloud: "local", Checksum: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", Exported: time.Now().Unix()})
m.AddEntry(manifest.Entry{ID: "nocheck", Filename: "nocheck.jpg", Path: "nocheck.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()})
m.AddEntry(manifest.Entry{ID: "unreadable", Filename: "adir", Path: "adir", Cloud: "local", Checksum: "abc", Exported: time.Now().Unix()})
m.Close()
out, stderr, rc := runWith([]string{"verify", "--out", dir, "--deep"}, &mockBridge{})
if rc != exitPartial || stderr != "" || !strings.Contains(out, "checksum-mismatch") || !strings.Contains(out, "checksum-unreadable") || strings.Contains(out, "good.jpg") || strings.Contains(out, "nocheck") {
t.Fatalf("deep verify rc=%d out=%q stderr=%q", rc, out, stderr)
}
out, stderr, rc = runWith([]string{"verify", "--out", dir}, &mockBridge{})
if rc != exitOK || stderr != "" || strings.Contains(out, "checksum") {
t.Fatalf("plain verify rc=%d out=%q stderr=%q", rc, out, stderr)
}
}
func TestManifestRepair(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("abc"), 0644); err != nil {
t.Fatal(err)
}
m := manifest.LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.AddEntry(manifest.Entry{ID: "x1", Filename: "photo.jpg", Path: "photo.jpg", Size: 0, Cloud: "local", Exported: time.Now().Unix()})
m.AddEntry(manifest.Entry{ID: "missing", Filename: "missing.jpg", Path: "missing.jpg", Size: 0, Cloud: "local", Exported: time.Now().Unix()})
m.Close()
out, stderr, rc := runWith([]string{"manifest", "repair", "--out", dir, "--checksum", "sha256", "--dry-run"}, &mockBridge{})
if rc != exitOK || stderr != "" || !strings.Contains(out, "x1\tphoto.jpg\trepaired") || !strings.Contains(out, "skipped\t1") {
t.Fatalf("dry repair rc=%d out=%q stderr=%q", rc, out, stderr)
}
if got := manifest.LoadJSONL(dir).Entries()["x1"].Checksum; got != "" {
t.Fatalf("dry run wrote checksum %q", got)
}
out, stderr, rc = runWith([]string{"manifest", "repair", "--out", dir, "--checksum", "sha256"}, &mockBridge{})
if rc != exitOK || stderr != "" || !strings.Contains(out, "repaired\t1") {
t.Fatalf("repair rc=%d out=%q stderr=%q", rc, out, stderr)
}
entry := manifest.LoadJSONL(dir).Entries()["x1"]
if entry.Size != 3 || entry.Checksum != "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" {
t.Fatalf("entry not repaired: %+v", entry)
}
out, stderr, rc = runWith([]string{"manifest", "repair", "--out", dir, "--checksum", "sha256"}, &mockBridge{})
if rc != exitOK || stderr != "" || !strings.Contains(out, "repaired\t0") {
t.Fatalf("second repair rc=%d out=%q stderr=%q", rc, out, stderr)
}
}
func TestManifestRepairErrors(t *testing.T) {
dir := t.TempDir()
_, stderr, rc := runWith([]string{"manifest"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "expected manifest repair") {
t.Fatalf("manifest missing subcommand rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"manifest", "repair"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "--out") {
t.Fatalf("manifest repair missing out rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"manifest", "repair", "--out", dir, "--checksum", "bad"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "--checksum") {
t.Fatalf("manifest repair bad checksum rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"manifest", "repair", "--out", dir, "--manifest", "bad"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "manifest") {
t.Fatalf("manifest repair bad manifest rc=%d stderr=%q", rc, stderr)
}
fileOut := filepath.Join(dir, "file-out")
if err := os.WriteFile(fileOut, []byte("x"), 0644); err != nil {
t.Fatal(err)
}
_, stderr, rc = runWith([]string{"manifest", "repair", "--out", fileOut}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "error:") {
t.Fatalf("manifest repair open append rc=%d stderr=%q", rc, stderr)
}
badDBDir := t.TempDir()
if err := os.WriteFile(manifest.SQLitePath(badDBDir), []byte("not sqlite"), 0644); err != nil {
t.Fatal(err)
}
_, stderr, rc = runWith([]string{"manifest", "repair", "--out", badDBDir}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "error:") {
t.Fatalf("manifest repair open rc=%d stderr=%q", rc, stderr)
}
saveDir := t.TempDir()
sm := manifest.LoadJSONL(saveDir)
if err := sm.OpenAppend(); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(saveDir, "photo.jpg"), []byte("abc"), 0644); err != nil {
t.Fatal(err)
}
sm.AddEntry(manifest.Entry{ID: "x1", Filename: "photo.jpg", Path: "photo.jpg", Exported: time.Now().Unix()})
sm.Close()
oldHook := manifest.SetJSONLSaveHook(func() error { return fmt.Errorf("save") })
_, stderr, rc = runWith([]string{"manifest", "repair", "--out", saveDir}, &mockBridge{})
manifest.SetJSONLSaveHook(oldHook)
if rc != exitErr || !strings.Contains(stderr, "save") {
t.Fatalf("manifest repair save rc=%d stderr=%q", rc, stderr)
}
}
func TestManifestRepairBranches(t *testing.T) {
dir := t.TempDir()
m := manifest.LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.AddEntry(manifest.Entry{ID: "empty"})
m.AddEntry(manifest.Entry{ID: "stat-only", Filename: "missing.jpg", Path: "missing.jpg", Checksum: "", Exported: time.Now().Unix()})
m.AddEntry(manifest.Entry{ID: "hash-fail", Filename: "hash.jpg", Path: "hash.jpg", Checksum: "", Exported: time.Now().Unix()})
m.Close()
oldStat := statFunc
statFunc = func(path string) (os.FileInfo, error) {
if strings.HasSuffix(path, "hash.jpg") {
return testFileInfo{size: 3}, nil
}
return oldStat(path)
}
out, stderr, rc := runWith([]string{"manifest", "repair", "--out", dir, "--checksum", "sha256", "--dry-run"}, &mockBridge{})
statFunc = oldStat
if rc != exitOK || stderr != "" || !strings.Contains(out, "skipped\t3") {
t.Fatalf("manifest repair branches rc=%d out=%q stderr=%q", rc, out, stderr)
}
}
func TestCleanup(t *testing.T) {
dir := t.TempDir()
for _, path := range []string{"keep.jpg", "keep.xmp", "keep.json", "orphan.jpg", "export.log", "failures.jsonl", filepath.Join(".photoscli", "geocode-cache.jsonl")} {
full := filepath.Join(dir, path)
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte("data"), 0644); err != nil {
t.Fatal(err)
}
}
m := manifest.LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.AddEntry(manifest.Entry{ID: "x1", Filename: "keep.jpg", Path: "keep.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()})
m.AddEntry(manifest.Entry{ID: "fallback", Filename: "fallback.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()})
m.AddEntry(manifest.Entry{ID: "empty", Exported: time.Now().Unix()})
m.AddEntry(manifest.Entry{ID: "badrel", Filename: "../bad.jpg", Path: "../bad.jpg", Exported: time.Now().Unix()})
m.Close()
if err := os.WriteFile(filepath.Join(dir, "fallback.jpg"), []byte("data"), 0644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(dir, "subdir"), 0755); err != nil {
t.Fatal(err)
}
out, stderr, rc := runWith([]string{"cleanup", "--out", dir, "--dry-run"}, &mockBridge{})
if rc != exitOK || stderr != "" || !strings.Contains(out, "orphan.jpg\torphan") || !strings.Contains(out, "removed\t1") {
t.Fatalf("cleanup dry rc=%d out=%q stderr=%q", rc, out, stderr)
}
if _, err := os.Stat(filepath.Join(dir, "orphan.jpg")); err != nil {
t.Fatalf("dry-run removed orphan: %v", err)
}
out, stderr, rc = runWith([]string{"cleanup", "--out", dir}, &mockBridge{})
if rc != exitOK || stderr != "" || !strings.Contains(out, "removed\t1") {
t.Fatalf("cleanup rc=%d out=%q stderr=%q", rc, out, stderr)
}
if _, err := os.Stat(filepath.Join(dir, "orphan.jpg")); !os.IsNotExist(err) {
t.Fatalf("orphan still exists or bad error: %v", err)
}
for _, path := range []string{"keep.jpg", "keep.xmp", "keep.json", "fallback.jpg", "downloads.jsonl", "export.log", "failures.jsonl", filepath.Join(".photoscli", "geocode-cache.jsonl")} {
if _, err := os.Stat(filepath.Join(dir, path)); err != nil {
t.Fatalf("kept file missing %s: %v", path, err)
}
}
}
func TestCleanupErrors(t *testing.T) {
dir := t.TempDir()
_, stderr, rc := runWith([]string{"cleanup"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "--out") {
t.Fatalf("cleanup missing out rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"cleanup", "--out", dir, "--manifest", "bad"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "manifest") {
t.Fatalf("cleanup bad manifest rc=%d stderr=%q", rc, stderr)
}
badDBDir := t.TempDir()
if err := os.WriteFile(manifest.SQLitePath(badDBDir), []byte("not sqlite"), 0644); err != nil {
t.Fatal(err)
}
_, stderr, rc = runWith([]string{"cleanup", "--out", badDBDir, "--manifest", "sqlite"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "error:") {
t.Fatalf("cleanup load error rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"cleanup", "--out", filepath.Join(dir, "missing")}, &mockBridge{})
if rc != exitOK || stderr != "" {
t.Fatalf("cleanup missing root rc=%d stderr=%q", rc, stderr)
}
dir = t.TempDir()
oldRemove := removeFunc
removeFunc = func(string) error { return fmt.Errorf("remove") }
if err := os.WriteFile(filepath.Join(dir, "orphan.jpg"), []byte("data"), 0644); err != nil {
t.Fatal(err)
}
out, stderr, rc := runWith([]string{"cleanup", "--out", dir}, &mockBridge{})
removeFunc = oldRemove
if rc != exitOK || stderr != "" || !strings.Contains(out, "skipped\t1") {
t.Fatalf("cleanup remove error rc=%d out=%q stderr=%q", rc, out, stderr)
}
}
func TestVerifySidecarBranches(t *testing.T) {
dir := t.TempDir()
subdir := filepath.Join(dir, "sub")
if err := os.Mkdir(subdir, 0755); err != nil {
t.Fatal(err)
}
xmp := filepath.Join(dir, "asset.xmp")
if err := os.WriteFile(xmp, []byte(`photoscli:assetID="x1"`), 0644); err != nil {
t.Fatal(err)
}
oldRead := readFileFunc
readFileFunc = func(string) ([]byte, error) { return nil, fmt.Errorf("read") }
var out bytes.Buffer
if got := verifySidecar(&out, subdir, "x1", "../asset.jpg", false); got != 1 || !strings.Contains(out.String(), "sidecar-unreadable") {
t.Fatalf("expected unreadable with rel fallback, got=%d out=%q", got, out.String())
}
readFileFunc = oldRead
}
func TestVerifySidecarStrict(t *testing.T) {
dir := t.TempDir()
media := filepath.Join(dir, "photo.jpg")
if err := os.WriteFile(media, []byte("data"), 0644); err != nil {
t.Fatal(err)
}
if err := writeXMPSidecar(sidecarPath(media), xmpSidecarData{AssetID: "x1", ExportedFilename: "photo.jpg"}); err != nil {
t.Fatal(err)
}
var out bytes.Buffer
if got := verifySidecar(&out, dir, "x1", "photo.jpg", true); got != 0 || out.Len() != 0 {
t.Fatalf("strict valid got=%d out=%q", got, out.String())
}
if err := os.WriteFile(sidecarPath(media), []byte(`photoscli:assetID="x1"`), 0644); err != nil {
t.Fatal(err)
}
out.Reset()
if got := verifySidecar(&out, dir, "x1", "photo.jpg", true); got != 1 || !strings.Contains(out.String(), "sidecar-schema-missing") {
t.Fatalf("strict schema got=%d out=%q", got, out.String())
}
if err := os.WriteFile(sidecarPath(media), []byte(`<?xpacket begin=""?><x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description xmlns:photoscli="https://gitea.k3s.k0.nu/tools/photocli/ns/1.0/" photoscli:assetID="x1" photoscli:xmpSchemaVersion="2" photoscli:exportedFilename="photo.jpg" /></rdf:RDF></x:xmpmeta>`), 0644); err != nil {
t.Fatal(err)
}
out.Reset()
if got := verifySidecar(&out, dir, "x1", "photo.jpg", true); got != 1 || !strings.Contains(out.String(), "sidecar-generator-missing") {
t.Fatalf("strict generator got=%d out=%q", got, out.String())
}
if err := os.WriteFile(sidecarPath(media), []byte(`<?xpacket begin=""?><x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description xmlns:photoscli="https://gitea.k3s.k0.nu/tools/photocli/ns/1.0/" photoscli:assetID="x1" photoscli:xmpSchemaVersion="2" photoscli:sidecarGenerator="photoscli" photoscli:exportedFilename="other.jpg" /></rdf:RDF></x:xmpmeta>`), 0644); err != nil {
t.Fatal(err)
}
out.Reset()
if got := verifySidecar(&out, dir, "x1", "photo.jpg", true); got != 1 || !strings.Contains(out.String(), "sidecar-filename-mismatch") {
t.Fatalf("strict filename got=%d out=%q", got, out.String())
}
}
func TestSidecarInspect(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "photo.xmp")
if err := writeXMPSidecar(path, xmpSidecarData{AssetID: "x1", ExportedFilename: "photo.jpg", Album: "Trips"}); err != nil {
t.Fatal(err)
}
out, stderr, rc := runWith([]string{"sidecar", "inspect", path}, &mockBridge{})
if rc != exitOK || stderr != "" || !strings.Contains(out, "assetID\tx1") || !strings.Contains(out, "album\tTrips") {
t.Fatalf("inspect rc=%d out=%q stderr=%q", rc, out, stderr)
}
out, stderr, rc = runWith([]string{"sidecar", "inspect", path, "--json"}, &mockBridge{})
if rc != exitOK || stderr != "" || !strings.Contains(out, `"assetID":"x1"`) || !strings.Contains(out, `"exportedFilename":"photo.jpg"`) {
t.Fatalf("inspect json rc=%d out=%q stderr=%q", rc, out, stderr)
}
_, stderr, rc = runWith([]string{"sidecar"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "expected sidecar inspect") {
t.Fatalf("inspect missing subcommand rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"sidecar", "inspect"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "requires <file.xmp>") {
t.Fatalf("inspect missing path rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"sidecar", "inspect", filepath.Join(dir, "missing.xmp")}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "error:") {
t.Fatalf("inspect missing file rc=%d stderr=%q", rc, stderr)
}
plain := filepath.Join(dir, "plain.xmp")
if err := os.WriteFile(plain, []byte(`<x:xmpmeta xmlns:x="adobe:ns:meta/"></x:xmpmeta>`), 0644); err != nil {
t.Fatal(err)
}
_, stderr, rc = runWith([]string{"sidecar", "inspect", plain}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "no photoscli metadata") {
t.Fatalf("inspect no metadata rc=%d stderr=%q", rc, stderr)
}
bad := inspectXMP([]byte(`<x:xmpmeta><rdf:RDF>`))
if len(bad) != 0 {
t.Fatalf("expected empty metadata on malformed XML, got %#v", bad)
}
stderrBuf := &bytes.Buffer{}
if rc := cmdSidecar([]string{"inspect", path, "--json"}, errWriter{}, stderrBuf); rc != exitErr || !strings.Contains(stderrBuf.String(), "error:") {
t.Fatalf("expected json encoder error rc=%d stderr=%q", rc, stderrBuf.String())
}
}
func TestXMPSidecarHelpers(t *testing.T) {
if got := sidecarPath("/tmp/IMG_0001.HEIC"); got != "/tmp/IMG_0001.xmp" {
t.Fatalf("sidecar path = %q", got)
}
xmp := string(renderXMP(xmpSidecarData{
AssetID: `id&<>"`,
OriginalFilename: "IMG_0001.HEIC",
ExportedFilename: "IMG_0001.jpg",
Album: "A&B",
AlbumPath: "/tmp/A&B",
Keywords: []string{"A&B", "Trips"},
ManifestPath: "A&B/IMG_0001.jpg",
MediaType: "image",
MediaSubtypes: []string{"photoLive", `hdr&<>"`},
SourceType: "userLibrary",
PlaybackStyle: "livePhoto",
PixelWidth: 10,
PixelHeight: 20,
Duration: 1.25,
IsFavorite: true,
IsHidden: true,
HasAdjustments: true,
Cloud: "local",
ExportMode: "preview",
PhotoscliVersion: "test",
ExportedAt: "2026-01-01T00:00:00Z",
Size: 123,
CreateDate: "2024-01-01T00:00:00Z",
ModifyDate: "2024-02-01T00:00:00Z",
Location: &photos.AssetLocation{Latitude: 59.3293, Longitude: 18.0686, Altitude: 10, HorizontalAccuracy: 5},
Placemark: &photos.Placemark{Country: "Sweden", CountryCode: "SE", Locality: "Stockholm", FormattedAddress: "Stockholm, Sweden", AreasOfInterest: []string{"Gamla stan"}},
BurstIdentifier: "burst1",
RepresentsBurst: true,
BurstSelectionTypes: []string{"autoPick"},
AdjustmentInfo: &photos.AdjustmentInfo{FormatIdentifier: "com.apple", FormatVersion: "1.0", BaseFilename: "base.heic"},
Resources: []photos.AssetResource{{Type: "photo", Filename: `res&.heic`, UTI: "public.heic", Local: true, Size: 99}},
}))
for _, want := range []string{"photoscli:xmpSchemaVersion=\"2\"", "photoscli:assetID=\"id&amp;&lt;&gt;&#34;\"", "photoscli:isFavorite=\"true\"", "xmp:Rating=\"5\"", "photoscli:hasAdjustments=\"true\"", "xmp:ModifyDate=\"2024-02-01T00:00:00Z\"", "photoshop:DateCreated=\"2024-01-01T00:00:00Z\"", "exif:GPSLatitude=\"59.32930000\"", "photoscli:latitude=\"59.32930000\"", "photoscli:addressCountry=\"Sweden\"", "photoscli:adjustmentFormatIdentifier=\"com.apple\"", "<dc:subject><rdf:Seq>", "<rdf:li>A&amp;B</rdf:li>", "<photoscli:resources><rdf:Seq>", "photoscli:resourceFilename=\"res&amp;.heic\"", "<photoscli:mediaSubtypes><rdf:Seq>"} {
if !strings.Contains(xmp, want) {
t.Fatalf("XMP missing %q in %s", want, xmp)
}
}
}
func TestKeywordsFromAlbumPath(t *testing.T) {
got := keywordsFromAlbumPath("Album", "Trips/Album/2024")
want := []string{"Album", "Trips", "2024"}
if len(got) != len(want) {
t.Fatalf("keywords len=%d want=%d: %#v", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("keywords[%d]=%q want %q in %#v", i, got[i], want[i], got)
}
}
if got := keywordsFromAlbumPath("", "."); len(got) != 0 {
t.Fatalf("expected no dot keyword, got %#v", got)
}
}
func TestWriteXMPSidecar(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "photo.xmp")
if err := writeXMPSidecar(path, xmpSidecarData{AssetID: "x1", ExportedFilename: "photo.jpg"}); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "photoscli:assetID=\"x1\"") {
t.Fatalf("unexpected xmp: %s", string(data))
}
badParent := filepath.Join(t.TempDir(), "file")
if err := os.WriteFile(badParent, []byte("x"), 0644); err != nil {
t.Fatal(err)
}
if err := writeXMPSidecar(filepath.Join(badParent, "bad.xmp"), xmpSidecarData{}); err == nil {
t.Fatal("expected mkdir error")
}
}
func TestWriteJSONSidecar(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "photo.json")
if got := jsonSidecarPath(filepath.Join(dir, "photo.jpg")); got != path {
t.Fatalf("json sidecar path=%q", got)
}
if !sidecarEnabled("xmp,json", "json") || sidecarEnabled("xmp", "json") {
t.Fatal("sidecarEnabled mismatch")
}
if err := writeJSONSidecar(path, xmpSidecarData{AssetID: "x1", ExportedFilename: "photo.jpg"}); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), `"AssetID": "x1"`) {
t.Fatalf("unexpected json sidecar: %s", string(data))
}
badParent := filepath.Join(t.TempDir(), "file")
if err := os.WriteFile(badParent, []byte("x"), 0644); err != nil {
t.Fatal(err)
}
if err := writeJSONSidecar(filepath.Join(badParent, "bad.json"), xmpSidecarData{}); err == nil {
t.Fatal("expected mkdir error")
}
oldCreate := createTempFunc
createTempFunc = func(string, string) (*os.File, error) { return nil, fmt.Errorf("create") }
if err := writeJSONSidecar(path, xmpSidecarData{}); err == nil {
t.Fatal("expected create temp error")
}
createTempFunc = oldCreate
oldWrite := writeFileFunc
writeFileFunc = func(string, []byte, os.FileMode) error { return fmt.Errorf("write") }
if err := writeJSONSidecar(path, xmpSidecarData{}); err == nil {
t.Fatal("expected write error")
}
writeFileFunc = oldWrite
oldRename := renameFunc
renameFunc = func(string, string) error { return fmt.Errorf("rename") }
if err := writeJSONSidecar(path, xmpSidecarData{}); err == nil {
t.Fatal("expected rename error")
}
renameFunc = oldRename
}
func TestSidecarExportIntegration(t *testing.T) {
dir := t.TempDir()
date := "2024-01-02T03:04:05Z"
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "orig&.HEIC", MediaType: "image", PixelWidth: 10, PixelHeight: 20, IsFavorite: true, CreationDate: &date}}}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
if err := os.WriteFile(filepath.Join(out, "photo.jpg"), []byte("data"), 0644); err != nil {
return photos.ExportResult{}, err
}
return photos.ExportResult{Filename: "photo.jpg", Size: 4, Cloud: "local"}, nil
}
exported, failed := exportAssets(b.assets, dir, 1024, 85, 3, false, 1, io.Discard, b, "Album", false, manifest.FormatJSONL, false, exportOptions{sidecar: "xmp,json"})
if exported != 1 || failed != 0 {
t.Fatalf("exported=%d failed=%d", exported, failed)
}
data, err := os.ReadFile(filepath.Join(dir, "photo.xmp"))
if err != nil {
t.Fatal(err)
}
content := string(data)
for _, want := range []string{"photoscli:assetID=\"x1\"", "photoscli:originalFilename=\"orig&amp;.HEIC\"", "photoscli:album=\"Album\"", "xmp:CreateDate=\"2024-01-02T03:04:05Z\""} {
if !strings.Contains(content, want) {
t.Fatalf("sidecar missing %q in %s", want, content)
}
}
if _, err := os.Stat(filepath.Join(dir, "photo.jpg.xmp")); !os.IsNotExist(err) {
t.Fatal("sidecar should use basename, not double extension")
}
jsonData, err := os.ReadFile(filepath.Join(dir, "photo.json"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(jsonData), `"AssetID": "x1"`) {
t.Fatalf("json sidecar missing asset ID: %s", string(jsonData))
}
}
func TestSidecarReverseGeocodeCache(t *testing.T) {
dir := t.TempDir()
geoCalls := 0
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "geo.jpg", Location: &photos.AssetLocation{Latitude: 59.3293, Longitude: 18.0686}}}}
b.reverseGeocodeFn = func(lat, lon float64) (photos.Placemark, error) {
geoCalls++
return photos.Placemark{Country: "Sweden", Locality: "Stockholm", FormattedAddress: "Stockholm, Sweden"}, nil
}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
name := fmt.Sprintf("photo%d.jpg", geoCalls)
if err := os.WriteFile(filepath.Join(out, name), []byte("data"), 0644); err != nil {
return photos.ExportResult{}, err
}
return photos.ExportResult{Filename: name, Size: 4, Cloud: "local"}, nil
}
for i := 0; i < 2; i++ {
exported, failed := exportAssets(b.assets, dir, 1024, 85, 1, false, 1, io.Discard, b, "Album", true, manifest.FormatJSONL, false, exportOptions{sidecar: "xmp", reverseGeocode: true})
if exported != 1 || failed != 0 {
t.Fatalf("run %d exported=%d failed=%d", i, exported, failed)
}
}
if geoCalls != 1 {
t.Fatalf("expected cached geocode after first call, got %d", geoCalls)
}
data, err := os.ReadFile(filepath.Join(dir, "photo1.xmp"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "photoscli:addressCity=\"Stockholm\"") || !strings.Contains(string(data), "photoscli:reverseGeocoder=\"MapKit\"") {
t.Fatalf("missing geocode fields: %s", string(data))
}
if _, err := os.Stat(filepath.Join(dir, ".photoscli", "geocode-cache.jsonl")); err != nil {
t.Fatalf("missing geocode cache: %v", err)
}
}
func TestGeocodeCacheBranches(t *testing.T) {
dir := t.TempDir()
cache := newGeocodeCache(dir)
if got := cache.lookup(1, 2, &mockBridge{reverseGeocodeFn: func(float64, float64) (photos.Placemark, error) {
return photos.Placemark{}, fmt.Errorf("offline")
}}); got != nil {
t.Fatalf("expected nil on geocode error, got %+v", got)
}
if got := (*geocodeCache)(nil).lookup(1, 2, &mockBridge{}); got != nil {
t.Fatalf("expected nil cache lookup, got %+v", got)
}
oldOpen := openFileFunc
openFileFunc = func(string, int, os.FileMode) (*os.File, error) { return nil, fmt.Errorf("open") }
got := cache.lookup(3, 4, &mockBridge{reverseGeocodeFn: func(float64, float64) (photos.Placemark, error) {
return photos.Placemark{Country: "Nowhere"}, nil
}})
openFileFunc = oldOpen
if got == nil || got.Country != "Nowhere" {
t.Fatalf("expected placemark despite cache write error, got %+v", got)
}
}
func TestSidecarReverseGeocodeWithoutCache(t *testing.T) {
dir := t.TempDir()
pa := pendingAsset{asset: photos.Asset{ID: "x1", Filename: "geo.jpg", Location: &photos.AssetLocation{Latitude: 1, Longitude: 2}}, root: dir, path: dir}
if err := writeSidecarIfNeeded(pa, photos.ExportResult{Filename: "geo.jpg", Size: 1}, false, exportOptions{sidecar: "xmp", reverseGeocode: true}, nil, &mockBridge{}); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dir, "geo.xmp"))
if err != nil {
t.Fatal(err)
}
content := string(data)
if !strings.Contains(content, "photoscli:latitude=\"1.00000000\"") || strings.Contains(content, "photoscli:reverseGeocoder") {
t.Fatalf("unexpected reverse geocode content: %s", content)
}
}
func TestSidecarModificationDate(t *testing.T) {
dir := t.TempDir()
modified := "2024-03-04T05:06:07Z"
pa := pendingAsset{asset: photos.Asset{ID: "x1", Filename: "mod.jpg", ModificationDate: &modified}, path: dir}
if err := writeSidecarIfNeeded(pa, photos.ExportResult{Filename: "mod.jpg", Size: 1}, false, exportOptions{sidecar: "xmp"}, nil, &mockBridge{}); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dir, "mod.xmp"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "xmp:ModifyDate=\"2024-03-04T05:06:07Z\"") {
t.Fatalf("missing modify date: %s", string(data))
}
}
func TestExportPendingReverseGeocodeNoPending(t *testing.T) {
bar := newProgressBar(io.Discard, 1)
done, failed := exportPending(nil, 1024, 85, false, 0, bar, &mockBridge{}, nil, 1, manifest.NoopLogWriter, exportOptions{sidecar: "xmp", reverseGeocode: true})
if done != 0 || failed != 0 {
t.Fatalf("done=%d failed=%d", done, failed)
}
}
func TestExportPendingGeocodeCacheRootFallback(t *testing.T) {
dir := t.TempDir()
a := photos.Asset{ID: "g1", Filename: "geo.jpg", Location: &photos.AssetLocation{Latitude: 1, Longitude: 2}}
b := &mockBridge{assets: []photos.Asset{a}, reverseGeocodeFn: func(float64, float64) (photos.Placemark, error) {
return photos.Placemark{Country: "Sweden"}, nil
}}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
if err := os.WriteFile(filepath.Join(out, "geo.jpg"), []byte("data"), 0644); err != nil {
return photos.ExportResult{}, err
}
return photos.ExportResult{Filename: "geo.jpg", Size: 4}, nil
}
bar := newProgressBar(io.Discard, 1)
done, failed := exportPending([]pendingAsset{{asset: a, path: dir}}, 1024, 85, false, 1, bar, b, nil, 1, manifest.NoopLogWriter, exportOptions{sidecar: "xmp", reverseGeocode: true})
if done != 1 || failed != 0 {
t.Fatalf("done=%d failed=%d", done, failed)
}
if _, err := os.Stat(filepath.Join(dir, ".photoscli", "geocode-cache.jsonl")); err != nil {
t.Fatalf("expected fallback-root cache: %v", err)
}
}
func TestExportPendingCreatesGeocodeCacheForParallel(t *testing.T) {
dir := t.TempDir()
assets := []photos.Asset{
{ID: "g1", Filename: "one.jpg", Location: &photos.AssetLocation{Latitude: 1, Longitude: 2}},
{ID: "g2", Filename: "two.jpg", Location: &photos.AssetLocation{Latitude: 3, Longitude: 4}},
{ID: "g3", Filename: "three.jpg", Location: &photos.AssetLocation{Latitude: 5, Longitude: 6}},
{ID: "g4", Filename: "four.jpg", Location: &photos.AssetLocation{Latitude: 7, Longitude: 8}},
}
pending := make([]pendingAsset, len(assets))
for i, a := range assets {
pending[i] = pendingAsset{asset: a, root: dir, path: dir, album: "Geo"}
}
b := &mockBridge{assets: assets, reverseGeocodeFn: func(float64, float64) (photos.Placemark, error) {
return photos.Placemark{Country: "Sweden"}, nil
}}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
name := assetID + ".jpg"
if err := os.WriteFile(filepath.Join(out, name), []byte("data"), 0644); err != nil {
return photos.ExportResult{}, err
}
return photos.ExportResult{Filename: name, Size: 4}, nil
}
bar := newProgressBar(io.Discard, 4)
done, failed := exportPending(pending, 1024, 85, false, len(pending), bar, b, nil, 4, manifest.NoopLogWriter, exportOptions{sidecar: "xmp", reverseGeocode: true})
if done != len(pending) || failed != 0 {
t.Fatalf("done=%d failed=%d", done, failed)
}
if _, err := os.Stat(filepath.Join(dir, ".photoscli", "geocode-cache.jsonl")); err != nil {
t.Fatalf("expected geocode cache: %v", err)
}
}
func TestSidecarConfigAndErrors(t *testing.T) {
oldConfigValues, oldConfigLoaded := configValues, configLoaded
defer func() { configValues, configLoaded = oldConfigValues, oldConfigLoaded }()
dir := t.TempDir()
cfg := filepath.Join(dir, "config.toml")
if err := os.WriteFile(cfg, []byte("sidecar = \"xmp\"\n"), 0644); err != nil {
t.Fatal(err)
}
t.Setenv("PHOTOSCLI_CONFIG", cfg)
configValues, configLoaded = nil, false
opts, ok := parseExportOptions(nil, io.Discard)
if !ok || opts.sidecar != "xmp" {
t.Fatalf("expected sidecar config, opts=%+v ok=%v", opts, ok)
}
var stderr bytes.Buffer
if _, ok := parseExportOptions([]string{"--sidecar", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--sidecar") {
t.Fatalf("expected sidecar validation error, stderr=%q", stderr.String())
}
stderr.Reset()
if opts, ok := parseExportOptions([]string{"--sidecar", "json"}, &stderr); !ok || opts.sidecar != "json" || stderr.Len() != 0 {
t.Fatalf("expected json sidecar option, opts=%+v ok=%v stderr=%q", opts, ok, stderr.String())
}
stderr.Reset()
if _, ok := parseExportOptions([]string{"--sidecar", "xmp,bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--sidecar") {
t.Fatalf("expected mixed sidecar validation error, stderr=%q", stderr.String())
}
stderr.Reset()
if _, ok := parseExportOptions([]string{"--xmp-privacy", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--xmp-privacy") {
t.Fatalf("expected xmp privacy validation error, stderr=%q", stderr.String())
}
stderr.Reset()
if _, ok := parseExportOptions([]string{"--xmp-keywords", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--xmp-keywords") {
t.Fatalf("expected xmp keywords validation error, stderr=%q", stderr.String())
}
stderr.Reset()
if _, ok := parseExportOptions([]string{"--xmp-rating", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--xmp-rating") {
t.Fatalf("expected xmp rating validation error, stderr=%q", stderr.String())
}
stderr.Reset()
if _, ok := parseExportOptions([]string{"--checksum", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--checksum") {
t.Fatalf("expected checksum validation error, stderr=%q", stderr.String())
}
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "photo.jpg"}}}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
return photos.ExportResult{Filename: "photo.jpg", Size: 4}, nil
}
oldRename := renameFunc
renameFunc = func(string, string) error { return fmt.Errorf("sidecar rename") }
exported, failed := exportAssets(b.assets, dir, 1024, 85, 3, false, 1, io.Discard, b, "", false, manifest.FormatJSONL, false, exportOptions{sidecar: "json"})
if exported != 0 || failed != 1 {
t.Fatalf("expected json sidecar failure, exported=%d failed=%d", exported, failed)
}
exported, failed = exportAssets(b.assets, dir, 1024, 85, 3, false, 1, io.Discard, b, "", false, manifest.FormatJSONL, false, exportOptions{sidecar: "xmp"})
renameFunc = oldRename
if exported != 0 || failed != 1 {
t.Fatalf("expected xmp sidecar failure, exported=%d failed=%d", exported, failed)
}
}
func TestXMPSidecarPrivacy(t *testing.T) {
dir := t.TempDir()
asset := photos.Asset{ID: "x1", Filename: "geo.jpg", Location: &photos.AssetLocation{Latitude: 59.3293, Longitude: 18.0686}}
bridge := &mockBridge{}
bridge.reverseGeocodeFn = func(float64, float64) (photos.Placemark, error) {
return photos.Placemark{Country: "Sweden", Locality: "Stockholm"}, nil
}
pa := pendingAsset{asset: asset, root: dir, path: dir, album: "Album"}
for _, tc := range []struct {
privacy string
wantGPS bool
wantAddress bool
}{
{privacy: "keep", wantGPS: true, wantAddress: true},
{privacy: "strip-address", wantGPS: true, wantAddress: false},
{privacy: "strip-location", wantGPS: false, wantAddress: false},
} {
path := filepath.Join(dir, tc.privacy+".jpg")
if err := os.WriteFile(path, []byte("data"), 0644); err != nil {
t.Fatal(err)
}
if err := writeSidecarIfNeeded(pa, photos.ExportResult{Filename: filepath.Base(path), Size: 4}, false, exportOptions{sidecar: "xmp", reverseGeocode: true, xmpPrivacy: tc.privacy}, newGeocodeCache(dir), bridge); err != nil {
t.Fatalf("%s write sidecar: %v", tc.privacy, err)
}
data, err := os.ReadFile(sidecarPath(path))
if err != nil {
t.Fatal(err)
}
content := string(data)
if strings.Contains(content, "photoscli:latitude") != tc.wantGPS {
t.Fatalf("%s GPS presence mismatch in %s", tc.privacy, content)
}
if strings.Contains(content, "photoscli:addressCountry") != tc.wantAddress {
t.Fatalf("%s address presence mismatch in %s", tc.privacy, content)
}
}
}
func TestXMPSidecarKeywordAndRatingOptions(t *testing.T) {
dir := t.TempDir()
asset := photos.Asset{ID: "x1", Filename: "photo.jpg", IsFavorite: true}
pa := pendingAsset{asset: asset, root: dir, path: filepath.Join(dir, "Trips", "Beach"), album: "Beach"}
if err := os.MkdirAll(pa.path, 0755); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
keywords string
rating string
wantTrip bool
wantBeach bool
wantRate bool
}{
{name: "default", wantTrip: true, wantBeach: true, wantRate: true},
{name: "album", keywords: "album", wantTrip: false, wantBeach: true, wantRate: true},
{name: "none", keywords: "none", rating: "none", wantTrip: false, wantBeach: false, wantRate: false},
} {
filename := tc.name + ".jpg"
path := filepath.Join(pa.path, filename)
if err := os.WriteFile(path, []byte("data"), 0644); err != nil {
t.Fatal(err)
}
if err := writeSidecarIfNeeded(pa, photos.ExportResult{Filename: filename, Size: 4}, false, exportOptions{sidecar: "xmp", xmpKeywords: tc.keywords, xmpRating: tc.rating}, nil, &mockBridge{}); err != nil {
t.Fatalf("%s write sidecar: %v", tc.name, err)
}
data, err := os.ReadFile(sidecarPath(path))
if err != nil {
t.Fatal(err)
}
content := string(data)
if strings.Contains(content, "<rdf:li>Trips</rdf:li>") != tc.wantTrip {
t.Fatalf("%s Trips keyword mismatch in %s", tc.name, content)
}
if strings.Contains(content, "<rdf:li>Beach</rdf:li>") != tc.wantBeach {
t.Fatalf("%s Beach keyword mismatch in %s", tc.name, content)
}
if strings.Contains(content, "xmp:Rating=\"5\"") != tc.wantRate {
t.Fatalf("%s rating mismatch in %s", tc.name, content)
}
}
}
func TestMetadataOnlyExport(t *testing.T) {
dir := t.TempDir()
m := manifest.LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.AddEntry(manifest.Entry{ID: "x1", Filename: "photo.jpg", Path: "photo.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()})
m.Close()
if err := os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("data"), 0644); err != nil {
t.Fatal(err)
}
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "orig.heic", MediaType: "image", IsFavorite: true}}}
b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) {
t.Fatal("metadata-only must not export media")
return photos.ExportResult{}, nil
}
out, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--sidecar", "xmp", "--metadata-only"}, b)
if rc != exitOK || out != "" || !strings.Contains(stderr, "wrote 1 metadata sidecars") {
t.Fatalf("metadata-only rc=%d out=%q stderr=%q", rc, out, stderr)
}
data, err := os.ReadFile(filepath.Join(dir, "photo.xmp"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "photoscli:assetID=\"x1\"") || !strings.Contains(string(data), "xmp:Rating=\"5\"") {
t.Fatalf("unexpected metadata sidecar: %s", string(data))
}
}
func TestMetadataOnlyExportErrors(t *testing.T) {
dir := t.TempDir()
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "photo.jpg"}}}
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--metadata-only"}, b)
if rc != exitErr || !strings.Contains(stderr, "--metadata-only requires --sidecar") {
t.Fatalf("expected sidecar requirement rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"export", "--album-id", "x", "--out", dir, "--sidecar", "xmp", "--metadata-only", "--no-manifest"}, b)
if rc != exitErr || !strings.Contains(stderr, "requires a manifest") {
t.Fatalf("expected manifest requirement rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"backup-all", "--out", dir, "--sidecar", "xmp", "--metadata-only", "--no-manifest"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "requires a manifest") {
t.Fatalf("expected backup manifest requirement rc=%d stderr=%q", rc, stderr)
}
}
func TestMetadataOnlyHelperBranches(t *testing.T) {
dir := t.TempDir()
pa := pendingAsset{asset: photos.Asset{ID: "x1", Filename: "photo.jpg"}, path: dir}
if err := writeMetadataOnlySidecar(pa, manifest.Entry{ID: "x1"}, false, exportOptions{sidecar: "xmp"}, nil, &mockBridge{}); err == nil || !strings.Contains(err.Error(), "no path") {
t.Fatalf("expected no path error, got %v", err)
}
if err := writeMetadataOnlySidecar(pa, manifest.Entry{ID: "x1", Path: "missing.jpg"}, false, exportOptions{sidecar: "xmp"}, nil, &mockBridge{}); err == nil || !strings.Contains(err.Error(), "missing") {
t.Fatalf("expected missing error, got %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "zero.jpg"), nil, 0644); err != nil {
t.Fatal(err)
}
if err := writeMetadataOnlySidecar(pa, manifest.Entry{ID: "x1", Path: "zero.jpg"}, false, exportOptions{sidecar: "xmp"}, nil, &mockBridge{}); err == nil || !strings.Contains(err.Error(), "zero-byte") {
t.Fatalf("expected zero-byte error, got %v", err)
}
if entries := manifestEntries(noEntryManifest{}); entries != nil {
t.Fatalf("expected nil entries, got %#v", entries)
}
written, failed := metadataOnlyPending([]pendingAsset{{asset: photos.Asset{ID: "x1"}, path: dir}}, map[string]manifest.Entry{"x1": {ID: "x1", Path: "missing.jpg"}}, false, exportOptions{sidecar: "xmp"}, &mockBridge{})
if written != 0 || failed != 1 {
t.Fatalf("expected failed metadata pending, written=%d failed=%d", written, failed)
}
if err := os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("data"), 0644); err != nil {
t.Fatal(err)
}
written, failed = metadataOnlyPending([]pendingAsset{{asset: photos.Asset{ID: "x2", Location: &photos.AssetLocation{Latitude: 1, Longitude: 2}}, path: dir}}, map[string]manifest.Entry{"x2": {ID: "x2", Path: "photo.jpg"}}, false, exportOptions{sidecar: "xmp", reverseGeocode: true}, &mockBridge{reverseGeocodeFn: func(float64, float64) (photos.Placemark, error) {
return photos.Placemark{Country: "Sweden"}, nil
}})
if written != 1 || failed != 0 {
t.Fatalf("expected reverse geocode metadata success, written=%d failed=%d", written, failed)
}
}
func TestMetadataOnlyBackupAll(t *testing.T) {
dir := t.TempDir()
m := manifest.LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.AddEntry(manifest.Entry{ID: "x1", Filename: "photo.jpg", Path: "Album/photo.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()})
m.Close()
albumDir := filepath.Join(dir, "Album")
if err := os.Mkdir(albumDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(albumDir, "photo.jpg"), []byte("data"), 0644); err != nil {
t.Fatal(err)
}
b := &mockBridge{
tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}},
assetsByAlbum: map[string][]photos.Asset{"a1": {{ID: "x1", Filename: "orig.heic", MediaType: "image"}, {ID: "x2", Filename: "missing.heic", MediaType: "image"}}},
}
b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) {
t.Fatal("metadata-only backup-all must not export media")
return photos.ExportResult{}, nil
}
_, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--sidecar", "xmp", "--metadata-only"}, b)
if rc != exitOK || !strings.Contains(stderr, "wrote 1 metadata sidecars") {
t.Fatalf("metadata-only backup rc=%d stderr=%q", rc, stderr)
}
if _, err := os.Stat(filepath.Join(albumDir, "photo.xmp")); err != nil {
t.Fatalf("expected metadata-only sidecar: %v", err)
}
}
func TestSidecarAdditionalBranches(t *testing.T) {
dir := t.TempDir()
oldCreateTemp := createTempFunc
createTempFunc = func(string, string) (*os.File, error) { return nil, fmt.Errorf("createtemp") }
if err := writeXMPSidecar(filepath.Join(dir, "bad.xmp"), xmpSidecarData{}); err == nil || !strings.Contains(err.Error(), "createtemp") {
t.Fatalf("expected create temp error, got %v", err)
}
createTempFunc = oldCreateTemp
oldWriteFile := writeFileFunc
writeFileFunc = func(string, []byte, os.FileMode) error { return fmt.Errorf("writefile") }
if err := writeXMPSidecar(filepath.Join(dir, "badwrite.xmp"), xmpSidecarData{}); err == nil || !strings.Contains(err.Error(), "writefile") {
t.Fatalf("expected write file error, got %v", err)
}
writeFileFunc = oldWriteFile
pa := pendingAsset{asset: photos.Asset{ID: "x1", Filename: "orig.HEIC"}, path: dir, album: "Album"}
if err := writeSidecarIfNeeded(pa, photos.ExportResult{Filename: "out.jpg", Size: 1, Cloud: "local"}, true, exportOptions{sidecar: "xmp"}, nil, &mockBridge{}); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dir, "out.xmp"))
if err != nil {
t.Fatal(err)
}
content := string(data)
if !strings.Contains(content, "photoscli:exportMode=\"original\"") || !strings.Contains(content, "photoscli:manifestPath=\"out.jpg\"") {
t.Fatalf("unexpected sidecar: %s", content)
}
otherRoot := filepath.Join(dir, "other")
if err := writeSidecarIfNeeded(pendingAsset{asset: photos.Asset{ID: "x2"}, root: otherRoot, path: dir}, photos.ExportResult{Filename: "fallback.jpg"}, false, exportOptions{sidecar: "xmp"}, nil, &mockBridge{}); err != nil {
t.Fatal(err)
}
data, err = os.ReadFile(filepath.Join(dir, "fallback.xmp"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "photoscli:manifestPath=\"fallback.jpg\"") {
t.Fatalf("expected fallback manifest path, got %s", string(data))
}
}
func TestParallelSidecarExport(t *testing.T) {
dir := t.TempDir()
assets := []photos.Asset{
{ID: "x1", Filename: "one.jpg"},
{ID: "x2", Filename: "two.jpg"},
{ID: "x3", Filename: "three.jpg"},
{ID: "x4", Filename: "four.jpg"},
}
b := &mockBridge{assets: assets}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
name := fmt.Sprintf("%s.jpg", assetID)
if err := os.WriteFile(filepath.Join(out, name), []byte("data"), 0644); err != nil {
return photos.ExportResult{}, err
}
return photos.ExportResult{Filename: name, Size: 4, Cloud: "local"}, nil
}
exported, failed := exportAssets(assets, dir, 1024, 85, 4, false, len(assets), io.Discard, b, "", true, manifest.FormatJSONL, false, exportOptions{sidecar: "xmp"})
if exported != 4 || failed != 0 {
t.Fatalf("exported=%d failed=%d", exported, failed)
}
if _, err := os.Stat(filepath.Join(dir, "x4.xmp")); err != nil {
t.Fatalf("expected parallel sidecar: %v", err)
}
}
func TestParallelSidecarFailure(t *testing.T) {
dir := t.TempDir()
assets := []photos.Asset{{ID: "x1", Filename: "one.jpg"}, {ID: "x2", Filename: "two.jpg"}, {ID: "x3", Filename: "three.jpg"}, {ID: "x4", Filename: "four.jpg"}}
b := &mockBridge{assets: assets}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
return photos.ExportResult{Filename: assetID + ".jpg", Size: 4, Cloud: "local"}, nil
}
oldWriteFile := writeFileFunc
writeFileFunc = func(string, []byte, os.FileMode) error { return fmt.Errorf("parallel sidecar") }
exported, failed := exportAssets(assets, dir, 1024, 85, 4, false, len(assets), io.Discard, b, "", true, manifest.FormatJSONL, false, exportOptions{sidecar: "xmp"})
writeFileFunc = oldWriteFile
if exported != 0 || failed != 4 {
t.Fatalf("expected sidecar failures, exported=%d failed=%d", exported, failed)
}
}
func TestRetryFailedClearOnSuccess(t *testing.T) {
dir := t.TempDir()
appendFailure(dir, pendingAsset{asset: photos.Asset{ID: "bad", Filename: "bad.jpg"}, root: dir, path: dir, album: "Album"}, fmt.Errorf("boom"))
out, _, rc := runWith([]string{"retry-failed", "--out", dir, "--clear-on-success"}, &mockBridge{})
if rc != exitOK || !strings.Contains(out, "exported") {
t.Fatalf("retry clear rc=%d out=%q", rc, out)
}
if len(loadFailures(dir)) != 0 {
t.Fatal("expected successful retry to clear failure")
}
}
func TestAtomicSlotExportHelper(t *testing.T) {
dir := t.TempDir()
b := &mockBridge{}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
if err := os.WriteFile(filepath.Join(out, "slot.jpg"), []byte("data"), 0644); err != nil {
return photos.ExportResult{}, err
}
return photos.ExportResult{Filename: "slot.jpg", Size: 4, Cloud: "local"}, nil
}
pa := pendingAsset{asset: photos.Asset{ID: "slot", Filename: "slot.jpg"}, root: dir, path: filepath.Join(dir, "Album"), album: "Album"}
result, err := exportOneWithSlotAtomic(b, pa, 1024, 85, false, 0, 0)
if err != nil || result.Filename != "slot.jpg" {
t.Fatalf("slot atomic result=%+v err=%v", result, err)
}
}
func TestInjectedErrorBranchesForCoverage(t *testing.T) {
dir := t.TempDir()
b := &mockBridge{}
pa := pendingAsset{asset: photos.Asset{ID: "x", Filename: "x.jpg"}, root: dir, path: filepath.Join(dir, "Album")}
oldMkdirTemp := mkdirTempFunc
mkdirTempFunc = func(string, string) (string, error) { return "", fmt.Errorf("mkdirtemp") }
if _, err := exportOneAtomic(b, pa, 1024, 85, false, 0); err == nil || !strings.Contains(err.Error(), "mkdirtemp") {
t.Fatalf("expected mkdirtemp error, got %v", err)
}
mkdirTempFunc = oldMkdirTemp
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
if err := os.WriteFile(filepath.Join(out, "file.jpg"), []byte("data"), 0644); err != nil {
return photos.ExportResult{}, err
}
return photos.ExportResult{Filename: "file.jpg", Size: 4}, nil
}
oldRename := renameFunc
renameFunc = func(string, string) error { return fmt.Errorf("rename") }
if _, err := exportOneAtomic(b, pa, 1024, 85, false, 0); err == nil || !strings.Contains(err.Error(), "rename") {
t.Fatalf("expected rename error, got %v", err)
}
renameFunc = oldRename
oldOpen := openFileFunc
openFileFunc = func(string, int, os.FileMode) (*os.File, error) { return nil, fmt.Errorf("open") }
if err := saveFailures(dir, map[string]failureEntry{"x": {ID: "x"}}); err == nil || !strings.Contains(err.Error(), "open") {
t.Fatalf("expected open error, got %v", err)
}
openFileFunc = oldOpen
oldRemove := removeFunc
removeFunc = func(string) error { return fmt.Errorf("remove") }
_, stderr, rc := runWith([]string{"failures", "clear", "--out", dir}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "remove") {
t.Fatalf("expected remove error, rc=%d stderr=%q", rc, stderr)
}
removeFunc = oldRemove
mf := &mockManifest{}
addManifestEntry(mf, pendingAsset{asset: photos.Asset{ID: "x"}, root: filepath.Join(dir, "other"), path: dir}, photos.ExportResult{Filename: "file.jpg", Size: 1}, "")
if mf.last.Path != "file.jpg" {
t.Fatalf("expected fallback rel path, got %+v", mf.last)
}
badPathRoot := t.TempDir()
badPath := filepath.Join(badPathRoot, "notdir")
if err := os.WriteFile(badPath, []byte("x"), 0644); err != nil {
t.Fatal(err)
}
pa = pendingAsset{asset: photos.Asset{ID: "x2", Filename: "x2.jpg"}, root: badPathRoot, path: badPath}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
if err := os.WriteFile(filepath.Join(out, "file.jpg"), []byte("data"), 0644); err != nil {
return photos.ExportResult{}, err
}
return photos.ExportResult{Filename: "file.jpg", Size: 4}, nil
}
if _, err := exportOneAtomic(b, pa, 1024, 85, false, 0); err == nil {
t.Fatal("expected final mkdir error")
}
slotRootFile := filepath.Join(t.TempDir(), "rootfile")
if err := os.WriteFile(slotRootFile, []byte("x"), 0644); err != nil {
t.Fatal(err)
}
pa = pendingAsset{asset: photos.Asset{ID: "slotfallback", Filename: "slot.jpg"}, root: slotRootFile, path: t.TempDir()}
if _, err := exportOneWithSlotAtomic(b, pa, 1024, 85, true, 0, 0); err != nil {
t.Fatalf("unexpected original fallback error: %v", err)
}
if _, err := exportOneWithSlotAtomic(b, pa, 1024, 85, false, 0, 0); err != nil {
t.Fatalf("unexpected preview fallback error: %v", err)
}
pa = pendingAsset{asset: photos.Asset{ID: "slotzero", Filename: "slot.jpg"}, root: dir, path: filepath.Join(dir, "Slot")}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
if err := os.WriteFile(filepath.Join(out, "empty.jpg"), nil, 0644); err != nil {
return photos.ExportResult{}, err
}
return photos.ExportResult{Filename: "empty.jpg"}, nil
}
if _, err := exportOneWithSlotAtomic(b, pa, 1024, 85, false, 0, 0); err == nil || !strings.Contains(err.Error(), "zero-byte") {
t.Fatalf("expected slot zero-byte error, got %v", err)
}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
if err := os.WriteFile(filepath.Join(out, "file.jpg"), []byte("data"), 0644); err != nil {
return photos.ExportResult{}, err
}
return photos.ExportResult{Filename: "file.jpg", Size: 4}, nil
}
renameFunc = func(string, string) error { return fmt.Errorf("slot rename") }
if _, err := exportOneWithSlotAtomic(b, pa, 1024, 85, false, 0, 0); err == nil || !strings.Contains(err.Error(), "slot rename") {
t.Fatalf("expected slot rename error, got %v", err)
}
renameFunc = oldRename
mkdirTempFunc = func(string, string) (string, error) { return "", fmt.Errorf("slot mkdirtemp") }
if _, err := exportOneWithSlotAtomic(b, pa, 1024, 85, false, 0, 0); err == nil || !strings.Contains(err.Error(), "slot mkdirtemp") {
t.Fatalf("expected slot mkdirtemp error, got %v", err)
}
mkdirTempFunc = oldMkdirTemp
badSlotRoot := t.TempDir()
badSlotPath := filepath.Join(badSlotRoot, "notdir")
if err := os.WriteFile(badSlotPath, []byte("x"), 0644); err != nil {
t.Fatal(err)
}
pa = pendingAsset{asset: photos.Asset{ID: "slotbadpath", Filename: "slot.jpg"}, root: badSlotRoot, path: badSlotPath}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
if err := os.WriteFile(filepath.Join(out, "file.jpg"), []byte("data"), 0644); err != nil {
return photos.ExportResult{}, err
}
return photos.ExportResult{Filename: "file.jpg", Size: 4}, nil
}
if _, err := exportOneWithSlotAtomic(b, pa, 1024, 85, false, 0, 0); err == nil {
t.Fatal("expected slot final mkdir error")
}
}
type mockManifest struct{ last manifest.Entry }
func (m *mockManifest) Has(string) bool { return false }
func (m *mockManifest) Add(id string, filename string, size int64, cloud string) {
m.last = manifest.NewEntry(id, filename, filename, size, cloud)
}
func (m *mockManifest) AddEntry(e manifest.Entry) { m.last = e }
func (m *mockManifest) Save() error { return nil }
func (m *mockManifest) Close() {}
func (m *mockManifest) OpenAppend() error { return nil }