feat(api): implement library management and user-library association endpoints

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-06-06 14:34:24 -04:00
commit 403fe19e1a
7 changed files with 952 additions and 8 deletions

View file

@ -32,7 +32,9 @@ type LibraryRepository interface {
// Its implementation must be optimized to avoid unnecessary queries.
GetPath(id int) (string, error)
GetAll(...QueryOptions) (Libraries, error)
CountAll(...QueryOptions) (int64, error)
Put(*Library) error
Delete(id int) error
StoreMusicFolder() error
AddArtist(id int, artistID string) error

View file

@ -7,6 +7,7 @@ import (
"time"
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@ -211,6 +212,24 @@ func (r *libraryRepository) RefreshStats(id int) error {
return err
}
func (r *libraryRepository) Delete(id int) error {
if !isAdmin(r.ctx) {
return rest.ErrPermissionDenied
}
err := r.delete(Eq{"id": id})
if err != nil {
return err
}
// Clear cache entry for this library only if DB operation was successful
libLock.Lock()
defer libLock.Unlock()
delete(libCache, id)
return nil
}
func (r *libraryRepository) GetAll(ops ...model.QueryOptions) (model.Libraries, error) {
sq := r.newSelect(ops...).Columns("*")
res := model.Libraries{}
@ -218,6 +237,11 @@ func (r *libraryRepository) GetAll(ops ...model.QueryOptions) (model.Libraries,
return res, err
}
func (r *libraryRepository) CountAll(ops ...model.QueryOptions) (int64, error) {
sq := r.newSelect(ops...)
return r.count(sq)
}
// User-library association methods
func (r *libraryRepository) GetUsersWithLibraryAccess(libraryID int) (model.Users, error) {

337
server/nativeapi/library.go Normal file
View file

@ -0,0 +1,337 @@
package nativeapi
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"github.com/Masterminds/squirrel"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
)
// Library management endpoints (admin only)
func (n *Router) addLibraryRoute(r chi.Router) {
r.Route("/library", func(r chi.Router) {
r.Use(adminOnlyMiddleware)
r.Get("/", getLibraries(n.ds))
r.Post("/", createLibrary(n.ds))
r.Route("/{id}", func(r chi.Router) {
r.Use(parseIDMiddleware)
r.Get("/", getLibrary(n.ds))
r.Put("/", updateLibrary(n.ds))
r.Delete("/", deleteLibrary(n.ds))
})
})
}
// User-library association endpoints (admin only)
func (n *Router) addUserLibraryRoute(r chi.Router) {
r.Route("/user/{id}/library", func(r chi.Router) {
r.Use(adminOnlyMiddleware)
r.Use(parseUserIDMiddleware)
r.Get("/", getUserLibraries(n.ds))
r.Put("/", setUserLibraries(n.ds))
})
}
// Middleware to ensure only admin users can access endpoints
func adminOnlyMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := request.UserFrom(r.Context())
if !ok || !user.IsAdmin {
http.Error(w, "Access denied: admin privileges required", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// Middleware to parse library ID from URL
func parseIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid library ID", http.StatusBadRequest)
return
}
ctx := context.WithValue(r.Context(), "libraryID", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Middleware to parse user ID from URL
func parseUserIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "id")
if userID == "" {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
ctx := context.WithValue(r.Context(), "userID", userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Library CRUD handlers
func getLibraries(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
libraries, err := ds.Library(r.Context()).GetAll()
if err != nil {
log.Error(r.Context(), "Error getting libraries", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(libraries); err != nil {
log.Error(r.Context(), "Error encoding libraries response", err)
}
}
}
func getLibrary(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
libraryID := r.Context().Value("libraryID").(int)
library, err := ds.Library(r.Context()).Get(libraryID)
if err != nil {
if err == model.ErrNotFound {
http.Error(w, "Library not found", http.StatusNotFound)
return
}
log.Error(r.Context(), "Error getting library", "libraryID", libraryID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(library); err != nil {
log.Error(r.Context(), "Error encoding library response", err)
}
}
}
func createLibrary(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var library model.Library
if err := json.NewDecoder(r.Body).Decode(&library); err != nil {
log.Error(r.Context(), "Error decoding library request", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Validate required fields
if library.Name == "" {
http.Error(w, "Library name is required", http.StatusBadRequest)
return
}
if library.Path == "" {
http.Error(w, "Library path is required", http.StatusBadRequest)
return
}
if err := ds.Library(r.Context()).Put(&library); err != nil {
log.Error(r.Context(), "Error creating library", err)
http.Error(w, "Failed to create library", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(library); err != nil {
log.Error(r.Context(), "Error encoding library response", err)
}
}
}
func updateLibrary(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
libraryID := r.Context().Value("libraryID").(int)
var library model.Library
if err := json.NewDecoder(r.Body).Decode(&library); err != nil {
log.Error(r.Context(), "Error decoding library request", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Ensure the ID matches the URL parameter
library.ID = libraryID
// Validate required fields
if library.Name == "" {
http.Error(w, "Library name is required", http.StatusBadRequest)
return
}
if library.Path == "" {
http.Error(w, "Library path is required", http.StatusBadRequest)
return
}
if err := ds.Library(r.Context()).Put(&library); err != nil {
if err == model.ErrNotFound {
http.Error(w, "Library not found", http.StatusNotFound)
return
}
log.Error(r.Context(), "Error updating library", "libraryID", libraryID, err)
http.Error(w, "Failed to update library", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(library); err != nil {
log.Error(r.Context(), "Error encoding library response", err)
}
}
}
func deleteLibrary(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
libraryID := r.Context().Value("libraryID").(int)
if err := ds.Library(r.Context()).Delete(libraryID); err != nil {
if err == model.ErrNotFound {
http.Error(w, "Library not found", http.StatusNotFound)
return
}
log.Error(r.Context(), "Error deleting library", "libraryID", libraryID, err)
http.Error(w, "Failed to delete library", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// User-library association handlers
func getUserLibraries(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value("userID").(string)
// Verify user exists
if _, err := ds.User(r.Context()).Get(userID); err != nil {
if err == model.ErrNotFound {
http.Error(w, "User not found", http.StatusNotFound)
return
}
log.Error(r.Context(), "Error getting user", "userID", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
libraries, err := ds.User(r.Context()).GetUserLibraries(userID)
if err != nil {
log.Error(r.Context(), "Error getting user libraries", "userID", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(libraries); err != nil {
log.Error(r.Context(), "Error encoding user libraries response", err)
}
}
}
func setUserLibraries(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value("userID").(string)
// Verify user exists
user, err := ds.User(r.Context()).Get(userID)
if err != nil {
if err == model.ErrNotFound {
http.Error(w, "User not found", http.StatusNotFound)
return
}
log.Error(r.Context(), "Error getting user", "userID", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
var request struct {
LibraryIDs []int `json:"libraryIds"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
log.Error(r.Context(), "Error decoding request", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Admin users get all libraries automatically - don't allow manual assignment
if user.IsAdmin {
http.Error(w, "Cannot manually assign libraries to admin users", http.StatusBadRequest)
return
}
// Validate library IDs exist
if len(request.LibraryIDs) > 0 {
// Use CountAll with IN filter to efficiently check if all library IDs exist
count, err := ds.Library(r.Context()).CountAll(model.QueryOptions{
Filters: squirrel.Eq{"id": request.LibraryIDs},
})
if err != nil {
log.Error(r.Context(), "Error counting libraries", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if int(count) != len(request.LibraryIDs) {
// Some library IDs don't exist - find which ones for better error message
existingLibraries, err := ds.Library(r.Context()).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"id": request.LibraryIDs},
})
if err != nil {
log.Error(r.Context(), "Error getting all libraries", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
libraryMap := make(map[int]bool)
for _, lib := range existingLibraries {
libraryMap[lib.ID] = true
}
for _, libID := range request.LibraryIDs {
if !libraryMap[libID] {
http.Error(w, fmt.Sprintf("Library ID %d does not exist", libID), http.StatusBadRequest)
return
}
}
}
}
// Regular users must have at least one library
if len(request.LibraryIDs) == 0 {
http.Error(w, "At least one library must be assigned to non-admin users", http.StatusBadRequest)
return
}
if err := ds.User(r.Context()).SetUserLibraries(userID, request.LibraryIDs); err != nil {
log.Error(r.Context(), "Error setting user libraries", "userID", userID, err)
http.Error(w, "Failed to set user libraries", http.StatusInternalServerError)
return
}
// Return updated user libraries
libraries, err := ds.User(r.Context()).GetUserLibraries(userID)
if err != nil {
log.Error(r.Context(), "Error getting updated user libraries", "userID", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(libraries); err != nil {
log.Error(r.Context(), "Error encoding user libraries response", err)
}
}
}

View file

@ -0,0 +1,442 @@
package nativeapi
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Library API", func() {
var ds model.DataStore
var router http.Handler
var adminUser, regularUser model.User
var library1, library2 model.Library
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ds = &tests.MockDataStore{}
auth.Init(ds)
nativeRouter := New(ds, nil, nil, nil)
router = server.JWTVerifier(nativeRouter)
// Create test users
adminUser = model.User{
ID: "admin-1",
UserName: "admin",
Name: "Admin User",
IsAdmin: true,
NewPassword: "adminpass",
}
regularUser = model.User{
ID: "user-1",
UserName: "regular",
Name: "Regular User",
IsAdmin: false,
NewPassword: "userpass",
}
// Create test libraries
library1 = model.Library{
ID: 1,
Name: "Test Library 1",
Path: "/music/library1",
}
library2 = model.Library{
ID: 2,
Name: "Test Library 2",
Path: "/music/library2",
}
// Store in mock datastore
Expect(ds.User(context.TODO()).Put(&adminUser)).To(Succeed())
Expect(ds.User(context.TODO()).Put(&regularUser)).To(Succeed())
Expect(ds.Library(context.TODO()).Put(&library1)).To(Succeed())
Expect(ds.Library(context.TODO()).Put(&library2)).To(Succeed())
})
Describe("Library CRUD Operations", func() {
Context("as admin user", func() {
var adminToken string
BeforeEach(func() {
var err error
adminToken, err = auth.CreateToken(&adminUser)
Expect(err).ToNot(HaveOccurred())
})
Describe("GET /api/library", func() {
It("returns all libraries", func() {
req := createAuthenticatedRequest("GET", "/library", nil, adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var libraries []model.Library
err := json.Unmarshal(w.Body.Bytes(), &libraries)
Expect(err).ToNot(HaveOccurred())
Expect(libraries).To(HaveLen(2))
Expect(libraries[0].Name).To(Equal("Test Library 1"))
Expect(libraries[1].Name).To(Equal("Test Library 2"))
})
})
Describe("GET /api/library/{id}", func() {
It("returns a specific library", func() {
req := createAuthenticatedRequest("GET", "/library/1", nil, adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var library model.Library
err := json.Unmarshal(w.Body.Bytes(), &library)
Expect(err).ToNot(HaveOccurred())
Expect(library.Name).To(Equal("Test Library 1"))
Expect(library.Path).To(Equal("/music/library1"))
})
It("returns 404 for non-existent library", func() {
req := createAuthenticatedRequest("GET", "/library/999", nil, adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns 400 for invalid library ID", func() {
req := createAuthenticatedRequest("GET", "/library/invalid", nil, adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
})
})
Describe("POST /api/library", func() {
It("creates a new library", func() {
newLibrary := model.Library{
Name: "New Library",
Path: "/music/new",
}
body, _ := json.Marshal(newLibrary)
req := createAuthenticatedRequest("POST", "/library", bytes.NewBuffer(body), adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusCreated))
var created model.Library
err := json.Unmarshal(w.Body.Bytes(), &created)
Expect(err).ToNot(HaveOccurred())
Expect(created.Name).To(Equal("New Library"))
Expect(created.Path).To(Equal("/music/new"))
})
It("validates required fields", func() {
invalidLibrary := model.Library{
Name: "", // Missing name
Path: "/music/invalid",
}
body, _ := json.Marshal(invalidLibrary)
req := createAuthenticatedRequest("POST", "/library", bytes.NewBuffer(body), adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(w.Body.String()).To(ContainSubstring("Library name is required"))
})
It("validates path field", func() {
invalidLibrary := model.Library{
Name: "Valid Name",
Path: "", // Missing path
}
body, _ := json.Marshal(invalidLibrary)
req := createAuthenticatedRequest("POST", "/library", bytes.NewBuffer(body), adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(w.Body.String()).To(ContainSubstring("Library path is required"))
})
})
Describe("PUT /api/library/{id}", func() {
It("updates an existing library", func() {
updatedLibrary := model.Library{
Name: "Updated Library 1",
Path: "/music/updated",
}
body, _ := json.Marshal(updatedLibrary)
req := createAuthenticatedRequest("PUT", "/library/1", bytes.NewBuffer(body), adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var updated model.Library
err := json.Unmarshal(w.Body.Bytes(), &updated)
Expect(err).ToNot(HaveOccurred())
Expect(updated.ID).To(Equal(1))
Expect(updated.Name).To(Equal("Updated Library 1"))
Expect(updated.Path).To(Equal("/music/updated"))
})
It("validates required fields on update", func() {
invalidLibrary := model.Library{
Name: "",
Path: "/music/path",
}
body, _ := json.Marshal(invalidLibrary)
req := createAuthenticatedRequest("PUT", "/library/1", bytes.NewBuffer(body), adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
})
})
Describe("DELETE /api/library/{id}", func() {
It("deletes an existing library", func() {
req := createAuthenticatedRequest("DELETE", "/library/1", nil, adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusNoContent))
})
It("returns 404 for non-existent library", func() {
req := createAuthenticatedRequest("DELETE", "/library/999", nil, adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
})
Context("as regular user", func() {
var userToken string
BeforeEach(func() {
var err error
userToken, err = auth.CreateToken(&regularUser)
Expect(err).ToNot(HaveOccurred())
})
It("denies access to library management endpoints", func() {
endpoints := []string{
"GET /library",
"POST /library",
"GET /library/1",
"PUT /library/1",
"DELETE /library/1",
}
for _, endpoint := range endpoints {
parts := Split(endpoint, " ")
method, path := parts[0], parts[1]
req := createAuthenticatedRequest(method, path, nil, userToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusForbidden))
}
})
})
Context("without authentication", func() {
It("denies access to library management endpoints", func() {
req := createUnauthenticatedRequest("GET", "/library", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
})
})
Describe("User-Library Association Operations", func() {
Context("as admin user", func() {
var adminToken string
BeforeEach(func() {
var err error
adminToken, err = auth.CreateToken(&adminUser)
Expect(err).ToNot(HaveOccurred())
})
Describe("GET /api/user/{id}/library", func() {
It("returns user's libraries", func() {
// Set up user libraries
err := ds.User(context.TODO()).SetUserLibraries(regularUser.ID, []int{1, 2})
Expect(err).ToNot(HaveOccurred())
req := createAuthenticatedRequest("GET", fmt.Sprintf("/user/%s/library", regularUser.ID), nil, adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var libraries []model.Library
err = json.Unmarshal(w.Body.Bytes(), &libraries)
Expect(err).ToNot(HaveOccurred())
Expect(libraries).To(HaveLen(2))
})
It("returns 404 for non-existent user", func() {
req := createAuthenticatedRequest("GET", "/user/non-existent/library", nil, adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
Describe("PUT /api/user/{id}/library", func() {
It("sets user's libraries", func() {
request := map[string][]int{
"libraryIds": {1, 2},
}
body, _ := json.Marshal(request)
req := createAuthenticatedRequest("PUT", fmt.Sprintf("/user/%s/library", regularUser.ID), bytes.NewBuffer(body), adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var libraries []model.Library
err := json.Unmarshal(w.Body.Bytes(), &libraries)
Expect(err).ToNot(HaveOccurred())
Expect(libraries).To(HaveLen(2))
})
It("validates library IDs exist", func() {
request := map[string][]int{
"libraryIds": {999}, // Non-existent library
}
body, _ := json.Marshal(request)
req := createAuthenticatedRequest("PUT", fmt.Sprintf("/user/%s/library", regularUser.ID), bytes.NewBuffer(body), adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(w.Body.String()).To(ContainSubstring("Library ID 999 does not exist"))
})
It("requires at least one library for regular users", func() {
request := map[string][]int{
"libraryIds": {}, // Empty libraries
}
body, _ := json.Marshal(request)
req := createAuthenticatedRequest("PUT", fmt.Sprintf("/user/%s/library", regularUser.ID), bytes.NewBuffer(body), adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(w.Body.String()).To(ContainSubstring("At least one library must be assigned"))
})
It("prevents manual assignment to admin users", func() {
request := map[string][]int{
"libraryIds": {1},
}
body, _ := json.Marshal(request)
req := createAuthenticatedRequest("PUT", fmt.Sprintf("/user/%s/library", adminUser.ID), bytes.NewBuffer(body), adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(w.Body.String()).To(ContainSubstring("Cannot manually assign libraries to admin users"))
})
})
})
Context("as regular user", func() {
var userToken string
BeforeEach(func() {
var err error
userToken, err = auth.CreateToken(&regularUser)
Expect(err).ToNot(HaveOccurred())
})
It("denies access to user-library association endpoints", func() {
req := createAuthenticatedRequest("GET", fmt.Sprintf("/user/%s/library", regularUser.ID), nil, userToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusForbidden))
})
})
})
})
// Helper functions
func createAuthenticatedRequest(method, path string, body *bytes.Buffer, token string) *http.Request {
if body == nil {
body = &bytes.Buffer{}
}
req := httptest.NewRequest(method, path, body)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
return req
}
func createUnauthenticatedRequest(method, path string, body *bytes.Buffer) *http.Request {
if body == nil {
body = &bytes.Buffer{}
}
req := httptest.NewRequest(method, path, body)
req.Header.Set("Content-Type", "application/json")
return req
}
// Helper function to split strings
func Split(s, sep string) []string {
var result []string
start := 0
for i, r := range s {
if string(r) == sep {
result = append(result, s[start:i])
start = i + 1
}
}
result = append(result, s[start:])
return result
}

View file

@ -66,6 +66,8 @@ func (n *Router) routes() http.Handler {
n.addConfigRoute(r)
n.addKeepAliveRoute(r)
n.addInsightsRoute(r)
n.addLibraryRoute(r)
n.addUserLibraryRoute(r)
})
return r

View file

@ -1,8 +1,10 @@
package tests
import (
"slices"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/model"
"golang.org/x/exp/maps"
)
type MockLibraryRepo struct {
@ -22,7 +24,54 @@ func (m *MockLibraryRepo) GetAll(...model.QueryOptions) (model.Libraries, error)
if m.Err != nil {
return nil, m.Err
}
return maps.Values(m.Data), nil
var libraries model.Libraries
for _, lib := range m.Data {
libraries = append(libraries, lib)
}
// Sort by ID for predictable order
slices.SortFunc(libraries, func(a, b model.Library) int {
return a.ID - b.ID
})
return libraries, nil
}
func (m *MockLibraryRepo) CountAll(qo ...model.QueryOptions) (int64, error) {
if m.Err != nil {
return 0, m.Err
}
// If no query options, return total count
if len(qo) == 0 || qo[0].Filters == nil {
return int64(len(m.Data)), nil
}
// Handle squirrel.Eq filter for ID validation
if eq, ok := qo[0].Filters.(squirrel.Eq); ok {
if idFilter, exists := eq["id"]; exists {
if ids, isSlice := idFilter.([]int); isSlice {
count := 0
for _, id := range ids {
if _, exists := m.Data[id]; exists {
count++
}
}
return int64(count), nil
}
}
}
// Default to total count for other filters
return int64(len(m.Data)), nil
}
func (m *MockLibraryRepo) Get(id int) (*model.Library, error) {
if m.Err != nil {
return nil, m.Err
}
if lib, ok := m.Data[id]; ok {
return &lib, nil
}
return nil, model.ErrNotFound
}
func (m *MockLibraryRepo) GetPath(id int) (string, error) {
@ -35,6 +84,63 @@ func (m *MockLibraryRepo) GetPath(id int) (string, error) {
return "", model.ErrNotFound
}
func (m *MockLibraryRepo) Put(library *model.Library) error {
if m.Err != nil {
return m.Err
}
if m.Data == nil {
m.Data = make(map[int]model.Library)
}
m.Data[library.ID] = *library
return nil
}
func (m *MockLibraryRepo) Delete(id int) error {
if m.Err != nil {
return m.Err
}
if _, ok := m.Data[id]; !ok {
return model.ErrNotFound
}
delete(m.Data, id)
return nil
}
func (m *MockLibraryRepo) StoreMusicFolder() error {
if m.Err != nil {
return m.Err
}
return nil
}
func (m *MockLibraryRepo) AddArtist(id int, artistID string) error {
if m.Err != nil {
return m.Err
}
return nil
}
func (m *MockLibraryRepo) ScanBegin(id int, fullScan bool) error {
if m.Err != nil {
return m.Err
}
return nil
}
func (m *MockLibraryRepo) ScanEnd(id int) error {
if m.Err != nil {
return m.Err
}
return nil
}
func (m *MockLibraryRepo) ScanInProgress() (bool, error) {
if m.Err != nil {
return false, m.Err
}
return false, nil
}
func (m *MockLibraryRepo) RefreshStats(id int) error {
return nil
}

View file

@ -2,6 +2,7 @@ package tests
import (
"encoding/base64"
"fmt"
"strings"
"time"
@ -11,14 +12,16 @@ import (
func CreateMockUserRepo() *MockedUserRepo {
return &MockedUserRepo{
Data: map[string]*model.User{},
Data: map[string]*model.User{},
UserLibraries: map[string][]int{},
}
}
type MockedUserRepo struct {
model.UserRepository
Error error
Data map[string]*model.User
Error error
Data map[string]*model.User
UserLibraries map[string][]int // userID -> libraryIDs
}
func (u *MockedUserRepo) CountAll(qo ...model.QueryOptions) (int64, error) {
@ -55,6 +58,18 @@ func (u *MockedUserRepo) FindByUsernameWithPassword(username string) (*model.Use
return u.FindByUsername(username)
}
func (u *MockedUserRepo) Get(id string) (*model.User, error) {
if u.Error != nil {
return nil, u.Error
}
for _, usr := range u.Data {
if usr.ID == id {
return usr, nil
}
}
return nil, model.ErrNotFound
}
func (u *MockedUserRepo) UpdateLastLoginAt(id string) error {
for _, usr := range u.Data {
if usr.ID == id {
@ -81,15 +96,31 @@ func (u *MockedUserRepo) GetUserLibraries(userID string) (model.Libraries, error
if u.Error != nil {
return nil, u.Error
}
// Mock: return empty libraries for now
return model.Libraries{}, nil
libraryIDs, exists := u.UserLibraries[userID]
if !exists {
return model.Libraries{}, nil
}
// Mock: Create libraries based on IDs
var libraries model.Libraries
for _, id := range libraryIDs {
libraries = append(libraries, model.Library{
ID: id,
Name: fmt.Sprintf("Test Library %d", id),
Path: fmt.Sprintf("/music/library%d", id),
})
}
return libraries, nil
}
func (u *MockedUserRepo) SetUserLibraries(userID string, libraryIDs []int) error {
if u.Error != nil {
return u.Error
}
// Mock: do nothing for now
if u.UserLibraries == nil {
u.UserLibraries = make(map[string][]int)
}
u.UserLibraries[userID] = libraryIDs
return nil
}