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.
368 lines
11 KiB
Go
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)
|
|
}
|
|
}
|