package photos import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/ultisuite/ulti-backend/internal/api/query" "github.com/ultisuite/ulti-backend/internal/nextcloud" photospkg "github.com/ultisuite/ulti-backend/internal/photos" ) func sampleAssets() []photospkg.Asset { return []photospkg.Asset{ {ID: "a1", OriginalName: "alpha.jpg", MimeType: "image/jpeg", FileSize: 100, CreatedAt: "2026-01-01T10:00:00Z"}, {ID: "a2", OriginalName: "beta.png", MimeType: "image/png", FileSize: 300, CreatedAt: "2026-01-15T10:00:00Z"}, {ID: "a3", OriginalName: "gamma.jpg", MimeType: "image/jpeg", FileSize: 200, CreatedAt: "2026-02-01T10:00:00Z"}, {ID: "a4", OriginalName: "delta.tiff", MimeType: "image/tiff", FileSize: 50, CreatedAt: "2026-02-10T10:00:00Z"}, } } func sampleAlbums() []photospkg.Album { return []photospkg.Album{ {ID: "al1", Name: "Alpha Album", AssetCount: 5, CreatedAt: "2026-01-01T10:00:00Z"}, {ID: "al2", Name: "Beta Album", AssetCount: 15, CreatedAt: "2026-01-20T10:00:00Z"}, {ID: "al3", Name: "Gamma Album", AssetCount: 8, CreatedAt: "2026-02-05T10:00:00Z"}, } } func mustDate(t *testing.T, raw string) *time.Time { t.Helper() d, err := query.ParseDate(raw) if err != nil { t.Fatalf("ParseDate(%q): %v", raw, err) } return &d } func mustEndDate(t *testing.T, raw string) *time.Time { t.Helper() d, err := query.ParseDate(raw) if err != nil { t.Fatalf("ParseDate(%q): %v", raw, err) } end := d.Add(24*time.Hour - time.Nanosecond) return &end } func TestApplyAssetListPagination(t *testing.T) { params := query.ListParams{Page: 2, PageSize: 2, Sort: "name"} page, total := applyAssetList(sampleAssets(), params) if total != 4 { t.Fatalf("total = %d, want 4", total) } if len(page) != 2 { t.Fatalf("page len = %d, want 2", len(page)) } if page[0].ID != "a4" || page[1].ID != "a3" { t.Fatalf("page ids = %q/%q, want a4/a3", page[0].ID, page[1].ID) } meta := params.Meta(&total) if meta.Page != 2 || meta.PageSize != 2 || meta.Total == nil || *meta.Total != 4 { t.Fatalf("meta = %+v", meta) } } func TestApplyAssetListPaginationBeyondRange(t *testing.T) { params := query.ListParams{Page: 10, PageSize: 2} page, total := applyAssetList(sampleAssets(), params) if total != 4 { t.Fatalf("total = %d, want 4", total) } if len(page) != 0 { t.Fatalf("page len = %d, want 0", len(page)) } } func TestApplyAssetListSortByNameAsc(t *testing.T) { params := query.ListParams{Page: 1, PageSize: 10, Sort: "name"} page, _ := applyAssetList(sampleAssets(), params) if len(page) != 4 { t.Fatalf("len = %d", len(page)) } want := []string{"a1", "a2", "a4", "a3"} for i, id := range want { if page[i].ID != id { t.Fatalf("page[%d].ID = %q, want %q", i, page[i].ID, id) } } } func TestApplyAssetListSortBySizeDesc(t *testing.T) { params := query.ListParams{Page: 1, PageSize: 10, Sort: "-size"} page, _ := applyAssetList(sampleAssets(), params) want := []string{"a2", "a3", "a1", "a4"} for i, id := range want { if page[i].ID != id { t.Fatalf("page[%d].ID = %q, want %q", i, page[i].ID, id) } } } func TestApplyAssetListSortByCreatedAtDefaultDesc(t *testing.T) { params := query.ListParams{Page: 1, PageSize: 10} page, _ := applyAssetList(sampleAssets(), params) want := []string{"a4", "a3", "a2", "a1"} for i, id := range want { if page[i].ID != id { t.Fatalf("page[%d].ID = %q, want %q", i, page[i].ID, id) } } } func TestApplyAssetListFilterByQ(t *testing.T) { params := query.ListParams{Page: 1, PageSize: 10, Q: "jpg"} page, total := applyAssetList(sampleAssets(), params) if total != 2 { t.Fatalf("total = %d, want 2", total) } if page[0].ID != "a3" || page[1].ID != "a1" { t.Fatalf("ids = %q/%q", page[0].ID, page[1].ID) } } func TestApplyAssetListFilterByQMimeType(t *testing.T) { params := query.ListParams{Page: 1, PageSize: 10, Q: "tiff"} page, total := applyAssetList(sampleAssets(), params) if total != 1 || page[0].ID != "a4" { t.Fatalf("total=%d page=%+v", total, page) } } func TestApplyAssetListFilterByDateRange(t *testing.T) { params := query.ListParams{ Page: 1, PageSize: 10, From: mustDate(t, "2026-01-10"), To: mustEndDate(t, "2026-02-05"), } page, total := applyAssetList(sampleAssets(), params) if total != 2 { t.Fatalf("total = %d, want 2", total) } if page[0].ID != "a3" || page[1].ID != "a2" { t.Fatalf("ids = %q/%q", page[0].ID, page[1].ID) } } func TestApplyAlbumListPagination(t *testing.T) { params := query.ListParams{Page: 1, PageSize: 2, Sort: "name"} page, total := applyAlbumList(sampleAlbums(), params) if total != 3 || len(page) != 2 { t.Fatalf("total=%d len=%d", total, len(page)) } if page[0].ID != "al1" || page[1].ID != "al2" { t.Fatalf("ids = %q/%q", page[0].ID, page[1].ID) } } func TestApplyAlbumListSortBySizeAsc(t *testing.T) { params := query.ListParams{Page: 1, PageSize: 10, Sort: "size"} page, _ := applyAlbumList(sampleAlbums(), params) want := []string{"al1", "al3", "al2"} for i, id := range want { if page[i].ID != id { t.Fatalf("page[%d].ID = %q, want %q", i, page[i].ID, id) } } } func TestApplyAlbumListFilterByQAndSortCreatedAtDesc(t *testing.T) { params := query.ListParams{Page: 1, PageSize: 10, Q: "album", Sort: "-created_at"} page, total := applyAlbumList(sampleAlbums(), params) if total != 3 { t.Fatalf("total = %d, want 3", total) } if page[0].ID != "al3" { t.Fatalf("first id = %q, want al3", page[0].ID) } } func TestApplyAlbumListFilterByFromDate(t *testing.T) { params := query.ListParams{ Page: 1, PageSize: 10, From: mustDate(t, "2026-01-15"), Sort: "name", } page, total := applyAlbumList(sampleAlbums(), params) if total != 2 { t.Fatalf("total = %d, want 2", total) } if page[0].ID != "al2" || page[1].ID != "al3" { t.Fatalf("ids = %q/%q", page[0].ID, page[1].ID) } } func TestServiceListAssetsUsesPaginationMeta(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/assets" { http.NotFound(w, r) return } _ = json.NewEncoder(w).Encode(sampleAssets()) })) defer server.Close() svc := NewService(photospkg.NewClient(server.URL), nil) result, err := svc.ListAssets(context.Background(), "key", query.ListParams{ Page: 2, PageSize: 2, Sort: "name", }) if err != nil { t.Fatalf("ListAssets: %v", err) } if len(result.Assets) != 2 || result.Page != 2 { t.Fatalf("assets=%d page=%d", len(result.Assets), result.Page) } if result.Pagination.Page != 2 || result.Pagination.PageSize != 2 { t.Fatalf("pagination = %+v", result.Pagination) } if result.Pagination.Total == nil || *result.Pagination.Total != 4 { t.Fatalf("total = %v", result.Pagination.Total) } } func TestServiceListAlbumsUsesPaginationMeta(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/albums" { http.NotFound(w, r) return } _ = json.NewEncoder(w).Encode(sampleAlbums()) })) defer server.Close() svc := NewService(photospkg.NewClient(server.URL), nil) result, err := svc.ListAlbums(context.Background(), "key", query.ListParams{ Page: 1, PageSize: 1, Sort: "-size", }) if err != nil { t.Fatalf("ListAlbums: %v", err) } if len(result.Albums) != 1 || result.Albums[0].ID != "al2" { t.Fatalf("albums = %+v", result.Albums) } if result.Pagination.Total == nil || *result.Pagination.Total != 3 { t.Fatalf("total = %v", result.Pagination.Total) } } func TestServiceGetAlbumNotFound(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })) defer server.Close() svc := NewService(photospkg.NewClient(server.URL), nil) _, err := svc.GetAlbum(context.Background(), "key", "missing") if err == nil || !errors.Is(err, ErrNotFound) { t.Fatalf("GetAlbum err = %v, want ErrNotFound", err) } } func TestServiceCreateAlbum(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/albums" { http.NotFound(w, r) return } w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(photospkg.Album{ID: "new-al", Name: "Created", AssetCount: 1}) })) defer server.Close() svc := NewService(photospkg.NewClient(server.URL), nil) album, err := svc.CreateAlbum(context.Background(), "key", &createAlbumRequest{ Name: "Created", AssetIDs: []string{"a1"}, }) if err != nil { t.Fatalf("CreateAlbum: %v", err) } if album.ID != "new-al" || album.Name != "Created" { t.Fatalf("album = %+v", album) } } func TestServiceUpdateAlbumInvalid(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) })) defer server.Close() svc := NewService(photospkg.NewClient(server.URL), nil) _, err := svc.UpdateAlbum(context.Background(), "key", "al1", &updateAlbumRequest{Name: strPtr("Renamed")}) if err == nil || !errors.Is(err, ErrInvalid) { t.Fatalf("UpdateAlbum err = %v, want ErrInvalid", err) } } func TestServiceDeleteAlbumNotFound(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })) defer server.Close() svc := NewService(photospkg.NewClient(server.URL), nil) err := svc.DeleteAlbum(context.Background(), "key", "missing") if err == nil || !errors.Is(err, ErrNotFound) { t.Fatalf("DeleteAlbum err = %v, want ErrNotFound", err) } } func TestServiceAddAlbumAssets(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut || r.URL.Path != "/albums/al1/assets" { http.NotFound(w, r) return } _ = json.NewEncoder(w).Encode([]photospkg.BulkAssetResult{{ID: "a1", Success: true}}) })) defer server.Close() svc := NewService(photospkg.NewClient(server.URL), nil) result, err := svc.AddAlbumAssets(context.Background(), "key", "al1", []string{"a1"}) if err != nil { t.Fatalf("AddAlbumAssets: %v", err) } if len(result.Results) != 1 || !result.Results[0].Success { t.Fatalf("results = %+v", result.Results) } } func strPtr(value string) *string { return &value } type quotaStub struct { quota nextcloud.UserQuota err error } func (q quotaStub) GetQuota(context.Context, string) (nextcloud.UserQuota, error) { return q.quota, q.err } func TestServiceUploadAssetQuotaExceeded(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("upload should not be called when quota exceeded") })) defer server.Close() svc := NewService(photospkg.NewClient(server.URL), quotaStub{ quota: nextcloud.UserQuota{Free: 10}, }) ctx := context.WithValue(context.Background(), contentLengthContextKey{}, int64(100)) _, err := svc.UploadAsset(ctx, "user-1", strings.NewReader("payload"), "application/octet-stream") if err == nil || !errors.Is(err, ErrQuotaExceeded) { t.Fatalf("UploadAsset err = %v, want ErrQuotaExceeded", err) } }