ultisuite-backend/internal/api/photos/service_test.go
R3D347HR4Y f0f0b31043 Implement Photos API robustness and quota integration
Improve Immich-backed photos endpoints with robust mapping/error handling, full albums CRUD, reliable list pagination/sorting/filtering, and shared Nextcloud quota checks before upload.
2026-05-22 21:09:13 +02:00

368 lines
11 KiB
Go

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)
}
}