From 1e8d3c710776271fb150fae16e72a9381070d602 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 6 Mar 2026 14:15:04 -0500 Subject: [PATCH] test: rename test files to follow Go conventions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename test helper files from .go to _test.go suffix to comply with Go testing conventions. This ensures proper test file recognition by the Go toolchain and improves build organization. - library_test_comprehensive.go → library_test_comprehensive_test.go - test_helpers.go → test_helpers_test.go - test_helpers_db.go → test_helpers_db_test.go --- .../tests/library_test_comprehensive_test.go | 571 +++++++++++++ cmd/server/tests/test_helpers_db_test.go | 158 ++++ cmd/server/tests/test_helpers_test.go | 757 ++++++++++++++++++ 3 files changed, 1486 insertions(+) create mode 100644 cmd/server/tests/library_test_comprehensive_test.go create mode 100644 cmd/server/tests/test_helpers_db_test.go create mode 100644 cmd/server/tests/test_helpers_test.go diff --git a/cmd/server/tests/library_test_comprehensive_test.go b/cmd/server/tests/library_test_comprehensive_test.go new file mode 100644 index 0000000..d1beec2 --- /dev/null +++ b/cmd/server/tests/library_test_comprehensive_test.go @@ -0,0 +1,571 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +// TestLibraryManagementEndpoints tests library management operations +func TestLibraryManagementEndpoints(t *testing.T) { + libraryID := uuid.New() + + t.Run("POST /api/libraries - Create library without admin role", func(t *testing.T) { + payload := map[string]interface{}{ + "name": "New Library", + "description": "Test description", + "type": "ebooks", + } + jsonData, _ := json.Marshal(payload) + + req := httptest.NewRequest("POST", "/api/libraries", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer user-token") + req.Header.Set("X-User-Role", "user") + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + userRole := r.Header.Get("X-User-Role") + if userRole != "admin" { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"error":"admin access required"}`)) + return + } + + w.WriteHeader(http.StatusCreated) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusForbidden, rr.Code) + }) + + t.Run("POST /api/libraries - Create library with invalid type", func(t *testing.T) { + payload := map[string]interface{}{ + "name": "New Library", + "description": "Test description", + "type": "invalid-type", + } + jsonData, _ := json.Marshal(payload) + + req := httptest.NewRequest("POST", "/api/libraries", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer admin-token") + req.Header.Set("X-User-Role", "admin") + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"invalid request"}`)) + return + } + + libType := req["type"].(string) + if libType != "ebooks" && libType != "comics" && libType != "manga" { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"invalid library type"}`)) + return + } + + w.WriteHeader(http.StatusCreated) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusBadRequest, rr.Code) + }) + + t.Run("POST /api/libraries - Create library with missing required fields", func(t *testing.T) { + payload := map[string]interface{}{ + "description": "Test description", + } + jsonData, _ := json.Marshal(payload) + + req := httptest.NewRequest("POST", "/api/libraries", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer admin-token") + req.Header.Set("X-User-Role", "admin") + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"invalid request"}`)) + return + } + + if _, ok := req["name"]; !ok { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"name is required"}`)) + return + } + + if _, ok := req["type"]; !ok { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"type is required"}`)) + return + } + + w.WriteHeader(http.StatusCreated) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusBadRequest, rr.Code) + }) + + t.Run("GET /api/libraries/:id - Get library with invalid UUID", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/libraries/invalid-uuid", nil) + req.Header.Set("Authorization", "Bearer admin-token") + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"invalid library id"}`)) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusBadRequest, rr.Code) + }) + + t.Run("GET /api/libraries/:id - Get non-existent library", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/libraries/"+uuid.New().String(), nil) + req.Header.Set("Authorization", "Bearer admin-token") + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"error":"library not found"}`)) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusNotFound, rr.Code) + }) + + t.Run("PUT /api/libraries/:id - Update library without admin role", func(t *testing.T) { + payload := map[string]interface{}{ + "name": "Updated Library", + "description": "Updated description", + } + jsonData, _ := json.Marshal(payload) + + req := httptest.NewRequest("PUT", "/api/libraries/"+libraryID.String(), bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer user-token") + req.Header.Set("X-User-Role", "user") + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + userRole := r.Header.Get("X-User-Role") + if userRole != "admin" { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"error":"admin access required"}`)) + return + } + + w.WriteHeader(http.StatusOK) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusForbidden, rr.Code) + }) + + t.Run("DELETE /api/libraries/:id - Delete library without admin role", func(t *testing.T) { + req := httptest.NewRequest("DELETE", "/api/libraries/"+libraryID.String(), nil) + req.Header.Set("Authorization", "Bearer user-token") + req.Header.Set("X-User-Role", "user") + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + userRole := r.Header.Get("X-User-Role") + if userRole != "admin" { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"error":"admin access required"}`)) + return + } + + w.WriteHeader(http.StatusNoContent) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusForbidden, rr.Code) + }) + + t.Run("DELETE /api/libraries/:id - Delete library with invalid UUID", func(t *testing.T) { + req := httptest.NewRequest("DELETE", "/api/libraries/invalid-uuid", nil) + req.Header.Set("Authorization", "Bearer admin-token") + req.Header.Set("X-User-Role", "admin") + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"invalid library id"}`)) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusBadRequest, rr.Code) + }) +} + +// TestLibraryFolders tests library folder management +func TestLibraryFolders(t *testing.T) { + libraryID := uuid.New() + + t.Run("POST /api/libraries/:id/folders - Add folder without admin role", func(t *testing.T) { + payload := map[string]interface{}{ + "folder_path": "/path/to/folder", + } + jsonData, _ := json.Marshal(payload) + + req := httptest.NewRequest("POST", "/api/libraries/"+libraryID.String()+"/folders", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer user-token") + req.Header.Set("X-User-Role", "user") + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + userRole := r.Header.Get("X-User-Role") + if userRole != "admin" { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"error":"admin access required"}`)) + return + } + + w.WriteHeader(http.StatusCreated) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusForbidden, rr.Code) + }) + + t.Run("POST /api/libraries/:id/folders - Add folder with invalid library ID", func(t *testing.T) { + payload := map[string]interface{}{ + "folder_path": "/path/to/folder", + } + jsonData, _ := json.Marshal(payload) + + req := httptest.NewRequest("POST", "/api/libraries/invalid-uuid/folders", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer admin-token") + req.Header.Set("X-User-Role", "admin") + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"invalid library id"}`)) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusBadRequest, rr.Code) + }) + + t.Run("POST /api/libraries/:id/folders - Add folder with missing path", func(t *testing.T) { + payload := map[string]interface{}{} + jsonData, _ := json.Marshal(payload) + + req := httptest.NewRequest("POST", "/api/libraries/"+libraryID.String()+"/folders", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer admin-token") + req.Header.Set("X-User-Role", "admin") + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"invalid request"}`)) + return + } + + if _, ok := req["folder_path"]; !ok { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"folder_path is required"}`)) + return + } + + w.WriteHeader(http.StatusCreated) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusBadRequest, rr.Code) + }) + + t.Run("GET /api/libraries/:id/folders - Get folders without admin role", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/libraries/"+libraryID.String()+"/folders", nil) + req.Header.Set("Authorization", "Bearer user-token") + req.Header.Set("X-User-Role", "user") + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + userRole := r.Header.Get("X-User-Role") + if userRole != "admin" { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"error":"admin access required"}`)) + return + } + + w.WriteHeader(http.StatusOK) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusForbidden, rr.Code) + }) + + t.Run("DELETE /api/libraries/:id/folders - Delete folder without admin role", func(t *testing.T) { + payload := map[string]interface{}{ + "folder_path": "/path/to/folder", + } + jsonData, _ := json.Marshal(payload) + + req := httptest.NewRequest("DELETE", "/api/libraries/"+libraryID.String()+"/folders", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer user-token") + req.Header.Set("X-User-Role", "user") + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + userRole := r.Header.Get("X-User-Role") + if userRole != "admin" { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"error":"admin access required"}`)) + return + } + + w.WriteHeader(http.StatusNoContent) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusForbidden, rr.Code) + }) +} + +// TestLibraryVisibility tests library visibility controls +func TestLibraryVisibility(t *testing.T) { + libraryID := uuid.New() + userID := uuid.New() + + t.Run("POST /api/libraries/visibility - Set visibility without auth", func(t *testing.T) { + payload := map[string]interface{}{ + "library_id": libraryID.String(), + "is_visible": true, + } + jsonData, _ := json.Marshal(payload) + + req := httptest.NewRequest("POST", "/api/libraries/visibility", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"message":"missing or malformed jwt"}`)) + return + } + + w.WriteHeader(http.StatusOK) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusUnauthorized, rr.Code) + }) + + t.Run("POST /api/libraries/visibility - Set visibility with invalid library ID", func(t *testing.T) { + payload := map[string]interface{}{ + "library_id": "invalid-uuid", + "is_visible": true, + } + jsonData, _ := json.Marshal(payload) + + req := httptest.NewRequest("POST", "/api/libraries/visibility", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer valid-token") + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"invalid request"}`)) + return + } + + libID := req["library_id"].(string) + if _, err := uuid.Parse(libID); err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"invalid library id"}`)) + return + } + + w.WriteHeader(http.StatusOK) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusBadRequest, rr.Code) + }) + + t.Run("POST /api/libraries/visibility - Set visibility successfully", func(t *testing.T) { + payload := map[string]interface{}{ + "library_id": libraryID.String(), + "is_visible": true, + } + jsonData, _ := json.Marshal(payload) + + req := httptest.NewRequest("POST", "/api/libraries/visibility", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer valid-token") + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + visibility := map[string]interface{}{ + "id": uuid.New().String(), + "user_id": userID.String(), + "library_id": libraryID.String(), + "is_visible": true, + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(visibility) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + }) + + t.Run("GET /api/libraries/visible - Get visible libraries without auth", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/libraries/visible", nil) + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"message":"missing or malformed jwt"}`)) + return + } + + w.WriteHeader(http.StatusOK) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusUnauthorized, rr.Code) + }) + + t.Run("GET /api/libraries/visible - Get visible libraries with auth", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/libraries/visible", nil) + req.Header.Set("Authorization", "Bearer valid-token") + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + libraries := []map[string]interface{}{ + { + "id": libraryID.String(), + "name": "Visible Library", + "is_visible": true, + }, + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(libraries) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + }) +} + +// TestLibraryStats tests library statistics +func TestLibraryStats(t *testing.T) { + libraryID := uuid.New() + + t.Run("GET /api/libraries/:id/stats - Get stats without admin role", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/libraries/"+libraryID.String()+"/stats", nil) + req.Header.Set("Authorization", "Bearer user-token") + req.Header.Set("X-User-Role", "user") + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + userRole := r.Header.Get("X-User-Role") + if userRole != "admin" { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"error":"admin access required"}`)) + return + } + + w.WriteHeader(http.StatusOK) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusForbidden, rr.Code) + }) + + t.Run("GET /api/libraries/:id/stats - Get stats with invalid library ID", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/libraries/invalid-uuid/stats", nil) + req.Header.Set("Authorization", "Bearer admin-token") + req.Header.Set("X-User-Role", "admin") + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"invalid library id"}`)) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusBadRequest, rr.Code) + }) + + t.Run("GET /api/libraries/:id/stats - Get stats successfully", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/libraries/"+libraryID.String()+"/stats", nil) + req.Header.Set("Authorization", "Bearer admin-token") + req.Header.Set("X-User-Role", "admin") + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + stats := map[string]interface{}{ + "media_count": 42, + "total_size": 1024000, + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(stats) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + }) +} + +// TestLibraryTypes tests library type retrieval +func TestLibraryTypes(t *testing.T) { + t.Run("GET /api/libraries/types - Get all library types", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/libraries/types", nil) + req.Header.Set("Authorization", "Bearer valid-token") + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + types := []map[string]interface{}{ + { + "id": uuid.New().String(), + "name": "ebooks", + "description": "Ebook files including EPUB, PDF, MOBI, etc.", + "allowed_extensions": []string{".epub", ".pdf", ".mobi"}, + }, + { + "id": uuid.New().String(), + "name": "comics", + "description": "Comic book archives and image formats", + "allowed_extensions": []string{".cbz", ".cbr", ".pdf"}, + }, + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(types) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "ebooks") + assert.Contains(t, rr.Body.String(), "comics") + }) +} diff --git a/cmd/server/tests/test_helpers_db_test.go b/cmd/server/tests/test_helpers_db_test.go new file mode 100644 index 0000000..e5be512 --- /dev/null +++ b/cmd/server/tests/test_helpers_db_test.go @@ -0,0 +1,158 @@ +package main + +import ( + "bookhoard/internal/database" + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Helper functions for database verification and test utilities +// These functions reduce code duplication and ensure consistent database state verification + +// verifyDeviceCreated verifies a device exists in database with expected values +func verifyDeviceCreated(t *testing.T, db *database.Queries, deviceID uuid.UUID, expectedName, expectedType, expectedIdentifier string) { + pgDeviceID := pgtype.UUID{Bytes: [16]byte(deviceID), Valid: true} + device, err := db.GetDevice(context.Background(), pgDeviceID) + require.NoError(t, err, "Device should exist in database") + + assert.Equal(t, expectedName, device.DeviceName, "Device name should match") + assert.Equal(t, expectedType, device.DeviceType, "Device type should match") + assert.Equal(t, expectedIdentifier, device.DeviceIdentifier, "Device identifier should match") + assert.NotEmpty(t, device.AuthToken, "Device should have auth token") +} + +// verifyDeviceDeleted verifies a device does not exist in database +func verifyDeviceDeleted(t *testing.T, db *database.Queries, deviceID uuid.UUID) { + pgDeviceID := pgtype.UUID{Bytes: [16]byte(deviceID), Valid: true} + _, err := db.GetDevice(context.Background(), pgDeviceID) + assert.Error(t, err, "Device should be deleted from database") +} + +// verifyUserField verifies a user has expected field value in database +func verifyUserField(t *testing.T, db *database.Queries, userID uuid.UUID, field string, expected interface{}) { + pgUserID := pgtype.UUID{Bytes: [16]byte(userID), Valid: true} + user, err := db.GetUser(context.Background(), pgUserID) + require.NoError(t, err, "User should exist in database") + + switch field { + case "email": + if em, ok := expected.(string); ok { + assert.Equal(t, em, user.Email, "Email should match") + } + case "first_name": + if fn, ok := expected.(string); ok { + assert.Equal(t, fn, user.FirstName.String, "First name should match") + } + case "last_name": + if ln, ok := expected.(string); ok { + assert.Equal(t, ln, user.LastName.String, "Last name should match") + } + case "username": + if un, ok := expected.(string); ok { + assert.Equal(t, un, user.Username, "Username should match") + } + case "theme": + if th, ok := expected.(string); ok { + assert.Equal(t, th, user.Theme.String, "Theme should match") + } + } +} + +// verifyMediaItemInDB verifies a media item exists in database +func verifyMediaItemInDB(t *testing.T, db *database.Queries, mediaID uuid.UUID) { + pgMediaID := pgtype.UUID{Bytes: [16]byte(mediaID), Valid: true} + _, err := db.GetMediaItem(context.Background(), pgMediaID) + require.NoError(t, err, "Media item should exist in database") +} + +// verifyMediaItemDeleted verifies a media item does not exist in database +func verifyMediaItemDeleted(t *testing.T, db *database.Queries, mediaID uuid.UUID) { + pgMediaID := pgtype.UUID{Bytes: [16]byte(mediaID), Valid: true} + _, err := db.GetMediaItem(context.Background(), pgMediaID) + assert.Error(t, err, "Media item should be deleted from database") +} + +// createTestLibraryWithFolder creates a test library with optional folder +func createTestLibraryWithFolder(t *testing.T, ts *httptest.Server, token, name string, withFolder bool) string { + libReq := map[string]interface{}{ + "name": name, + "type": "ebooks", + } + libBody, _ := json.Marshal(libReq) + + libHTTP, _ := http.NewRequest("POST", ts.URL+"/api/libraries", bytes.NewBuffer(libBody)) + libHTTP.Header.Set("Content-Type", "application/json") + libHTTP.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(libHTTP) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusCreated, resp.StatusCode, "Library creation should succeed") + + var libResponse map[string]interface{} + json.NewDecoder(resp.Body).Decode(&libResponse) + libraryID := libResponse["id"].(string) + + if withFolder { + folderReq := map[string]interface{}{ + "folder_path": "/app/uploads", + } + folderBody, _ := json.Marshal(folderReq) + + folderHTTP, _ := http.NewRequest("POST", fmt.Sprintf("%s/api/libraries/%s/folders", ts.URL, libraryID), bytes.NewBuffer(folderBody)) + folderHTTP.Header.Set("Content-Type", "application/json") + folderHTTP.Header.Set("Authorization", "Bearer "+token) + + folderResp, err := client.Do(folderHTTP) + require.NoError(t, err) + defer folderResp.Body.Close() + require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Folder creation should succeed") + } + + return libraryID +} + +// runConcurrent executes functions concurrently and waits for all to complete +func runConcurrent(t *testing.T, maxConcurrent int, fns []func() error) []error { + if len(fns) == 0 { + return nil + } + if len(fns) < maxConcurrent { + maxConcurrent = len(fns) + } + + errors := make(chan error, len(fns)) + var wg sync.WaitGroup + + for i := 0; i < maxConcurrent; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + if err := fns[idx](); err != nil { + errors <- err + } + }(i) + } + + wg.Wait() + close(errors) + + var allErrors []error + for err := range errors { + allErrors = append(allErrors, err) + } + + return allErrors +} diff --git a/cmd/server/tests/test_helpers_test.go b/cmd/server/tests/test_helpers_test.go new file mode 100644 index 0000000..89234b2 --- /dev/null +++ b/cmd/server/tests/test_helpers_test.go @@ -0,0 +1,757 @@ +package main + +import ( + "bookhoard/internal/config" + "bookhoard/internal/database" + "bookhoard/internal/handlers" + "bookhoard/internal/middleware" + ratelimit "bookhoard/internal/middleware" + "bookhoard/internal/router" + "bookhoard/internal/services" + wsync "bookhoard/internal/sync" + "bytes" + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/go-playground/validator/v10" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/labstack/echo/v5" + echomiddleware "github.com/labstack/echo/v5/middleware" + "github.com/stretchr/testify/require" +) + +// CustomValidator wraps the go-playground validator +type CustomValidator struct { + validator *validator.Validate +} + +func (cv *CustomValidator) Validate(i interface{}) error { + return cv.validator.Struct(i) +} + +// TestDeviceSetup provides a complete, isolated test environment for device tests +type TestDeviceSetup struct { + Server *httptest.Server + DB *database.Queries + Config *config.Config + User UserTestData + Device DeviceTestData + Library LibraryTestData + UserToken string +} + +type LibraryTestData struct { + ID string + Name string + Type string +} + +type UserTestData struct { + ID uuid.UUID + Email string + Username string + Password string + Token string +} + +type DeviceTestData struct { + ID uuid.UUID + Name string + Type string + Identifier string + AuthToken string + PGType database.Devices +} + +// TestServerSetup manages the lifecycle of a test server with proper resource cleanup +type TestServerSetup struct { + Server *httptest.Server + DB *database.Queries + DBPool *pgxpool.Pool + Config *config.Config + ConnManager *wsync.ConnectionManager + QueueProcessor *wsync.SyncQueueProcessor + CleanupCancel context.CancelFunc + QueueCtx context.Context + QueueCancel context.CancelFunc + Token string + RegularToken string + mu sync.Mutex + closed bool +} + +// Close cleans up all resources in the correct order +func (s *TestServerSetup) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.closed { + return nil + } + + // Stop queue processor first + if s.QueueCancel != nil { + s.QueueCancel() + s.QueueCancel = nil + } + + // Reset global worker instance + services.WorkerInstance = nil + + // Stop connection manager cleanup task + if s.CleanupCancel != nil { + s.CleanupCancel() + s.CleanupCancel = nil + } + + // Close HTTP server + if s.Server != nil { + s.Server.Close() + s.Server = nil + } + + // Close database pool (this waits for all connections to be released) + if s.DBPool != nil { + s.DBPool.Close() + s.DBPool = nil + } + + s.closed = true + return nil +} + +// Helper functions for testing +func containsPrefix(s, prefix string) bool { + return len(s) >= len(prefix) && s[:len(prefix)] == prefix +} + +func contains(s, substr string) bool { + return strings.Contains(s, substr) +} + +func trimSpace(s string) string { + return strings.TrimSpace(s) +} + +// isRunningInContainer detects if tests are running inside a Docker container +func isRunningInContainer() bool { + // Check for container-specific marker file + if _, err := os.Stat("/.dockerenv"); err == nil { + return true + } + + // Check if /app/uploads exists (container path) + if _, err := os.Stat("/app/uploads"); err == nil { + return true + } + + // Check environment variable (explicit override) + if os.Getenv("TEST_IN_CONTAINER") == "true" { + return true + } + + return false +} + +// getUploadPath returns the appropriate upload path based on runtime environment +func getUploadPath() string { + // Check for explicit override first + if path := os.Getenv("TEST_UPLOAD_PATH"); path != "" { + return path + } + + if isRunningInContainer() { + return "/app/uploads" // Container path (right side of volume mount) + } + + return "./uploads" // Host path (left side of volume mount) +} + +// getCachePath returns the appropriate cache path based on runtime environment +func getCachePath() string { + // Check for explicit override first + if path := os.Getenv("TEST_CACHE_PATH"); path != "" { + return path + } + + if isRunningInContainer() { + return "/app/cache/kepub" // Container path (volume mount) + } + + // Note: This is a Docker volume on host, not a folder + // Tests using this should handle the volume appropriately + return "/app/cache/kepub" +} + +// setupDeviceTest creates a complete test environment for device tests +func setupDeviceTest(t *testing.T) *TestDeviceSetup { + serverSetup := setupTestServer(t) + + // Create user ONCE with known credentials + user := createTestUserOnce(t, serverSetup.DB) + + // Login to get token + token := loginUserWithCredentials(t, serverSetup.Server, user.Email, user.Password) + + return &TestDeviceSetup{ + Server: serverSetup.Server, + DB: serverSetup.DB, + Config: serverSetup.Config, + User: user, + UserToken: token, + } +} + +// createTestUserOnce returns the pre-created test user info +func createTestUserOnce(t *testing.T, db *database.Queries) UserTestData { + ctx := context.Background() + user, err := db.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal") + require.NoError(t, err, "Test user should exist (created by setupTestServer)") + userUUID, err := uuid.FromBytes(user.ID.Bytes[0:16]) + require.NoError(t, err, "Should parse user UUID") + return UserTestData{ + ID: userUUID, + Email: "testuser@tests.bookhoard.internal", + Username: "testuser", + Password: "Test@Pass123!", + } +} + +// createRegularUserOnce creates a regular (non-admin) test user with unique credentials +func createRegularUserOnce(t *testing.T, db *database.Queries) UserTestData { + ctx := context.Background() + uniqueID := uuid.New().String()[:8] + email := fmt.Sprintf("regularuser-%s@tests.bookhoard.internal", uniqueID) + username := fmt.Sprintf("regularuser-%s", uniqueID) + + passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq" + newUser, err := db.CreateUser(ctx, database.CreateUserParams{ + Email: email, + Username: username, + PasswordHash: passwordHash, + FirstName: pgtype.Text{String: "Regular", Valid: true}, + LastName: pgtype.Text{String: "User", Valid: true}, + Role: "user", + }) + require.NoError(t, err, "Should create regular test user") + + userUUID, err := uuid.FromBytes(newUser.ID.Bytes[0:16]) + require.NoError(t, err, "Should parse user UUID") + + createDefaultCollectionsForUser(t, db, pgtype.UUID{Bytes: userUUID, Valid: true}) + + t.Cleanup(func() { + ctx := context.Background() + db.DeleteUser(ctx, pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}) + }) + + return UserTestData{ + ID: userUUID, + Email: email, + Username: username, + Password: "Test@Pass123!", + } +} + +// uuidToPGType converts uuid.UUID to pgtype.UUID +func uuidToPGType(u uuid.UUID) pgtype.UUID { + return pgtype.UUID{Bytes: [16]byte(u), Valid: true} +} + +// createDefaultCollectionsForUser creates the 4 default system collections for a user +func createDefaultCollectionsForUser(t *testing.T, db *database.Queries, userID pgtype.UUID) { + ctx := context.Background() + defaultCollections := []struct { + Name string + Description string + Icon string + Color string + QueryType string + Priority int32 + }{ + {"continue-reading", "Books you're currently reading (0 < progress < 1)", "📖", "#7aa2f7", "continue-reading", 1}, + {"recently-added", "Newly added items to this library", "🆕", "#9ece6a", "recently-added", 2}, + {"recently-read", "Books you've finished (progress >= 1)", "✅", "#e0af68", "recently-read", 3}, + {"not-started", "Books you haven't read yet (progress = 0 or no record)", "📕", "#f7768e", "not-started", 4}, + } + + for _, col := range defaultCollections { + _, err := db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{ + UserID: userID, + Name: col.Name, + Description: pgtype.Text{String: col.Description, Valid: true}, + Icon: pgtype.Text{String: col.Icon, Valid: true}, + Color: pgtype.Text{String: col.Color, Valid: true}, + ShowOnDashboard: pgtype.Bool{Bool: true, Valid: true}, + QueryType: pgtype.Text{String: col.QueryType, Valid: true}, + Priority: pgtype.Int4{Int32: col.Priority, Valid: true}, + }) + require.NoError(t, err, "Should create default collection: "+col.Name) + } +} + +// loginUserWithCredentials performs explicit login with provided credentials +func loginUserWithCredentials(t *testing.T, ts *httptest.Server, email, password string) string { + loginRequest := map[string]interface{}{ + "login": email, + "password": password, + } + body, _ := json.Marshal(loginRequest) + + req, _ := http.NewRequest("POST", ts.URL+"/api/auth/login", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err, "Failed to login") + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode, "Login should succeed") + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + token, ok := result["access_token"].(string) + require.True(t, ok, "Should have access_token") + require.NotEmpty(t, token, "Access token should not be empty") + + return token +} + +// CreateDevice creates a test device for the TestDeviceSetup +func (s *TestDeviceSetup) CreateDevice(t *testing.T, deviceName, deviceType, deviceIdentifier string) *DeviceTestData { + deviceToken := fmt.Sprintf("dev_%s", uuid.New().String()) + + pgUserID := pgtype.UUID{Bytes: [16]byte(s.User.ID), Valid: true} + device, err := s.DB.CreateDevice(context.Background(), database.CreateDeviceParams{ + UserID: pgUserID, + DeviceName: deviceName, + DeviceType: deviceType, + DeviceIdentifier: deviceIdentifier, + AuthToken: deviceToken, + SyncEnabled: pgtype.Bool{Bool: true, Valid: true}, + AutoSync: pgtype.Bool{Bool: true, Valid: true}, + SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true}, + DeviceMetadata: []byte("{}"), + }) + require.NoError(t, err, "Should create device") + + deviceUUID, err := uuid.FromBytes(device.ID.Bytes[0:16]) + require.NoError(t, err, "Should parse device ID") + + return &DeviceTestData{ + ID: deviceUUID, + Name: deviceName, + Type: deviceType, + Identifier: deviceIdentifier, + AuthToken: deviceToken, + PGType: device, + } +} + +// CreateLibrary creates a test library for the TestDeviceSetup +func (s *TestDeviceSetup) CreateLibrary(t *testing.T, name, libraryType string) string { + ctx := context.Background() + + // Get the library_type_id for the specified type + libraryTypeRow, err := s.DB.GetLibraryTypeByName(ctx, libraryType) + require.NoError(t, err, "Should find library type") + + pgUserID := pgtype.UUID{Bytes: [16]byte(s.User.ID), Valid: true} + library, err := s.DB.CreateLibrary(ctx, database.CreateLibraryParams{ + Name: name, + Description: pgtype.Text{String: "Test library description", Valid: true}, + LibraryTypeID: libraryTypeRow.ID, + CreatedByAdminID: pgUserID, + }) + require.NoError(t, err, "Should create library") + + libraryUUID, err := uuid.FromBytes(library.ID.Bytes[0:16]) + require.NoError(t, err, "Should parse library ID") + + s.Library = LibraryTestData{ + ID: libraryUUID.String(), + Name: name, + Type: libraryType, + } + + return libraryUUID.String() +} + +// CreateCollection creates a test collection for the TestDeviceSetup +func (s *TestDeviceSetup) CreateCollection(t *testing.T, name string) string { + ctx := context.Background() + + pgUserID := pgtype.UUID{Bytes: [16]byte(s.User.ID), Valid: true} + collection, err := s.DB.CreateCollection(ctx, database.CreateCollectionParams{ + UserID: pgUserID, + Name: name, + Description: pgtype.Text{String: "Test collection description", Valid: true}, + Color: pgtype.Text{String: "#FF5733", Valid: true}, + Icon: pgtype.Text{String: "folder", Valid: true}, + }) + require.NoError(t, err, "Should create collection") + + collectionUUID, err := uuid.FromBytes(collection.ID.Bytes[0:16]) + require.NoError(t, err, "Should parse collection ID") + + return collectionUUID.String() +} + +// setupTestServer creates a test server with a test database +// Returns: *TestServerSetup with automatic cleanup via t.Cleanup +func setupTestServer(t *testing.T) *TestServerSetup { + // Load configuration using the same method as main application + cfg := config.LoadConfig() + + // Apply test-specific overrides + cfg.ServerPort = "0" // Use random port for tests + cfg.BaseURL = "http://localhost" + cfg.JWTSecret = "test-secret-key" + cfg.UploadPath = getUploadPath() + cfg.TestMode = true + cfg.RateLimitEnabled = false + cfg.RequestsPerMinute = 1000 + + // Connect to test database using the same method as main application + // Use max_conns=1 to prevent connection pool exhaustion during test runs + // (78 tests × 1 connection = 78 connections, well under PostgreSQL's 100 default max_connections) + dbConfig, err := pgxpool.ParseConfig(cfg.DatabaseURL()) + require.NoError(t, err, "Failed to parse database URL") + dbConfig.MaxConns = 1 + dbPool, err := pgxpool.NewWithConfig(context.Background(), dbConfig) + require.NoError(t, err, "Failed to connect to test database") + + queries := database.New(dbPool) + + // Create login attempt tracker + loginAttemptTracker := ratelimit.NewLoginAttemptTracker(5, 15*time.Minute, 5*time.Minute) + + // Create handlers + authHandler := handlers.NewAuthHandler(queries, cfg.JWTSecret, loginAttemptTracker) + libraryHandler := handlers.NewLibraryHandler(queries) + deviceHandler := handlers.NewDeviceHandler(queries, cfg.JWTSecret, cfg) + deviceAuthMiddleware := middleware.NewDeviceAuthMiddleware(queries) + + // Create WebSocket connection manager + connManager := wsync.NewConnectionManager() + cleanupCancel := connManager.StartCleanupTask() + + // Create sync queue processor with cancellable context + queueProcessor := wsync.NewSyncQueueProcessor(queries) + queueCtx, queueCancel := context.WithCancel(context.Background()) + go queueProcessor.Start(queueCtx) + + koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor) + wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware) + conflictHandler := handlers.NewConflictHandler(queries, connManager) + analyticsHandler := handlers.NewAnalyticsHandler(queries) + queueHandler := handlers.NewQueueHandler(queries, queueProcessor) + systemSettingsHandler := handlers.NewSystemSettingsHandler(queries) + + // Create refactored handlers (matching main.go) + libraryService := services.NewLibraryService(queries) + worker := services.NewWorker(3, connManager) + services.WorkerInstance = worker + jobsHandler := handlers.NewJobsHandler(queries, worker) + collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager) + dashboardService := services.NewDashboardService(queries) + dashboardHandler := handlers.NewDashboardHandler(queries) + mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker) + matchingHandler := handlers.NewMatchingHandler(queries, connManager) + + // Create conversion service for OPDS + conversionService := services.NewConversionService(queries, getCachePath()) + opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService) + + // Create Echo instance + e := echo.New() + + // Set up validator + v := validator.New() + if err := ratelimit.RegisterPasswordValidation(v); err != nil { + t.Fatal("Failed to register password validator:", err) + } + e.Validator = &CustomValidator{validator: v} + + // Middleware + e.Use(echomiddleware.RequestLogger()) + e.Use(echomiddleware.Recover()) + e.Use(echomiddleware.CORS()) + + // Setup routes using router package + routerConfig := &router.Config{ + Echo: e, + Queries: queries, + Cfg: cfg, + DBPool: dbPool, + AuthHandler: authHandler, + LibraryHandler: libraryHandler, + DeviceHandler: deviceHandler, + MediaHandler: mediaHandler, + MatchingHandler: matchingHandler, + KOReaderHandler: koreaderHandler, + WSHandler: wsHandler, + ConflictHandler: conflictHandler, + AnalyticsHandler: analyticsHandler, + QueueHandler: queueHandler, + SystemSettingsHandler: systemSettingsHandler, + CollectionHandler: collectionHandler, + DashboardHandler: dashboardHandler, + DashboardService: dashboardService, + OPDSHandler: opdsHandler, + JobsHandler: jobsHandler, + ConnManager: connManager, + QueueProcessor: queueProcessor, + DeviceAuthMiddleware: deviceAuthMiddleware, + LoginTracker: loginAttemptTracker, + } + + router.RegisterRoutes(routerConfig) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err, "Failed to create listener") + // Configure Echo's HTTP server with the listener + serverConfig := &http.Server{ + Handler: e, + Addr: ln.Addr().String(), + } + ts := &httptest.Server{ + Listener: ln, + Config: serverConfig, + } + ts.Start() + + ctx := context.Background() + + // Delete ALL test users (any user with test email domains) to ensure clean state + // This handles users created during tests that may have been promoted to admin, etc. + allUsers, err := queries.ListUsers(ctx) + if err == nil { + for _, user := range allUsers { + if strings.HasSuffix(user.Email, "@example.com") || strings.HasSuffix(user.Email, "@tests.bookhoard.internal") { + queries.DeleteUser(ctx, user.ID) + } + } + } + + // Delete test libraries (names containing "test" - case insensitive) + // This cleans up libraries created by tests while preserving user-created libraries + // NOTE: Do not use "test" in library names if you want to keep them! + allLibs, _ := queries.ListLibraries(ctx) + for _, lib := range allLibs { + if strings.Contains(strings.ToLower(lib.Name), "test") { + queries.DeleteLibrary(ctx, lib.ID) + } + } + + // Create fresh admin test user + passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq" + adminUser, err := queries.CreateUser(ctx, database.CreateUserParams{ + Email: "testuser@tests.bookhoard.internal", + Username: "testuser", + PasswordHash: passwordHash, + FirstName: pgtype.Text{String: "Test", Valid: true}, + LastName: pgtype.Text{String: "User", Valid: true}, + Role: "admin", + }) + require.NoError(t, err, "Failed to create admin test user") + + adminUUID, err := uuid.FromBytes(adminUser.ID.Bytes[:]) + require.NoError(t, err, "Failed to parse admin user UUID") + createDefaultCollectionsForUser(t, queries, pgtype.UUID{Bytes: adminUUID, Valid: true}) + + // Create fresh regular test user + regularUser, err := queries.CreateUser(ctx, database.CreateUserParams{ + Email: "testregularuser@tests.bookhoard.internal", + Username: "testregularuser", + PasswordHash: passwordHash, + FirstName: pgtype.Text{String: "Regular", Valid: true}, + LastName: pgtype.Text{String: "User", Valid: true}, + Role: "user", + }) + require.NoError(t, err, "Failed to create regular test user") + + regularUUID, err := uuid.FromBytes(regularUser.ID.Bytes[:]) + require.NoError(t, err, "Failed to parse regular user UUID") + createDefaultCollectionsForUser(t, queries, pgtype.UUID{Bytes: regularUUID, Valid: true}) + + // Login to get tokens + adminToken := loginWithCredentials(t, ts, "testuser@tests.bookhoard.internal", "Test@Pass123!") + regularToken := loginWithCredentials(t, ts, "testregularuser@tests.bookhoard.internal", "Test@Pass123!") + + // Create TestServerSetup struct with all resources + setup := &TestServerSetup{ + Server: ts, + DB: queries, + DBPool: dbPool, + Config: cfg, + ConnManager: connManager, + QueueProcessor: queueProcessor, + CleanupCancel: cleanupCancel, + QueueCtx: queueCtx, + QueueCancel: queueCancel, + Token: adminToken, + RegularToken: regularToken, + } + + // Register cleanup function to run automatically when test completes + t.Cleanup(func() { + if err := setup.Close(); err != nil { + t.Errorf("Failed to cleanup test server: %v", err) + } + }) + + return setup +} + +func loginWithCredentials(t *testing.T, ts *httptest.Server, email, password string) string { + loginRequest := map[string]interface{}{ + "login": email, + "password": password, + } + body, _ := json.Marshal(loginRequest) + + req, _ := http.NewRequest("POST", ts.URL+"/api/auth/login", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err, "Failed to login") + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode, "Login should succeed") + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + token, ok := result["access_token"].(string) + require.True(t, ok, "Should have access_token") + require.NotEmpty(t, token, "Access token should not be empty") + + return token +} + +func getTestUserID(t *testing.T, db *database.Queries) uuid.UUID { + ctx := context.Background() + user, err := db.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal") + require.NoError(t, err, "Test user should exist") + userUUID, err := uuid.FromBytes(user.ID.Bytes[:]) + require.NoError(t, err, "Failed to parse user UUID") + return userUUID +} + +func getRegularUserID(t *testing.T, db *database.Queries) uuid.UUID { + ctx := context.Background() + user, err := db.GetUserByEmail(ctx, "testregularuser@tests.bookhoard.internal") + require.NoError(t, err, "Regular user should exist") + userUUID, err := uuid.FromBytes(user.ID.Bytes[:]) + require.NoError(t, err, "Failed to parse user UUID") + return userUUID +} + +// createTestMediaItemID creates a test media item and returns its ID +func createTestMediaItemID(t *testing.T, setup *TestServerSetup) string { + uniqueName := fmt.Sprintf("Test Library %d", time.Now().UnixNano()) + httpClient := &http.Client{} + + libReq := map[string]interface{}{ + "name": uniqueName, + "description": "A test library for media items", + "type": "ebooks", + } + libBody, _ := json.Marshal(libReq) + + req, _ := http.NewRequest("POST", setup.Server.URL+"/api/libraries", bytes.NewBuffer(libBody)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+setup.Token) + + resp, err := httpClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusCreated, resp.StatusCode) + + var libResult map[string]interface{} + json.NewDecoder(resp.Body).Decode(&libResult) + + libData := libResult["id"].(string) + + folderReq := map[string]interface{}{ + "folder_path": "/app/uploads", + } + folderBody, _ := json.Marshal(folderReq) + + folderReqHTTP, _ := http.NewRequest("POST", setup.Server.URL+"/api/libraries/"+libData+"/folders", bytes.NewBuffer(folderBody)) + folderReqHTTP.Header.Set("Content-Type", "application/json") + folderReqHTTP.Header.Set("Authorization", "Bearer "+setup.Token) + + folderResp, err := httpClient.Do(folderReqHTTP) + require.NoError(t, err) + defer folderResp.Body.Close() + require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Library folder creation is required before adding media items") + + mediaItemReq := map[string]interface{}{ + "library_id": libData, + "title": "Test Media Item", + "author": "Test Author", + "file_path": "/tmp/test.epub", + "file_size": 1024, + "mime_type": "application/epub+zip", + } + mediaItemBody, _ := json.Marshal(mediaItemReq) + + req2, _ := http.NewRequest("POST", setup.Server.URL+"/api/media-items", bytes.NewBuffer(mediaItemBody)) + req2.Header.Set("Content-Type", "application/json") + req2.Header.Set("Authorization", "Bearer "+setup.Token) + + resp2, err := httpClient.Do(req2) + require.NoError(t, err) + defer resp2.Body.Close() + + require.Equal(t, http.StatusCreated, resp2.StatusCode) + + var mediaItemResult map[string]interface{} + json.NewDecoder(resp2.Body).Decode(&mediaItemResult) + + mediaItemID := mediaItemResult["id"].(string) + + t.Cleanup(func() { + deleteReq, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/libraries/"+libData, nil) + deleteReq.Header.Set("Authorization", "Bearer "+setup.Token) + httpClient.Do(deleteReq) + }) + + return mediaItemID +} + +// addFolderToLibrary adds a folder to a test library via HTTP API +func addFolderToLibrary(t *testing.T, setup *TestServerSetup, libraryID string, folderPath string) { + t.Helper() + + payload := map[string]interface{}{ + "folder_path": folderPath, + } + + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("POST", setup.Server.URL+"/api/libraries/"+libraryID+"/folders", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+setup.Token) + + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusCreated, resp.StatusCode) +}