Files
photocli/internal/photos/photos_test.go
T
Ein Anderssono 85eaa3ea37 v0.2.0: semaphore timeouts, error logging, dead code removal, parallel exports
Critical:
- Replace DISPATCH_TIME_FOREVER with 120s/30s timeouts in ObjC
- Log failed asset IDs and error messages in cmdExport/backupTree
- Show failed count in export summaries

Cleanup:
- Remove legacy Bridge methods (ExportAlbumPreviews, ExportAlbumOriginals, BackupAll)
- Remove legacy ObjC functions and C stub equivalents
- Remove photos.go delegates (package-level pass-throughs)
- Remove InterpretExportResult (only used by legacy methods)
- Clean up mockBridge fields (rename Fn2 -> Fn)
- Fix rc race condition in main_main.go (atomic.Int32)
- Remove unused variables (_ = grandTotal, _ = sig)

Design:
- Fix resolveAlbumID: ListAlbums first (cheap), then direct ID
- Unify Cloud type: Asset.Cloud string (was bool)
- Extract shared export logic into exportAssets/exportOne
- Add worker pool for parallel exports (3 workers when assets >= 4)
- Fix backupTree progress bar counter and directory prefix

Robustness:
- Add nil checks for stringWithUTF8String: in ObjC
- Log directory creation errors in ensure_directory (ObjC)

Quality:
- Add go vet and -race flag to Makefile test target
- Add ADR for performSelector cloudIdentifier decision
- Add sync comments between Go/ObjC sanitizePathComponent
- Add package-level doc comment
- Add tests: partial failure, skipped album, album-not-found message
2026-06-11 21:12:47 +02:00

441 lines
12 KiB
Go

