85eaa3ea37
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
441 lines
12 KiB
Go
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
|
|
}
|