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.
This commit is contained in:
parent
f232aaf960
commit
f0f0b31043
@ -152,6 +152,9 @@ IMMICH_ENABLED=true
|
||||
# Ce flag pilote aussi le lancement Docker via ./deploy/compose-up.sh
|
||||
# Immich utilise son propre Postgres (pgvecto.rs) via immich-postgres
|
||||
IMMICH_API_URL=http://immich-server:2283/api
|
||||
# Quota photos partagé avec Ultidrive (0 = désactivé)
|
||||
ULTID_PHOTOS_MAX_UPLOAD_BYTES=0
|
||||
ULTID_PHOTOS_QUOTA_RESERVED_BYTES=0
|
||||
|
||||
IMMICH_DB_NAME=immich
|
||||
IMMICH_UPLOAD_LOCATION=/upload
|
||||
|
||||
@ -215,7 +215,7 @@ func main() {
|
||||
r.Mount("/api/v1/meet", meetapi.NewHandler(meetCfg).Routes())
|
||||
}
|
||||
if photosClient != nil {
|
||||
r.Mount("/api/v1/photos", photosapi.NewHandler(photosClient).Routes())
|
||||
r.Mount("/api/v1/photos", photosapi.NewHandler(photosClient, ncClient).Routes())
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
package photos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@ -11,8 +13,9 @@ import (
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||
photospkg "github.com/ultisuite/ulti-backend/internal/photos"
|
||||
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||
"github.com/ultisuite/ulti-backend/internal/permission"
|
||||
photospkg "github.com/ultisuite/ulti-backend/internal/photos"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
@ -20,9 +23,11 @@ type Handler struct {
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewHandler(client *photospkg.Client) *Handler {
|
||||
type contentLengthContextKey struct{}
|
||||
|
||||
func NewHandler(client *photospkg.Client, quota *nextcloud.Client) *Handler {
|
||||
return &Handler{
|
||||
svc: NewService(client),
|
||||
svc: NewService(client, quota),
|
||||
logger: slog.Default().With("component", "photos-api"),
|
||||
}
|
||||
}
|
||||
@ -35,8 +40,14 @@ func (h *Handler) Routes() chi.Router {
|
||||
r.With(read).Get("/assets", h.ListAssets)
|
||||
r.With(read).Get("/assets/{assetID}/thumbnail", h.Thumbnail)
|
||||
r.With(read).Get("/albums", h.ListAlbums)
|
||||
r.With(read).Get("/albums/{albumID}", h.GetAlbum)
|
||||
r.With(write).Post("/assets", h.Upload)
|
||||
r.With(write).Post("/albums", h.CreateAlbum)
|
||||
r.With(write).Patch("/albums/{albumID}", h.UpdateAlbum)
|
||||
r.With(write).Delete("/assets/{assetID}", h.DeleteAsset)
|
||||
r.With(write).Delete("/albums/{albumID}", h.DeleteAlbum)
|
||||
r.With(write).Put("/albums/{albumID}/assets", h.AddAlbumAssets)
|
||||
r.With(write).Delete("/albums/{albumID}/assets", h.RemoveAlbumAssets)
|
||||
return r
|
||||
}
|
||||
|
||||
@ -59,11 +70,12 @@ func (h *Handler) ListAssets(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
ctx := context.WithValue(r.Context(), contentLengthContextKey{}, r.ContentLength)
|
||||
|
||||
id, err := h.svc.UploadAsset(r.Context(), claims.Sub, r.Body, r.Header.Get("Content-Type"))
|
||||
id, err := h.svc.UploadAsset(ctx, claims.Sub, r.Body, r.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
h.logger.Error("upload asset", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
writePhotosError(w, r, err)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusCreated, map[string]string{"id": id})
|
||||
@ -79,7 +91,7 @@ func (h *Handler) Thumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
body, ct, err := h.svc.GetAssetThumbnail(r.Context(), claims.Sub, assetID)
|
||||
if err != nil {
|
||||
apivalidate.WriteNotFound(w, r, "not found")
|
||||
writePhotosError(w, r, err)
|
||||
return
|
||||
}
|
||||
defer body.Close()
|
||||
@ -98,7 +110,7 @@ func (h *Handler) DeleteAsset(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if err := h.svc.DeleteAsset(r.Context(), claims.Sub, assetID); err != nil {
|
||||
h.logger.Error("delete asset", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
writePhotosError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@ -115,8 +127,177 @@ func (h *Handler) ListAlbums(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := h.svc.ListAlbums(r.Context(), claims.Sub, params)
|
||||
if err != nil {
|
||||
h.logger.Error("list albums", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
writePhotosError(w, r, err)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *Handler) GetAlbum(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
albumID := chi.URLParam(r, "albumID")
|
||||
if verr := validateAlbumID(albumID); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
album, err := h.svc.GetAlbum(r.Context(), claims.Sub, albumID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
apivalidate.WriteNotFound(w, r, "not found")
|
||||
return
|
||||
}
|
||||
h.logger.Error("get album", "error", err)
|
||||
writePhotosError(w, r, err)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusOK, album)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateAlbum(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
|
||||
var req createAlbumRequest
|
||||
if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if verr := validateCreateAlbum(&req); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
album, err := h.svc.CreateAlbum(r.Context(), claims.Sub, &req)
|
||||
if err != nil {
|
||||
h.logger.Error("create album", "error", err)
|
||||
writePhotosError(w, r, err)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusCreated, album)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateAlbum(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
albumID := chi.URLParam(r, "albumID")
|
||||
if verr := validateAlbumID(albumID); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
var req updateAlbumRequest
|
||||
if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if verr := validateUpdateAlbum(&req); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
album, err := h.svc.UpdateAlbum(r.Context(), claims.Sub, albumID, &req)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
apivalidate.WriteNotFound(w, r, "not found")
|
||||
return
|
||||
}
|
||||
h.logger.Error("update album", "error", err)
|
||||
writePhotosError(w, r, err)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusOK, album)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteAlbum(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
albumID := chi.URLParam(r, "albumID")
|
||||
if verr := validateAlbumID(albumID); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.DeleteAlbum(r.Context(), claims.Sub, albumID); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
apivalidate.WriteNotFound(w, r, "not found")
|
||||
return
|
||||
}
|
||||
h.logger.Error("delete album", "error", err)
|
||||
writePhotosError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) AddAlbumAssets(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
albumID := chi.URLParam(r, "albumID")
|
||||
if verr := validateAlbumID(albumID); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
var req albumAssetsRequest
|
||||
if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if verr := validateAlbumAssetsRequest(&req); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.svc.AddAlbumAssets(r.Context(), claims.Sub, albumID, req.AssetIDs)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
apivalidate.WriteNotFound(w, r, "not found")
|
||||
return
|
||||
}
|
||||
h.logger.Error("add album assets", "error", err)
|
||||
writePhotosError(w, r, err)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *Handler) RemoveAlbumAssets(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
albumID := chi.URLParam(r, "albumID")
|
||||
if verr := validateAlbumID(albumID); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
var req albumAssetsRequest
|
||||
if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if verr := validateAlbumAssetsRequest(&req); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.svc.RemoveAlbumAssets(r.Context(), claims.Sub, albumID, req.AssetIDs)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
apivalidate.WriteNotFound(w, r, "not found")
|
||||
return
|
||||
}
|
||||
h.logger.Error("remove album assets", "error", err)
|
||||
writePhotosError(w, r, err)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func writePhotosError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrNotFound):
|
||||
apivalidate.WriteNotFound(w, r, "not found")
|
||||
case errors.Is(err, ErrConflict):
|
||||
apiresponse.WriteError(w, r, http.StatusConflict, "photos.conflict", "resource conflict", nil)
|
||||
case errors.Is(err, ErrForbidden):
|
||||
apiresponse.WriteError(w, r, http.StatusForbidden, "photos.forbidden", "forbidden", nil)
|
||||
case errors.Is(err, ErrQuotaExceeded):
|
||||
apiresponse.WriteError(w, r, http.StatusInsufficientStorage, "photos.quota_exceeded", "quota exceeded", nil)
|
||||
case errors.Is(err, ErrInvalid):
|
||||
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid request body", nil)
|
||||
default:
|
||||
apivalidate.WriteInternal(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
173
internal/api/photos/list.go
Normal file
173
internal/api/photos/list.go
Normal file
@ -0,0 +1,173 @@
|
||||
package photos
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/paginate"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||
photospkg "github.com/ultisuite/ulti-backend/internal/photos"
|
||||
)
|
||||
|
||||
var photosTimeFormats = []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05.000Z",
|
||||
"2006-01-02",
|
||||
}
|
||||
|
||||
func parsePhotosTime(raw string) (time.Time, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
for _, layout := range photosTimeFormats {
|
||||
if t, err := time.Parse(layout, raw); err == nil {
|
||||
return t.UTC(), true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func parseSortField(sort string) (field string, desc bool) {
|
||||
sort = strings.TrimSpace(sort)
|
||||
if sort == "" {
|
||||
return "created_at", true
|
||||
}
|
||||
if strings.HasPrefix(sort, "-") {
|
||||
return strings.TrimPrefix(sort, "-"), true
|
||||
}
|
||||
return sort, false
|
||||
}
|
||||
|
||||
func assetMatchesDate(a photospkg.Asset, from, to *time.Time) bool {
|
||||
if from == nil && to == nil {
|
||||
return true
|
||||
}
|
||||
created, ok := parsePhotosTime(a.CreatedAt)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if from != nil && created.Before(*from) {
|
||||
return false
|
||||
}
|
||||
if to != nil && created.After(*to) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func albumMatchesDate(a photospkg.Album, from, to *time.Time) bool {
|
||||
if from == nil && to == nil {
|
||||
return true
|
||||
}
|
||||
created, ok := parsePhotosTime(a.CreatedAt)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if from != nil && created.Before(*from) {
|
||||
return false
|
||||
}
|
||||
if to != nil && created.After(*to) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func filterAssets(assets []photospkg.Asset, params query.ListParams) []photospkg.Asset {
|
||||
q := strings.ToLower(strings.TrimSpace(params.Q))
|
||||
out := make([]photospkg.Asset, 0, len(assets))
|
||||
for _, a := range assets {
|
||||
if q != "" {
|
||||
if !strings.Contains(strings.ToLower(a.OriginalName), q) &&
|
||||
!strings.Contains(strings.ToLower(a.MimeType), q) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if !assetMatchesDate(a, params.From, params.To) {
|
||||
continue
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func filterAlbums(albums []photospkg.Album, params query.ListParams) []photospkg.Album {
|
||||
q := strings.ToLower(strings.TrimSpace(params.Q))
|
||||
out := make([]photospkg.Album, 0, len(albums))
|
||||
for _, a := range albums {
|
||||
if q != "" && !strings.Contains(strings.ToLower(a.Name), q) {
|
||||
continue
|
||||
}
|
||||
if !albumMatchesDate(a, params.From, params.To) {
|
||||
continue
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sortAssets(assets []photospkg.Asset, sortParam string) {
|
||||
field, desc := parseSortField(sortParam)
|
||||
sort.SliceStable(assets, func(i, j int) bool {
|
||||
if desc {
|
||||
return compareAssets(assets[j], assets[i], field)
|
||||
}
|
||||
return compareAssets(assets[i], assets[j], field)
|
||||
})
|
||||
}
|
||||
|
||||
func compareAssets(a, b photospkg.Asset, field string) bool {
|
||||
switch field {
|
||||
case "name":
|
||||
return strings.ToLower(a.OriginalName) < strings.ToLower(b.OriginalName)
|
||||
case "size":
|
||||
return a.FileSize < b.FileSize
|
||||
default: // created_at
|
||||
ti, okI := parsePhotosTime(a.CreatedAt)
|
||||
tj, okJ := parsePhotosTime(b.CreatedAt)
|
||||
if okI && okJ {
|
||||
return ti.Before(tj)
|
||||
}
|
||||
return a.CreatedAt < b.CreatedAt
|
||||
}
|
||||
}
|
||||
|
||||
func sortAlbums(albums []photospkg.Album, sortParam string) {
|
||||
field, desc := parseSortField(sortParam)
|
||||
sort.SliceStable(albums, func(i, j int) bool {
|
||||
if desc {
|
||||
return compareAlbums(albums[j], albums[i], field)
|
||||
}
|
||||
return compareAlbums(albums[i], albums[j], field)
|
||||
})
|
||||
}
|
||||
|
||||
func compareAlbums(a, b photospkg.Album, field string) bool {
|
||||
switch field {
|
||||
case "name":
|
||||
return strings.ToLower(a.Name) < strings.ToLower(b.Name)
|
||||
case "size":
|
||||
return a.AssetCount < b.AssetCount
|
||||
default: // created_at
|
||||
ti, okI := parsePhotosTime(a.CreatedAt)
|
||||
tj, okJ := parsePhotosTime(b.CreatedAt)
|
||||
if okI && okJ {
|
||||
return ti.Before(tj)
|
||||
}
|
||||
return a.CreatedAt < b.CreatedAt
|
||||
}
|
||||
}
|
||||
|
||||
func applyAssetList(assets []photospkg.Asset, params query.ListParams) ([]photospkg.Asset, int64) {
|
||||
filtered := filterAssets(assets, params)
|
||||
sortAssets(filtered, params.Sort)
|
||||
return paginate.Slice(filtered, params.Offset(), params.Limit())
|
||||
}
|
||||
|
||||
func applyAlbumList(albums []photospkg.Album, params query.ListParams) ([]photospkg.Album, int64) {
|
||||
filtered := filterAlbums(albums, params)
|
||||
sortAlbums(filtered, params.Sort)
|
||||
return paginate.Slice(filtered, params.Offset(), params.Limit())
|
||||
}
|
||||
@ -2,20 +2,44 @@ package photos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/paginate"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||
photospkg "github.com/ultisuite/ulti-backend/internal/photos"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
client *photospkg.Client
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrConflict = errors.New("conflict")
|
||||
ErrInvalid = errors.New("invalid request")
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
ErrQuotaExceeded = errors.New("quota exceeded")
|
||||
)
|
||||
|
||||
type quotaProvider interface {
|
||||
GetQuota(ctx context.Context, userID string) (nextcloud.UserQuota, error)
|
||||
}
|
||||
|
||||
func NewService(client *photospkg.Client) *Service {
|
||||
return &Service{client: client}
|
||||
type Service struct {
|
||||
client *photospkg.Client
|
||||
quota quotaProvider
|
||||
maxUploadBytes int64
|
||||
quotaReserveByte int64
|
||||
}
|
||||
|
||||
func NewService(client *photospkg.Client, quota quotaProvider) *Service {
|
||||
return &Service{
|
||||
client: client,
|
||||
quota: quota,
|
||||
maxUploadBytes: envInt64("ULTID_PHOTOS_MAX_UPLOAD_BYTES", 0),
|
||||
quotaReserveByte: envInt64("ULTID_PHOTOS_QUOTA_RESERVED_BYTES", 0),
|
||||
}
|
||||
}
|
||||
|
||||
type AssetsList struct {
|
||||
@ -25,13 +49,11 @@ type AssetsList struct {
|
||||
}
|
||||
|
||||
func (s *Service) ListAssets(ctx context.Context, apiKey string, params query.ListParams) (AssetsList, error) {
|
||||
assets, err := s.client.GetAssets(ctx, apiKey, params.Page, params.PageSize)
|
||||
assets, err := s.client.GetAllAssets(ctx, apiKey)
|
||||
if err != nil {
|
||||
return AssetsList{}, err
|
||||
return AssetsList{}, mapPhotosError(err)
|
||||
}
|
||||
filtered := filterAssets(assets, params.Q)
|
||||
total := int64(len(filtered))
|
||||
page, _ := paginate.Slice(filtered, 0, len(filtered))
|
||||
page, total := applyAssetList(assets, params)
|
||||
return AssetsList{
|
||||
Assets: page,
|
||||
Page: params.Page,
|
||||
@ -47,53 +69,153 @@ type AlbumsList struct {
|
||||
func (s *Service) ListAlbums(ctx context.Context, apiKey string, params query.ListParams) (AlbumsList, error) {
|
||||
albums, err := s.client.GetAlbums(ctx, apiKey)
|
||||
if err != nil {
|
||||
return AlbumsList{}, err
|
||||
return AlbumsList{}, mapPhotosError(err)
|
||||
}
|
||||
filtered := filterAlbums(albums, params.Q)
|
||||
page, total := paginate.Slice(filtered, params.Offset(), params.Limit())
|
||||
page, total := applyAlbumList(albums, params)
|
||||
return AlbumsList{
|
||||
Albums: page,
|
||||
Pagination: params.Meta(&total),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetAlbum(ctx context.Context, apiKey, albumID string) (photospkg.Album, error) {
|
||||
album, err := s.client.GetAlbum(ctx, apiKey, albumID)
|
||||
if err != nil {
|
||||
return photospkg.Album{}, mapPhotosError(err)
|
||||
}
|
||||
return album, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateAlbum(ctx context.Context, apiKey string, req *createAlbumRequest) (photospkg.Album, error) {
|
||||
album, err := s.client.CreateAlbum(ctx, apiKey, strings.TrimSpace(req.Name), strings.TrimSpace(req.Description), req.AssetIDs)
|
||||
if err != nil {
|
||||
return photospkg.Album{}, mapPhotosError(err)
|
||||
}
|
||||
return album, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateAlbum(ctx context.Context, apiKey, albumID string, req *updateAlbumRequest) (photospkg.Album, error) {
|
||||
var name, description *string
|
||||
if req.Name != nil {
|
||||
trimmed := strings.TrimSpace(*req.Name)
|
||||
name = &trimmed
|
||||
}
|
||||
if req.Description != nil {
|
||||
trimmed := strings.TrimSpace(*req.Description)
|
||||
description = &trimmed
|
||||
}
|
||||
album, err := s.client.UpdateAlbum(ctx, apiKey, albumID, name, description)
|
||||
if err != nil {
|
||||
return photospkg.Album{}, mapPhotosError(err)
|
||||
}
|
||||
return album, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteAlbum(ctx context.Context, apiKey, albumID string) error {
|
||||
return mapPhotosError(s.client.DeleteAlbum(ctx, apiKey, albumID))
|
||||
}
|
||||
|
||||
type AlbumAssetsResult struct {
|
||||
Results []photospkg.BulkAssetResult `json:"results"`
|
||||
}
|
||||
|
||||
func (s *Service) AddAlbumAssets(ctx context.Context, apiKey, albumID string, assetIDs []string) (AlbumAssetsResult, error) {
|
||||
results, err := s.client.AddAlbumAssets(ctx, apiKey, albumID, assetIDs)
|
||||
if err != nil {
|
||||
return AlbumAssetsResult{}, mapPhotosError(err)
|
||||
}
|
||||
return AlbumAssetsResult{Results: results}, nil
|
||||
}
|
||||
|
||||
func (s *Service) RemoveAlbumAssets(ctx context.Context, apiKey, albumID string, assetIDs []string) (AlbumAssetsResult, error) {
|
||||
results, err := s.client.RemoveAlbumAssets(ctx, apiKey, albumID, assetIDs)
|
||||
if err != nil {
|
||||
return AlbumAssetsResult{}, mapPhotosError(err)
|
||||
}
|
||||
return AlbumAssetsResult{Results: results}, nil
|
||||
}
|
||||
|
||||
func mapPhotosError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var statusErr *photospkg.HTTPStatusError
|
||||
if !errors.As(err, &statusErr) {
|
||||
return err
|
||||
}
|
||||
switch statusErr.StatusCode {
|
||||
case http.StatusNotFound:
|
||||
return ErrNotFound
|
||||
case http.StatusConflict:
|
||||
return ErrConflict
|
||||
case http.StatusForbidden, http.StatusUnauthorized:
|
||||
return ErrForbidden
|
||||
case http.StatusInsufficientStorage, http.StatusRequestEntityTooLarge:
|
||||
return ErrQuotaExceeded
|
||||
case http.StatusBadRequest, http.StatusUnprocessableEntity:
|
||||
return ErrInvalid
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) UploadAsset(ctx context.Context, apiKey string, body io.Reader, contentType string) (string, error) {
|
||||
return s.client.UploadAsset(ctx, apiKey, body, contentType)
|
||||
if err := s.ensureQuota(ctx, apiKey); err != nil {
|
||||
return "", err
|
||||
}
|
||||
id, err := s.client.UploadAsset(ctx, apiKey, body, contentType)
|
||||
return id, mapPhotosError(err)
|
||||
}
|
||||
|
||||
func (s *Service) GetAssetThumbnail(ctx context.Context, apiKey, assetID string) (io.ReadCloser, string, error) {
|
||||
return s.client.GetAssetThumbnail(ctx, apiKey, assetID)
|
||||
body, contentType, err := s.client.GetAssetThumbnail(ctx, apiKey, assetID)
|
||||
return body, contentType, mapPhotosError(err)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteAsset(ctx context.Context, apiKey, assetID string) error {
|
||||
return s.client.DeleteAsset(ctx, apiKey, assetID)
|
||||
return mapPhotosError(s.client.DeleteAsset(ctx, apiKey, assetID))
|
||||
}
|
||||
|
||||
func filterAssets(assets []photospkg.Asset, q string) []photospkg.Asset {
|
||||
q = strings.ToLower(strings.TrimSpace(q))
|
||||
if q == "" {
|
||||
return assets
|
||||
func (s *Service) ensureQuota(ctx context.Context, userID string) error {
|
||||
if s.quota == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]photospkg.Asset, 0, len(assets))
|
||||
for _, a := range assets {
|
||||
if strings.Contains(strings.ToLower(a.OriginalName), q) ||
|
||||
strings.Contains(strings.ToLower(a.MimeType), q) {
|
||||
out = append(out, a)
|
||||
|
||||
incomingBytes := requestContentLength(ctx)
|
||||
if incomingBytes > 0 && s.maxUploadBytes > 0 && incomingBytes > s.maxUploadBytes {
|
||||
return ErrQuotaExceeded
|
||||
}
|
||||
if incomingBytes <= 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
|
||||
quota, err := s.quota.GetQuota(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if quota.Free <= 0 || incomingBytes+s.quotaReserveByte > quota.Free {
|
||||
return ErrQuotaExceeded
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func filterAlbums(albums []photospkg.Album, q string) []photospkg.Album {
|
||||
q = strings.ToLower(strings.TrimSpace(q))
|
||||
if q == "" {
|
||||
return albums
|
||||
func requestContentLength(ctx context.Context) int64 {
|
||||
v := ctx.Value(contentLengthContextKey{})
|
||||
n, ok := v.(int64)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
out := make([]photospkg.Album, 0, len(albums))
|
||||
for _, a := range albums {
|
||||
if strings.Contains(strings.ToLower(a.Name), q) {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out
|
||||
return n
|
||||
}
|
||||
|
||||
func envInt64(key string, fallback int64) int64 {
|
||||
raw := strings.TrimSpace(os.Getenv(key))
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
367
internal/api/photos/service_test.go
Normal file
367
internal/api/photos/service_test.go
Normal file
@ -0,0 +1,367 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,23 @@ import (
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
||||
)
|
||||
|
||||
const maxJSONRequestBody = 32 << 10
|
||||
|
||||
type createAlbumRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
AssetIDs []string `json:"asset_ids"`
|
||||
}
|
||||
|
||||
type updateAlbumRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
|
||||
type albumAssetsRequest struct {
|
||||
AssetIDs []string `json:"asset_ids"`
|
||||
}
|
||||
|
||||
func validateAssetID(assetID string) *apivalidate.ValidationError {
|
||||
if strings.TrimSpace(assetID) == "" {
|
||||
return apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
@ -14,3 +31,93 @@ func validateAssetID(assetID string) *apivalidate.ValidationError {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAlbumID(albumID string) *apivalidate.ValidationError {
|
||||
if strings.TrimSpace(albumID) == "" {
|
||||
return apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
Field: "album_id", Message: "required",
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAlbumName(field, name string, required bool) *apivalidate.FieldDetail {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
if required {
|
||||
return &apivalidate.FieldDetail{Field: field, Message: "required"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(name, "\n") || strings.Contains(name, "\r") {
|
||||
return &apivalidate.FieldDetail{Field: field, Message: "invalid"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAssetIDs(field string, assetIDs []string, required bool) []apivalidate.FieldDetail {
|
||||
if len(assetIDs) == 0 {
|
||||
if required {
|
||||
return []apivalidate.FieldDetail{{Field: field, Message: "required"}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var details []apivalidate.FieldDetail
|
||||
for i, id := range assetIDs {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
details = append(details, apivalidate.FieldDetail{
|
||||
Field: field, Message: "invalid",
|
||||
})
|
||||
if i == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
func validateCreateAlbum(req *createAlbumRequest) *apivalidate.ValidationError {
|
||||
var details []apivalidate.FieldDetail
|
||||
if d := validateAlbumName("name", req.Name, true); d != nil {
|
||||
details = append(details, *d)
|
||||
}
|
||||
if d := validateAlbumName("description", req.Description, false); d != nil {
|
||||
details = append(details, *d)
|
||||
}
|
||||
details = append(details, validateAssetIDs("asset_ids", req.AssetIDs, false)...)
|
||||
if len(details) == 0 {
|
||||
return nil
|
||||
}
|
||||
return apivalidate.NewValidationError(details...)
|
||||
}
|
||||
|
||||
func validateUpdateAlbum(req *updateAlbumRequest) *apivalidate.ValidationError {
|
||||
if req.Name == nil && req.Description == nil {
|
||||
return apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
Field: "body", Message: "required",
|
||||
})
|
||||
}
|
||||
var details []apivalidate.FieldDetail
|
||||
if req.Name != nil {
|
||||
if d := validateAlbumName("name", *req.Name, true); d != nil {
|
||||
details = append(details, *d)
|
||||
}
|
||||
}
|
||||
if req.Description != nil {
|
||||
if d := validateAlbumName("description", *req.Description, false); d != nil {
|
||||
details = append(details, *d)
|
||||
}
|
||||
}
|
||||
if len(details) == 0 {
|
||||
return nil
|
||||
}
|
||||
return apivalidate.NewValidationError(details...)
|
||||
}
|
||||
|
||||
func validateAlbumAssetsRequest(req *albumAssetsRequest) *apivalidate.ValidationError {
|
||||
details := validateAssetIDs("asset_ids", req.AssetIDs, true)
|
||||
if len(details) == 0 {
|
||||
return nil
|
||||
}
|
||||
return apivalidate.NewValidationError(details...)
|
||||
}
|
||||
|
||||
73
internal/api/photos/validate_test.go
Normal file
73
internal/api/photos/validate_test.go
Normal file
@ -0,0 +1,73 @@
|
||||
package photos
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
||||
)
|
||||
|
||||
func hasFieldDetail(err *apivalidate.ValidationError, field, message string) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
for _, d := range err.Details {
|
||||
if d.Field == field && d.Message == message {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestValidateAlbumID(t *testing.T) {
|
||||
if validateAlbumID("album-1") != nil {
|
||||
t.Fatal("expected valid album id")
|
||||
}
|
||||
if err := validateAlbumID(""); !hasFieldDetail(err, "album_id", "required") {
|
||||
t.Fatal("expected required album_id error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCreateAlbum(t *testing.T) {
|
||||
if validateCreateAlbum(&createAlbumRequest{Name: "Vacation"}) != nil {
|
||||
t.Fatal("expected valid create album request")
|
||||
}
|
||||
if err := validateCreateAlbum(&createAlbumRequest{}); !hasFieldDetail(err, "name", "required") {
|
||||
t.Fatal("expected missing name error")
|
||||
}
|
||||
if err := validateCreateAlbum(&createAlbumRequest{Name: "Trip", AssetIDs: []string{""}}); !hasFieldDetail(err, "asset_ids", "invalid") {
|
||||
t.Fatal("expected invalid asset_ids error")
|
||||
}
|
||||
if err := validateCreateAlbum(&createAlbumRequest{Name: "Bad\nName"}); !hasFieldDetail(err, "name", "invalid") {
|
||||
t.Fatal("expected invalid name error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUpdateAlbum(t *testing.T) {
|
||||
name := "Renamed"
|
||||
if validateUpdateAlbum(&updateAlbumRequest{Name: &name}) != nil {
|
||||
t.Fatal("expected valid update album request")
|
||||
}
|
||||
desc := "Notes"
|
||||
if validateUpdateAlbum(&updateAlbumRequest{Description: &desc}) != nil {
|
||||
t.Fatal("expected valid description-only update")
|
||||
}
|
||||
if err := validateUpdateAlbum(&updateAlbumRequest{}); !hasFieldDetail(err, "body", "required") {
|
||||
t.Fatal("expected empty body error")
|
||||
}
|
||||
blank := " "
|
||||
if err := validateUpdateAlbum(&updateAlbumRequest{Name: &blank}); !hasFieldDetail(err, "name", "required") {
|
||||
t.Fatal("expected blank name error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAlbumAssetsRequest(t *testing.T) {
|
||||
if validateAlbumAssetsRequest(&albumAssetsRequest{AssetIDs: []string{"a1"}}) != nil {
|
||||
t.Fatal("expected valid album assets request")
|
||||
}
|
||||
if err := validateAlbumAssetsRequest(&albumAssetsRequest{}); !hasFieldDetail(err, "asset_ids", "required") {
|
||||
t.Fatal("expected required asset_ids error")
|
||||
}
|
||||
if err := validateAlbumAssetsRequest(&albumAssetsRequest{AssetIDs: []string{" "}}); !hasFieldDetail(err, "asset_ids", "invalid") {
|
||||
t.Fatal("expected invalid asset_ids error")
|
||||
}
|
||||
}
|
||||
129
internal/photos/albums.go
Normal file
129
internal/photos/albums.go
Normal file
@ -0,0 +1,129 @@
|
||||
package photos
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type BulkAssetResult struct {
|
||||
ID string `json:"id"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type createAlbumPayload struct {
|
||||
AlbumName string `json:"albumName"`
|
||||
Description string `json:"description,omitempty"`
|
||||
AssetIDs []string `json:"assetIds,omitempty"`
|
||||
}
|
||||
|
||||
type updateAlbumPayload struct {
|
||||
AlbumName *string `json:"albumName,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type bulkIDsPayload struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
|
||||
func (c *Client) GetAlbum(ctx context.Context, apiKey, albumID string) (Album, error) {
|
||||
url := fmt.Sprintf("%s/albums/%s?withoutAssets=true", c.baseURL, albumID)
|
||||
var album Album
|
||||
if err := c.doJSON(ctx, http.MethodGet, url, apiKey, nil, &album, "get album", http.StatusOK); err != nil {
|
||||
return Album{}, err
|
||||
}
|
||||
return album, nil
|
||||
}
|
||||
|
||||
func (c *Client) CreateAlbum(ctx context.Context, apiKey, name, description string, assetIDs []string) (Album, error) {
|
||||
payload := createAlbumPayload{
|
||||
AlbumName: name,
|
||||
Description: description,
|
||||
AssetIDs: assetIDs,
|
||||
}
|
||||
var album Album
|
||||
if err := c.doJSON(ctx, http.MethodPost, c.baseURL+"/albums", apiKey, payload, &album, "create album", http.StatusCreated); err != nil {
|
||||
return Album{}, err
|
||||
}
|
||||
return album, nil
|
||||
}
|
||||
|
||||
func (c *Client) UpdateAlbum(ctx context.Context, apiKey, albumID string, name, description *string) (Album, error) {
|
||||
payload := updateAlbumPayload{
|
||||
AlbumName: name,
|
||||
Description: description,
|
||||
}
|
||||
url := fmt.Sprintf("%s/albums/%s", c.baseURL, albumID)
|
||||
var album Album
|
||||
if err := c.doJSON(ctx, http.MethodPatch, url, apiKey, payload, &album, "update album", http.StatusOK); err != nil {
|
||||
return Album{}, err
|
||||
}
|
||||
return album, nil
|
||||
}
|
||||
|
||||
func (c *Client) DeleteAlbum(ctx context.Context, apiKey, albumID string) error {
|
||||
url := fmt.Sprintf("%s/albums/%s", c.baseURL, albumID)
|
||||
return c.doJSON(ctx, http.MethodDelete, url, apiKey, nil, nil, "delete album", http.StatusOK, http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (c *Client) AddAlbumAssets(ctx context.Context, apiKey, albumID string, assetIDs []string) ([]BulkAssetResult, error) {
|
||||
url := fmt.Sprintf("%s/albums/%s/assets", c.baseURL, albumID)
|
||||
var results []BulkAssetResult
|
||||
if err := c.doJSON(ctx, http.MethodPut, url, apiKey, bulkIDsPayload{IDs: assetIDs}, &results, "add album assets", http.StatusOK); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (c *Client) RemoveAlbumAssets(ctx context.Context, apiKey, albumID string, assetIDs []string) ([]BulkAssetResult, error) {
|
||||
url := fmt.Sprintf("%s/albums/%s/assets", c.baseURL, albumID)
|
||||
var results []BulkAssetResult
|
||||
if err := c.doJSON(ctx, http.MethodDelete, url, apiKey, bulkIDsPayload{IDs: assetIDs}, &results, "remove album assets", http.StatusOK); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (c *Client) doJSON(ctx context.Context, method, url, apiKey string, reqBody any, dest any, operation string, okStatuses ...int) error {
|
||||
var body io.Reader
|
||||
if reqBody != nil {
|
||||
data, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("x-api-key", apiKey)
|
||||
if reqBody != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
for _, status := range okStatuses {
|
||||
if resp.StatusCode == status {
|
||||
if dest != nil {
|
||||
if err := json.NewDecoder(resp.Body).Decode(dest); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
return &HTTPStatusError{Operation: operation, StatusCode: resp.StatusCode}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
package photos
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@ -29,22 +30,49 @@ type Asset struct {
|
||||
OriginalPath string `json:"originalPath"`
|
||||
OriginalName string `json:"originalFileName"`
|
||||
MimeType string `json:"originalMimeType"`
|
||||
FileSize int64 `json:"exifInfo.fileSizeInByte"`
|
||||
FileSize int64 `json:"fileSizeInByte"`
|
||||
CreatedAt string `json:"fileCreatedAt"`
|
||||
IsFavorite bool `json:"isFavorite"`
|
||||
ThumbHash string `json:"thumbhash"`
|
||||
ExifInfo struct {
|
||||
FileSizeInByte int64 `json:"fileSizeInByte"`
|
||||
} `json:"exifInfo,omitempty"`
|
||||
}
|
||||
|
||||
type Album struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"albumName"`
|
||||
Description string `json:"description,omitempty"`
|
||||
AssetCount int `json:"assetCount"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
const assetsPageSize = 200
|
||||
|
||||
func (c *Client) GetAllAssets(ctx context.Context, apiKey string) ([]Asset, error) {
|
||||
var all []Asset
|
||||
page := 1
|
||||
for {
|
||||
batch, err := c.GetAssets(ctx, apiKey, page, assetsPageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, batch...)
|
||||
if len(batch) < assetsPageSize {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetAssets(ctx context.Context, apiKey string, page, size int) ([]Asset, error) {
|
||||
url := fmt.Sprintf("%s/assets?page=%d&size=%d", c.baseURL, page, size)
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("x-api-key", apiKey)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
@ -52,15 +80,52 @@ func (c *Client) GetAssets(ctx context.Context, apiKey string, page, size int) (
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
return nil, &HTTPStatusError{Operation: "get assets", StatusCode: resp.StatusCode}
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var assets []Asset
|
||||
json.NewDecoder(resp.Body).Decode(&assets)
|
||||
trimmed := bytes.TrimSpace(body)
|
||||
switch {
|
||||
case len(trimmed) == 0:
|
||||
return []Asset{}, nil
|
||||
case trimmed[0] == '[':
|
||||
if err := json.Unmarshal(trimmed, &assets); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
var wrapped struct {
|
||||
Items []Asset `json:"items"`
|
||||
Assets []Asset `json:"assets"`
|
||||
}
|
||||
if err := json.Unmarshal(trimmed, &wrapped); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(wrapped.Items) > 0 {
|
||||
assets = wrapped.Items
|
||||
} else {
|
||||
assets = wrapped.Assets
|
||||
}
|
||||
}
|
||||
for i := range assets {
|
||||
if assets[i].FileSize == 0 {
|
||||
assets[i].FileSize = assets[i].ExifInfo.FileSizeInByte
|
||||
}
|
||||
}
|
||||
return assets, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetAlbums(ctx context.Context, apiKey string) ([]Album, error) {
|
||||
url := fmt.Sprintf("%s/albums", c.baseURL)
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("x-api-key", apiKey)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
@ -68,15 +133,24 @@ func (c *Client) GetAlbums(ctx context.Context, apiKey string) ([]Album, error)
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
return nil, &HTTPStatusError{Operation: "get albums", StatusCode: resp.StatusCode}
|
||||
}
|
||||
|
||||
var albums []Album
|
||||
json.NewDecoder(resp.Body).Decode(&albums)
|
||||
if err := json.NewDecoder(resp.Body).Decode(&albums); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return albums, nil
|
||||
}
|
||||
|
||||
func (c *Client) UploadAsset(ctx context.Context, apiKey string, body io.Reader, contentType string) (string, error) {
|
||||
url := fmt.Sprintf("%s/assets", c.baseURL)
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", url, body)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("x-api-key", apiKey)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
|
||||
@ -85,36 +159,61 @@ func (c *Client) UploadAsset(ctx context.Context, apiKey string, body io.Reader,
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
return "", &HTTPStatusError{Operation: "upload asset", StatusCode: resp.StatusCode}
|
||||
}
|
||||
|
||||
var result struct {
|
||||
ID string `json:"id"`
|
||||
AssetID string `json:"assetId"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.ID != "" {
|
||||
return result.ID, nil
|
||||
}
|
||||
return result.AssetID, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetAssetThumbnail(ctx context.Context, apiKey, assetID string) (io.ReadCloser, string, error) {
|
||||
url := fmt.Sprintf("%s/assets/%s/thumbnail", c.baseURL, assetID)
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req.Header.Set("x-api-key", apiKey)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
return nil, "", &HTTPStatusError{Operation: "get asset thumbnail", StatusCode: resp.StatusCode}
|
||||
}
|
||||
|
||||
return resp.Body, resp.Header.Get("Content-Type"), nil
|
||||
}
|
||||
|
||||
func (c *Client) DeleteAsset(ctx context.Context, apiKey, assetID string) error {
|
||||
url := fmt.Sprintf("%s/assets/%s", c.baseURL, assetID)
|
||||
req, _ := http.NewRequestWithContext(ctx, "DELETE", url, nil)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("x-api-key", apiKey)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
return &HTTPStatusError{Operation: "delete asset", StatusCode: resp.StatusCode}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
200
internal/photos/client_test.go
Normal file
200
internal/photos/client_test.go
Normal file
@ -0,0 +1,200 @@
|
||||
package photos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetAssetsSupportsWrappedItemsResponse(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(map[string]any{
|
||||
"items": []map[string]any{
|
||||
{
|
||||
"id": "a1",
|
||||
"originalFileName": "a.jpg",
|
||||
"fileCreatedAt": "2026-01-01T00:00:00Z",
|
||||
"exifInfo": map[string]any{
|
||||
"fileSizeInByte": int64(42),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL)
|
||||
assets, err := client.GetAssets(context.Background(), "key", 1, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAssets: %v", err)
|
||||
}
|
||||
if len(assets) != 1 || assets[0].FileSize != 42 {
|
||||
t.Fatalf("assets = %+v", assets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAllAssetsPaginatesUpstream(t *testing.T) {
|
||||
call := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
call++
|
||||
page := r.URL.Query().Get("page")
|
||||
if page == "1" {
|
||||
batch := make([]Asset, assetsPageSize)
|
||||
for i := range batch {
|
||||
batch[i] = Asset{ID: fmt.Sprintf("full-%d", i)}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(batch)
|
||||
return
|
||||
}
|
||||
if page == "2" {
|
||||
_ = json.NewEncoder(w).Encode([]Asset{
|
||||
{ID: "tail-1"},
|
||||
{ID: "tail-2"},
|
||||
})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode([]Asset{})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL)
|
||||
assets, err := client.GetAllAssets(context.Background(), "key")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllAssets: %v", err)
|
||||
}
|
||||
if len(assets) != assetsPageSize+2 {
|
||||
t.Fatalf("len = %d, want %d", len(assets), assetsPageSize+2)
|
||||
}
|
||||
if call != 2 {
|
||||
t.Fatalf("upstream calls = %d, want 2", call)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAlbumMapsImmichPayload(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
|
||||
}
|
||||
if r.Header.Get("x-api-key") != "key" {
|
||||
t.Fatalf("api key = %q", r.Header.Get("x-api-key"))
|
||||
}
|
||||
var payload createAlbumPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
if payload.AlbumName != "Trip" || payload.Description != "Summer" || len(payload.AssetIDs) != 1 || payload.AssetIDs[0] != "asset-1" {
|
||||
t.Fatalf("payload = %+v", payload)
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(Album{ID: "album-1", Name: payload.AlbumName})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL)
|
||||
album, err := client.CreateAlbum(context.Background(), "key", "Trip", "Summer", []string{"asset-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAlbum: %v", err)
|
||||
}
|
||||
if album.ID != "album-1" || album.Name != "Trip" {
|
||||
t.Fatalf("album = %+v", album)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadAssetStatusMapped(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL)
|
||||
_, err := client.UploadAsset(context.Background(), "key", strings.NewReader("body"), "application/octet-stream")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
var statusErr *HTTPStatusError
|
||||
if !errors.As(err, &statusErr) || statusErr.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAlbumNotFound(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL)
|
||||
_, err := client.GetAlbum(context.Background(), "key", "missing")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
var statusErr *HTTPStatusError
|
||||
if !errors.As(err, &statusErr) || statusErr.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddAlbumAssetsSendsBulkIDs(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut || r.URL.Path != "/albums/album-1/assets" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
var payload bulkIDsPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
if len(payload.IDs) != 2 || payload.IDs[0] != "a1" || payload.IDs[1] != "a2" {
|
||||
t.Fatalf("payload = %+v", payload)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode([]BulkAssetResult{{ID: "a1", Success: true}, {ID: "a2", Success: true}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL)
|
||||
results, err := client.AddAlbumAssets(context.Background(), "key", "album-1", []string{"a1", "a2"})
|
||||
if err != nil {
|
||||
t.Fatalf("AddAlbumAssets: %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("results = %+v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveAlbumAssetsUsesDeleteWithBody(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete || r.URL.Path != "/albums/album-1/assets" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
if string(body) != `{"ids":["a1"]}` {
|
||||
t.Fatalf("body = %s", string(body))
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode([]BulkAssetResult{{ID: "a1", Success: true}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL)
|
||||
results, err := client.RemoveAlbumAssets(context.Background(), "key", "album-1", []string{"a1"})
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveAlbumAssets: %v", err)
|
||||
}
|
||||
if len(results) != 1 || !results[0].Success {
|
||||
t.Fatalf("results = %+v", results)
|
||||
}
|
||||
}
|
||||
12
internal/photos/errors.go
Normal file
12
internal/photos/errors.go
Normal file
@ -0,0 +1,12 @@
|
||||
package photos
|
||||
|
||||
import "fmt"
|
||||
|
||||
type HTTPStatusError struct {
|
||||
Operation string
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
func (e *HTTPStatusError) Error() string {
|
||||
return fmt.Sprintf("%s failed: %d", e.Operation, e.StatusCode)
|
||||
}
|
||||
@ -166,10 +166,10 @@ Objectif: transformer état actuel (partiellement implémenté) vers produit fon
|
||||
|
||||
#### Photos
|
||||
|
||||
- [ ] Corriger mapping API Immich (champs JSON exacts, erreurs HTTP).
|
||||
- [ ] Ajouter pagination/tri/filtres robustes.
|
||||
- [ ] Ajouter albums CRUD complets.
|
||||
- [ ] Ajouter liaison stockage/quota avec Ultidrive.
|
||||
- [x] Corriger mapping API Immich (champs JSON exacts, erreurs HTTP).
|
||||
- [x] Ajouter pagination/tri/filtres robustes.
|
||||
- [x] Ajouter albums CRUD complets.
|
||||
- [x] Ajouter liaison stockage/quota avec Ultidrive.
|
||||
|
||||
#### Admin
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user