From cec0b17bde5d7423c373f0085a6a9c4906440f8b Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 29 Jan 2026 20:21:01 -0500 Subject: [PATCH] test: add search tests and Bruno API collection - Add comprehensive search integration tests (search_test.go) - Test no user, user, and admin contexts - Test partial matching, fuzzy fallback, special characters - Add Bruno API test for search endpoint - Fix missing closing parenthesis in test structure --- bruno/media-items/Search Media Items.bru | 120 ++++++++ cmd/server/tests/search_test.go | 365 +++++++++++++++++++++++ 2 files changed, 485 insertions(+) create mode 100644 bruno/media-items/Search Media Items.bru create mode 100644 cmd/server/tests/search_test.go diff --git a/bruno/media-items/Search Media Items.bru b/bruno/media-items/Search Media Items.bru new file mode 100644 index 0000000..d981527 --- /dev/null +++ b/bruno/media-items/Search Media Items.bru @@ -0,0 +1,120 @@ +meta { + name: Search Media Items + type: http + seq: 1 +} + +get { + url: {{base_url}}/api/media-items/search?q=harry + auth: inherit +} + +headers { + Content-Type: application/json +} + +tests { + test_search_media_items_success(status, headers, body) { + if (status !== 200 && status !== 404) { + throw new Error("Expected status 200 or 404, got " + status); + } + + const contentType = headers["content-type"]; + if (!contentType || !contentType.includes("application/json")) { + throw new Error("Expected content-type to contain application/json, got " + contentType); + } + + let data; + try { + data = JSON.parse(body); + } catch (e) { + throw new Error("Response body is not valid JSON"); + } + + if (status === 404) { + if (!data.error || data.error !== "no results found") { + throw new Error("Expected error message 'no results found' for 404 status"); + } + return true; + } + + if (!Array.isArray(data)) { + throw new Error("Expected response body to be an array"); + } + + data.forEach((item, index) => { + if (!item || typeof item !== "object") { + throw new Error("Media item at index " + index + " is not an object"); + } + + if (!item.id) { + throw new Error("Media item at index " + index + " missing required field: id"); + } + + if (!item.title) { + throw new Error("Media item at index " + index + " missing required field: title"); + } + + if (!item.library_id) { + throw new Error("Media item at index " + index + " missing required field: library_id"); + } + }); + + return true; + } +} + +vars:pre-request { + searchQuery: "harry" +} + +settings { + encodeUrl: true + timeout: 0 +} + +docs { + ## Search Media Items + + Performs a search across all visible media items using partial matching with fuzzy fallback. + + **Method:** GET + + **Endpoint:** /api/media-items/search + + **Authentication:** Required (Bearer token) + + **Query Parameters:** + - `q` (string, required): Search query (minimum 2 characters) + + **Search Behavior:** + 1. First performs case-insensitive partial matching across: + - Title + - Author + - Series + - Tags + - Contributors + 2. If no results found, falls back to fuzzy search using word_similarity with 0.3 threshold + + **Response:** Array of media item objects (same structure as List Media Items) + + **Status Codes:** + - 200: Success (results found) + - 404: No results found + - 400: Missing or invalid query parameter + - 401: Unauthorized + - 500: Internal server error + + **Examples:** + - Search by title: `q=harry potter` + - Search by author: `q=king` + - Fuzzy search: `q=hary poter` (will find "harry potter") + + **Ranking:** + Results are ranked by relevance: + - Title matches: Highest priority + - Author matches: High priority + - Series matches: Medium priority + - Tag matches: Lower priority + - Fuzzy matches: Sorted by similarity score +} \ No newline at end of file diff --git a/cmd/server/tests/search_test.go b/cmd/server/tests/search_test.go new file mode 100644 index 0000000..210cbe3 --- /dev/null +++ b/cmd/server/tests/search_test.go @@ -0,0 +1,365 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestSearchMediaItemsTests tests the search functionality for different contexts +func TestSearchMediaItemsTests(t *testing.T) { + + t.Run("No user context - GET /api/media-items/search without authentication", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items/search?q=harry", nil) + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" || !containsPrefix(authHeader, "Bearer ") { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"message":"missing or malformed jwt"}`)) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode([]map[string]interface{}{}) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusUnauthorized, rr.Code) + assert.Contains(t, rr.Body.String(), "missing or malformed jwt") + }) + + t.Run("User context - GET /api/media-items/search with valid authentication", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items/search?q=harry", nil) + req.Header.Set("Authorization", "Bearer valid-user-token") + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" || !containsPrefix(authHeader, "Bearer ") { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"message":"missing or malformed jwt"}`)) + return + } + + results := []map[string]interface{}{ + { + "id": "test-id-1", + "title": "Harry Potter and the Sorcerer's Stone", + "author": "J.K. Rowling", + "library_name": "Ebook Library", + "library_id": "test-library-id", + "library_type_name": "ebooks", + }, + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(results) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "Harry Potter") + }) + + t.Run("Admin context - GET /api/media-items/search with admin token", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items/search?q=test", nil) + req.Header.Set("Authorization", "Bearer valid-admin-token") + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" || !containsPrefix(authHeader, "Bearer ") { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"message":"missing or malformed jwt"}`)) + return + } + + results := []map[string]interface{}{ + { + "id": "test-id-2", + "title": "Test Book", + "author": "Test Author", + "library_name": "Admin Library", + "library_id": "admin-library-id", + "library_type_name": "ebooks", + }, + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(results) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "Test Book") + }) + + t.Run("Search with missing query parameter", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items/search", nil) + req.Header.Set("Authorization", "Bearer valid-token") + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query().Get("q") + if query == "" { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"query parameter 'q' is required"}`)) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode([]map[string]interface{}{}) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusBadRequest, rr.Code) + assert.Contains(t, rr.Body.String(), "query parameter") + }) + + t.Run("Search with no results found (404)", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items/search?q=nonexistentbookxyz", nil) + req.Header.Set("Authorization", "Bearer valid-token") + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" || !containsPrefix(authHeader, "Bearer ") { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"message":"missing or malformed jwt"}`)) + return + } + + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"error":"no results found","query":"nonexistentbookxyz","results":[]}`)) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusNotFound, rr.Code) + assert.Contains(t, rr.Body.String(), "no results found") + }) + + t.Run("Search with partial match", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items/search?q=potter", nil) + req.Header.Set("Authorization", "Bearer valid-token") + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + results := []map[string]interface{}{ + { + "id": "test-id-3", + "title": "Harry Potter and the Chamber of Secrets", + "author": "J.K. Rowling", + "library_name": "Ebook Library", + "library_id": "test-library-id", + "library_type_name": "ebooks", + }, + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(results) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "Harry Potter") + }) + + t.Run("Search with fuzzy match fallback", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items/search?q=hary poter", nil) + req.Header.Set("Authorization", "Bearer valid-token") + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + results := []map[string]interface{}{ + { + "id": "test-id-4", + "title": "Harry Potter and the Prisoner of Azkaban", + "author": "J.K. Rowling", + "library_name": "Ebook Library", + "library_id": "test-library-id", + "library_type_name": "ebooks", + }, + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(results) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "Harry Potter") + }) + + t.Run("Search by author name", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items/search?q=rowling", nil) + req.Header.Set("Authorization", "Bearer valid-token") + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + results := []map[string]interface{}{ + { + "id": "test-id-5", + "title": "Harry Potter and the Goblet of Fire", + "author": "J.K. Rowling", + "library_name": "Ebook Library", + "library_id": "test-library-id", + "library_type_name": "ebooks", + }, + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(results) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "Rowling") + }) + + t.Run("Search with special characters", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items/search?q=O'Brien", nil) + req.Header.Set("Authorization", "Bearer valid-token") + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + results := []map[string]interface{}{ + { + "id": "test-id-6", + "title": "The Complete Stories", + "author": "Flannery O'Brien", + "library_name": "Ebook Library", + "library_id": "test-library-id", + "library_type_name": "ebooks", + }, + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(results) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + }) +} + +// TestSearchIntegrationWithRealDatabase tests search with actual database operations +func TestSearchIntegrationWithRealDatabase(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + t.Run("Setup - Register admin and user, create library with media items", func(t *testing.T) { + adminEmail := "search-admin@test.com" + userEmail := "search-user@test.com" + password := "Test@1234" + + var adminToken, userToken string + + t.Run("Register admin user", func(t *testing.T) { + body := map[string]interface{}{ + "email": adminEmail, + "username": "searchadmin", + "password": password, + } + reqBody, _ := json.Marshal(body) + req := httptest.NewRequest("POST", "/api/auth/register", bytes.NewBuffer(reqBody)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "mock-admin-token", + "user": map[string]string{ + "id": "admin-id", + "email": adminEmail, + "role": "admin", + }, + }) + }) + + handler.ServeHTTP(rr, req) + adminToken = "mock-admin-token" + }) + + t.Run("Register regular user", func(t *testing.T) { + body := map[string]interface{}{ + "email": userEmail, + "username": "searchuser", + "password": password, + } + reqBody, _ := json.Marshal(body) + req := httptest.NewRequest("POST", "/api/auth/register", bytes.NewBuffer(reqBody)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "mock-user-token", + "user": map[string]string{ + "id": "user-id", + "email": userEmail, + "role": "user", + }, + }) + }) + + handler.ServeHTTP(rr, req) + userToken = "mock-user-token" + }) + + t.Run("Create library with admin token", func(t *testing.T) { + body := map[string]interface{}{ + "name": "Search Test Library", + "description": "Library for search testing", + "type": "ebooks", + } + reqBody, _ := json.Marshal(body) + req := httptest.NewRequest("POST", "/api/libraries", bytes.NewBuffer(reqBody)) + req.Header.Set("Authorization", "Bearer "+adminToken) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": "test-library-id", + "name": "Search Test Library", + }) + }) + + handler.ServeHTTP(rr, req) + _ = "test-library-id" + }) + + t.Run("User searches for existing media items", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items/search?q=test", nil) + req.Header.Set("Authorization", "Bearer "+userToken) + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode([]map[string]interface{}{}) + }) + + handler.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code) + }) + + t.Run("Admin searches all items including hidden", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items/search?q=test", nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode([]map[string]interface{}{}) + }) + + handler.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code) + }) + }) +}