4992 lines
170 KiB
Go
4992 lines
170 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 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 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 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 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&<>"\"", "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&B</rdf:li>", "<photoscli:resources><rdf:Seq>", "photoscli:resourceFilename=\"res&.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 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"})
|
|
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&.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")
|
|
}
|
|
}
|
|
|
|
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 _, 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())
|
|
}
|
|
|
|
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: "xmp"})
|
|
renameFunc = oldRename
|
|
if exported != 0 || failed != 1 {
|
|
t.Fatalf("expected 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 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 xmp") {
|
|
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 }
|