//go:build test
package photos
import (
"encoding/json"
"testing"
)
func TestParseAlbumsJSON(t *testing.T) {
tests := []struct {
name string
json string
want []Album
wantErr bool
}{
{
name: "empty albums",
json: `{"albums":[]}`,
want: []Album{},
},
{
name: "single album",
json: `{"albums":[{"id":"abc","title":"Vacation"}]}`,
want: []Album{{ID: "abc", Title: "Vacation"}},
},
{
name: "multiple albums",
json: `{"albums":[{"id":"a1","title":"Album1"},{"id":"a2","title":"Album2"}]}`,
want: []Album{{ID: "a1", Title: "Album1"}, {ID: "a2", Title: "Album2"}},
},
{
name: "invalid json",
json: `not json`,
wantErr: true,
},
{
name: "missing albums key",
json: `{}`,
want: []Album{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseAlbumsJSON(tt.json)
if (err != nil) != tt.wantErr {
t.Errorf("ParseAlbumsJSON() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr {
return
}
if len(got) != len(tt.want) {
t.Errorf("ParseAlbumsJSON() got %d albums, want %d", len(got), len(tt.want))
return
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("ParseAlbumsJSON()[%d] = %+v, want %+v", i, got[i], tt.want[i])
}
}
})
}
}
func TestParseAssetsJSON(t *testing.T) {
tests := []struct {
name string
json string
want []Asset
wantTotal int
wantErr bool
errMsg string
}{
{
name: "empty assets",
json: `{"assets":[],"total":0}`,
want: []Asset{},
wantTotal: 0,
},
{
name: "single asset",
json: `{"assets":[{"id":"asset1","filename":"IMG_0001.JPG"}],"total":1}`,
want: []Asset{{ID: "asset1", Filename: "IMG_0001.JPG"}},
wantTotal: 1,
},
{
name: "multiple assets",
json: `{"assets":[{"id":"a1","filename":"a.jpg"},{"id":"a2","filename":"b.jpg"},{"id":"a3","filename":"c.jpg"}],"total":3}`,
want: []Asset{{ID: "a1", Filename: "a.jpg"}, {ID: "a2", Filename: "b.jpg"}, {ID: "a3", Filename: "c.jpg"}},
wantTotal: 3,
},
{
name: "error response",
json: `{"error":"album not found"}`,
wantErr: true,
errMsg: "album not found",
},
{
name: "invalid json",
json: `not json`,
wantErr: true,
},
{
name: "empty error is not an error",
json: `{"error":"","assets":[{"id":"a1","filename":"IMG.JPG"}],"total":1}`,
want: []Asset{{ID: "a1", Filename: "IMG.JPG"}},
wantTotal: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, total, err := ParseAssetsJSON(tt.json)
if (err != nil) != tt.wantErr {
t.Errorf("ParseAssetsJSON() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr {
if tt.errMsg != "" && err.Error() != tt.errMsg {
t.Errorf("ParseAssetsJSON() error = %q, want %q", err.Error(), tt.errMsg)
}
return
}
if total != tt.wantTotal {
t.Errorf("ParseAssetsJSON() total = %d, want %d", total, tt.wantTotal)
}
if len(got) != len(tt.want) {
t.Errorf("ParseAssetsJSON() got %d assets, want %d", len(got), len(tt.want))
return
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("ParseAssetsJSON()[%d] = %+v, want %+v", i, got[i], tt.want[i])
}
}
})
}
}
func TestParseTreeJSON(t *testing.T) {
tests := []struct {
name string
json string
want []CollectionNode
wantErr bool
errMsg string
}{
{
name: "empty tree",
json: `{"collections":[]}`,
want: []CollectionNode{},
},
{
name: "nested tree",
json: `{"collections":[{"id":"f1","name":"Trips","kind":"folder","children":[{"id":"f2","name":"Italy 2024","kind":"folder","children":[{"id":"a1","name":"Venice","kind":"album","children":[]}]}]},{"id":"a2","name":"Favorites","kind":"album","children":[]}]}`,
want: []CollectionNode{
{ID: "f1", Name: "Trips", Kind: "folder", Children: []CollectionNode{{ID: "f2", Name: "Italy 2024", Kind: "folder", Children: []CollectionNode{{ID: "a1", Name: "Venice", Kind: "album"}}}}},
{ID: "a2", Name: "Favorites", Kind: "album"},
},
},
{
name: "error response",
json: `{"error":"library unavailable"}`,
wantErr: true,
errMsg: "library unavailable",
},
{
name: "invalid json",
json: `not json`,
wantErr: true,
},
{
name: "missing collections key",
json: `{}`,
want: nil,
},
{
name: "empty error is not an error",
json: `{"error":"","collections":[{"id":"a9","name":"Root Album","kind":"album"}]}`,
want: []CollectionNode{{ID: "a9", Name: "Root Album", Kind: "album"}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseTreeJSON(tt.json)
if (err != nil) != tt.wantErr {
t.Errorf("ParseTreeJSON() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr {
if tt.errMsg != "" && err.Error() != tt.errMsg {
t.Errorf("ParseTreeJSON() error = %q, want %q", err.Error(), tt.errMsg)
}
return
}
if len(got) != len(tt.want) {
t.Errorf("ParseTreeJSON() got %d collections, want %d", len(got), len(tt.want))
return
}
for i := range got {
if !equalCollectionNode(got[i], tt.want[i]) {
t.Errorf("ParseTreeJSON()[%d] = %+v, want %+v", i, got[i], tt.want[i])
}
}
})
}
}
func TestAlbumsResponseUnmarshal(t *testing.T) {
raw := `{"albums":[{"id":"x","title":"Y"}]}`
var resp AlbumsResponse
if err := json.Unmarshal([]byte(raw), &resp); err != nil {
t.Fatal(err)
}
if len(resp.Albums) != 1 || resp.Albums[0].ID != "x" || resp.Albums[0].Title != "Y" {
t.Errorf("got %+v", resp.Albums)
}
}
func TestAssetsResponseUnmarshal(t *testing.T) {
raw := `{"assets":[{"id":"z","filename":"IMG_9999.JPG"}],"total":1}`
var resp AssetsResponse
if err := json.Unmarshal([]byte(raw), &resp); err != nil {
t.Fatal(err)
}
if len(resp.Assets) != 1 || resp.Assets[0].ID != "z" || resp.Assets[0].Filename != "IMG_9999.JPG" {
t.Errorf("got %+v", resp.Assets)
}
if resp.Total != 1 {
t.Errorf("got total %d, want 1", resp.Total)
}
}
func TestTreeResponseUnmarshal(t *testing.T) {
raw := `{"collections":[{"id":"f1","name":"Trips","kind":"folder","children":[{"id":"a1","name":"Venice","kind":"album"}]}]}`
var resp TreeResponse
if err := json.Unmarshal([]byte(raw), &resp); err != nil {
t.Fatal(err)
}
if len(resp.Collections) != 1 || resp.Collections[0].ID != "f1" || resp.Collections[0].Name != "Trips" || len(resp.Collections[0].Children) != 1 || resp.Collections[0].Children[0].ID != "a1" || resp.Collections[0].Children[0].Name != "Venice" {
t.Errorf("got %+v", resp.Collections)
}
}
func TestErrorResponseUnmarshal(t *testing.T) {
raw := `{"error":"oops"}`
var resp ErrorResponse
if err := json.Unmarshal([]byte(raw), &resp); err != nil {
t.Fatal(err)
}
if resp.Error != "oops" {
t.Errorf("got %q", resp.Error)
}
}
func TestCgoBridgeImplementsBridge(t *testing.T) {
var _ Bridge = &CgoBridge{}
}
func TestCgoBridgeListAlbumsViaStub(t *testing.T) {
SetTestAlbumsJSON(`{"albums":[{"id":"abc","title":"TestAlbum"}]}`)
bridge := &CgoBridge{}
albums, err := bridge.ListAlbums()
if err != nil {
t.Fatal(err)
}
if len(albums) != 1 || albums[0].ID != "abc" || albums[0].Title != "TestAlbum" {
t.Errorf("got %+v", albums)
}
}
func TestCgoBridgeListAssetsViaStub(t *testing.T) {
SetTestAssetsJSON(`{"assets":[{"id":"asset-1","filename":"A.JPG"},{"id":"asset-2","filename":"B.JPG"}],"total":2}`)
bridge := &CgoBridge{}
assets, _, err := bridge.ListAssets("any")
if err != nil {
t.Fatal(err)
}
if len(assets) != 2 {
t.Errorf("got %d assets", len(assets))
}
if assets[0].Filename != "A.JPG" || assets[1].Filename != "B.JPG" {
t.Errorf("got %+v", assets)
}
}
func TestCgoBridgeListAssetsErrorViaStub(t *testing.T) {
SetTestAssetsJSON(`{"error":"album not found"}`)
bridge := &CgoBridge{}
_, _, err := bridge.ListAssets("bad-id")
if err == nil || err.Error() != "album not found" {
t.Errorf("got %v", err)
}
}
func TestCgoBridgeListTreeViaStub(t *testing.T) {
SetTestTreeJSON(`{"collections":[{"id":"f1","name":"Trips","kind":"folder","children":[{"id":"f2","name":"Italy 2024","kind":"folder","children":[{"id":"a1","name":"Venice","kind":"album"}]}]},{"id":"a2","name":"Favorites","kind":"album"}]}`)
bridge := &CgoBridge{}
tree, err := bridge.ListTree()
if err != nil {
t.Fatal(err)
}
if len(tree) != 2 {
t.Errorf("got %d collections", len(tree))
}
if tree[0].ID != "f1" || tree[0].Name != "Trips" || len(tree[0].Children) != 1 || tree[0].Children[0].ID != "f2" || tree[0].Children[0].Name != "Italy 2024" {
t.Errorf("got %+v", tree)
}
if tree[1].ID != "a2" || tree[1].Name != "Favorites" || tree[1].Kind != "album" {
t.Errorf("got %+v", tree)
}
}
func TestCgoBridgeListTreeNil(t *testing.T) {
SetTestTreeNull()
defer SetTestTreeJSON(`{"collections":[]}`)
bridge := &CgoBridge{}
_, err := bridge.ListTree()
if err != errBridgeNil {
t.Errorf("got %v, want %v", err, errBridgeNil)
}
}
func TestCgoBridgeRequestAccessGranted(t *testing.T) {
SetTestAccessRC(0)
bridge := &CgoBridge{}
if err := bridge.RequestAccess(); err != nil {
t.Errorf("got %v", err)
}
}
func TestCgoBridgeRequestAccessDenied(t *testing.T) {
SetTestAccessRC(-1)
bridge := &CgoBridge{}
err := bridge.RequestAccess()
if err == nil {
t.Fatal("expected error")
}
if err != errAccessDenied {
t.Errorf("got %v, want %v", err, errAccessDenied)
}
}
func TestCgoBridgeListAlbumsNil(t *testing.T) {
SetTestAlbumsNull()
defer SetTestAlbumsJSON(`{"albums":[]}`)
bridge := &CgoBridge{}
_, err := bridge.ListAlbums()
if err != errBridgeNil {
t.Errorf("got %v, want %v", err, errBridgeNil)
}
}
func TestCgoBridgeListAssetsNil(t *testing.T) {
SetTestAssetsNull()
defer SetTestAssetsJSON(`{"assets":[],"total":0}`)
bridge := &CgoBridge{}
_, _, err := bridge.ListAssets("any")
if err != errBridgeNil {
t.Errorf("got %v, want %v", err, errBridgeNil)
}
}
func TestErrAccessDeniedMessage(t *testing.T) {
if errAccessDenied.Error() == "" {
t.Error("errAccessDenied should have a message")
}
}
func TestErrBridgeNilMessage(t *testing.T) {
if errBridgeNil.Error() == "" {
t.Error("errBridgeNil should have a message")
}
}
func TestParseExportResultJSON(t *testing.T) {
tests := []struct {
name string
json string
want ExportResult
wantErr bool
errMsg string
}{
{
name: "success",
json: `{"filename":"0000_test.jpg","size":1024,"cloud":"local"}`,
want: ExportResult{Filename: "0000_test.jpg", Size: 1024, Cloud: "local"},
},
{
name: "cloud asset",
json: `{"filename":"photo.jpg","size":2048,"cloud":"cloud"}`,
want: ExportResult{Filename: "photo.jpg", Size: 2048, Cloud: "cloud"},
},
{
name: "error response",
json: `{"error":"write failed","cloud":"local"}`,
wantErr: true,
errMsg: "write failed",
},
{
name: "invalid json",
json: `not json`,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseExportResultJSON(tt.json)
if (err != nil) != tt.wantErr {
t.Errorf("ParseExportResultJSON() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr {
if tt.errMsg != "" && err.Error() != tt.errMsg {
t.Errorf("ParseExportResultJSON() error = %q, want %q", err.Error(), tt.errMsg)
}
return
}
if got != tt.want {
t.Errorf("ParseExportResultJSON() = %+v, want %+v", got, tt.want)
}
})
}
}
func equalCollectionNode(a, b CollectionNode) bool {
if a.ID != b.ID || a.Name != b.Name || a.Kind != b.Kind || len(a.Children) != len(b.Children) {
return false
}
for i := range a.Children {
if !equalCollectionNode(a.Children[i], b.Children[i]) {
return false
}
}
return true
}