diff --git a/bruno/media-items/Filter Media Items.bru b/bruno/media-items/Filter Media Items.bru new file mode 100644 index 0000000..219b273 --- /dev/null +++ b/bruno/media-items/Filter Media Items.bru @@ -0,0 +1,135 @@ +meta { + name: Filter Media Items + type: http + seq: 1 +} + +get { + url: {{base_url}}/api/media-items/filtered?library_id={{library_id}}&genre_filter=Fiction&language_filter=en&year_min=2000&year_max=2024&limit=10&offset=0 + auth: inherit +} + +headers { + Content-Type: application/json +} + +tests { + test_filter_media_items(status, headers, body) { + if (status !== 200) { + throw new Error("Expected status 200, 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 { + const parsed = JSON.parse(body); + data = parsed.data; + } catch (e) { + throw new Error("Response body is not valid JSON: " + e.message); + } + + if (!Array.isArray(data)) { + throw new Error("Expected response data to be an array"); + } + + // Verify filters are applied + 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"); + } + + // Check genre filter if genre is specified + if (item.genre && item.genre !== 'Fiction') { + throw new Error("Item at index " + index + " has incorrect genre: " + item.genre); + } + + // Check language filter if language is specified + if (item.language && item.language !== 'en') { + throw new Error("Item at index " + index + " has incorrect language: " + item.language); + } + + // Check year range if specified + if (item.copyright_year) { + if (item.copyright_year < 2000 || item.copyright_year > 2024) { + throw new Error("Item at index " + index + " has copyright_year outside range: " + item.copyright_year); + } + } + }); + + return true; + } +} + +vars:pre-request { + genre: "Fiction" + language: "en" + yearMin: 2000 + yearMax: 2024 +} + +settings { + encodeUrl: true + timeout: 0 +} + +docs { + ## Filter Media Items + + **Method:** GET + + **Endpoint:** /api/media-items/filtered + + **Authentication:** Required (Bearer token) + + **Query Parameters:** + - `library_id` (string, required): UUID of the library + - `author_filter` (string, optional): Filter by author (partial match) + - `series_filter` (string, optional): Filter by series (partial match) + - `genre_filter` (string, optional): Filter by genre (exact match) + - `language_filter` (string, optional): Filter by language (exact match, e.g., 'en', 'es', 'fr') + - `year_min` (integer, optional): Minimum copyright year + - `year_max` (integer, optional): Maximum copyright year + - `has_cover` (boolean, optional): Filter for items with cover images only + - `sort` (string, optional): Sort field and direction (same options as ListMediaItems) + - `limit` (integer, optional): Number of items to return (default: 50, max: 1000) + - `offset` (integer, optional): Number of items to skip (default: 0) + + **Response:** Object containing array of filtered media items + + **Status Codes:** + - 200: Success + - 400: Bad request (invalid parameters) + - 401: Unauthorized + - 500: Internal server error + + **Examples:** + - Filter by genre: `/api/media-items/filtered?library_id=xxx&genre_filter=Fiction` + - Filter by language: `/api/media-items/filtered?library_id=xxx&language_filter=es` + - Filter by year range: `/api/media-items/filtered?library_id=xxx&year_min=2000&year_max=2024` + - Filter by cover: `/api/media-items/filtered?library_id=xxx&has_cover=true` + - Combine filters: `/api/media-items/filtered?library_id=xxx&genre_filter=Sci-Fi&year_min=2010&language_filter=en` + + **Filter Behavior:** + - Multiple filters can be combined (AND logic) + - Author and series filters use partial matching (ILIKE) + - Genre and language filters use exact matching + - Year range filters are inclusive + - Filters are applied before sorting and pagination + - User library visibility is respected +} diff --git a/bruno/media-items/List Media Items Sorted.bru b/bruno/media-items/List Media Items Sorted.bru new file mode 100644 index 0000000..9deaf18 --- /dev/null +++ b/bruno/media-items/List Media Items Sorted.bru @@ -0,0 +1,132 @@ +meta { + name: List Media Items with Sorting + type: http + seq: 1 +} + +get { + url: {{base_url}}/api/media-items?library_id={{library_id}}&sort=title+ASC&limit=10&offset=0 + auth: inherit +} + +headers { + Content-Type: application/json +} + +tests { + test_list_media_items_sorted(status, headers, body) { + if (status !== 200) { + throw new Error("Expected status 200, 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 { + const parsed = JSON.parse(body); + data = parsed.data; + } catch (e) { + throw new Error("Response body is not valid JSON: " + e.message); + } + + if (!Array.isArray(data)) { + throw new Error("Expected response data to be an array"); + } + + // Verify items are sorted by title ascending + for (let i = 1; i < data.length; i++) { + const prevTitle = data[i - 1].title.toLowerCase(); + const currTitle = data[i].title.toLowerCase(); + if (prevTitle > currTitle) { + throw new Error("Items not sorted by title ASC: " + prevTitle + " should come before " + currTitle); + } + } + + 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"); + } + + if (!item.library_name) { + throw new Error("Media item at index " + index + " missing required field: library_name"); + } + }); + + return true; + } +} + +vars:pre-request { + sortBy: "title ASC" +} + +settings { + encodeUrl: true + timeout: 0 +} + +docs { + ## List Media Items with Sorting + + **Method:** GET + + **Endpoint:** /api/media-items + + **Authentication:** Required (Bearer token) + + **Query Parameters:** + - `library_id` (string, required): UUID of the library + - `sort` (string, optional): Sort field and direction + - Available options: + - `created_at ASC` - Oldest added first + - `created_at DESC` - Newest added first (default) + - `title ASC` - Title A-Z + - `title DESC` - Title Z-A + - `author ASC` - Author A-Z + - `author DESC` - Author Z-A + - `series ASC` - Series order + - `series DESC` - Series reverse order + - `date_published ASC` - Oldest published first + - `date_published DESC` - Newest published first + - `copyright_year ASC` - Oldest copyright first + - `copyright_year DESC` - Newest copyright first + - `page_count ASC` - Shortest first + - `page_count DESC` - Longest first + - `genre ASC` - Genre A-Z + - `genre DESC` - Genre Z-A + - `limit` (integer, optional): Number of items to return (default: 50, max: 1000) + - `offset` (integer, optional): Number of items to skip (default: 0) + + **Response:** Object containing array of media items + + **Status Codes:** + - 200: Success + - 400: Bad request (invalid parameters) + - 401: Unauthorized + - 500: Internal server error + + **Examples:** + - Sort by title: `/api/media-items?library_id=xxx&sort=title+ASC` + - Sort by author descending: `/api/media-items?library_id=xxx&sort=author+DESC` + - Sort by page count: `/api/media-items?library_id=xxx&sort=page_count+ASC` + + **Sorting Behavior:** + - All sorts are secondary-sorted by series_number then title for consistency + - NULL values are sorted last for ascending, first for descending + - Sorting is case-insensitive for text fields +} diff --git a/cmd/server/tests/filtering_test.go b/cmd/server/tests/filtering_test.go new file mode 100644 index 0000000..5246b2e --- /dev/null +++ b/cmd/server/tests/filtering_test.go @@ -0,0 +1,263 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestListMediaItemsFiltering tests the filtering functionality for different contexts +func TestListMediaItemsFiltering(t *testing.T) { + + t.Run("No user context - GET /api/media-items/filtered without authentication", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items/filtered?library_id=test-lib-id&genre_filter=Fiction", 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{}{ + "data": []map[string]interface{}{ + {"id": "1", "title": "Fiction Book", "genre": "Fiction", "library_id": "test-lib-id"}, + }, + }) + }) + + 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 - Filter by genre", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items/filtered?library_id=test-lib-id&genre_filter=Fiction", 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) + return + } + + // Return filtered data - only Fiction genre + results := []map[string]interface{}{ + {"id": "1", "title": "Fiction Book A", "genre": "Fiction", "library_id": "test-lib-id"}, + {"id": "2", "title": "Fiction Book B", "genre": "Fiction", "library_id": "test-lib-id"}, + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{"data": results}) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + + var response map[string]interface{} + err := json.Unmarshal(rr.Body.Bytes(), &response) + require.NoError(t, err) + + data := response["data"].([]interface{}) + assert.GreaterOrEqual(t, len(data), 1) + }) + + t.Run("User context - Filter by language", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items/filtered?library_id=test-lib-id&language_filter=en", 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) + return + } + + // Return filtered data - only English books + results := []map[string]interface{}{ + {"id": "1", "title": "English Book", "language": "en", "library_id": "test-lib-id"}, + {"id": "2", "title": "Another English Book", "language": "en", "library_id": "test-lib-id"}, + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{"data": results}) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + + var response map[string]interface{} + err := json.Unmarshal(rr.Body.Bytes(), &response) + require.NoError(t, err) + + data := response["data"].([]interface{}) + assert.GreaterOrEqual(t, len(data), 1) + }) + + t.Run("User context - Filter by year range", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items/filtered?library_id=test-lib-id&year_min=2000&year_max=2020", 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) + return + } + + // Return filtered data - books between 2000 and 2020 + results := []map[string]interface{}{ + {"id": "1", "title": "2005 Book", "copyright_year": 2005, "library_id": "test-lib-id"}, + {"id": "2", "title": "2010 Book", "copyright_year": 2010, "library_id": "test-lib-id"}, + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{"data": results}) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + + var response map[string]interface{} + err := json.Unmarshal(rr.Body.Bytes(), &response) + require.NoError(t, err) + + data := response["data"].([]interface{}) + assert.GreaterOrEqual(t, len(data), 1) + }) + + t.Run("User context - Filter by has_cover", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items/filtered?library_id=test-lib-id&has_cover=true", 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) + return + } + + // Return filtered data - only items with cover images + results := []map[string]interface{}{ + {"id": "1", "title": "Book With Cover", "cover_image_path": "/covers/1.jpg", "library_id": "test-lib-id"}, + {"id": "2", "title": "Another Book With Cover", "cover_image_path": "/covers/2.jpg", "library_id": "test-lib-id"}, + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{"data": results}) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + + var response map[string]interface{} + err := json.Unmarshal(rr.Body.Bytes(), &response) + require.NoError(t, err) + + data := response["data"].([]interface{}) + assert.GreaterOrEqual(t, len(data), 1) + }) + + t.Run("User context - Combine multiple filters", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items/filtered?library_id=test-lib-id&genre_filter=Fiction&language_filter=en&year_min=2010", 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) + return + } + + // Return filtered data - Fiction, English, from 2010+ + results := []map[string]interface{}{ + {"id": "1", "title": "Filtered Book", "genre": "Fiction", "language": "en", "copyright_year": 2015, "library_id": "test-lib-id"}, + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{"data": results}) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + + var response map[string]interface{} + err := json.Unmarshal(rr.Body.Bytes(), &response) + require.NoError(t, err) + + data := response["data"].([]interface{}) + assert.GreaterOrEqual(t, len(data), 0) + }) + + t.Run("User context - Filter with pagination", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items/filtered?library_id=test-lib-id&genre_filter=Fiction&limit=2&offset=1", 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) + return + } + + // Return paginated filtered results + results := []map[string]interface{}{ + {"id": "2", "title": "Fiction Book 2", "genre": "Fiction", "library_id": "test-lib-id"}, + {"id": "3", "title": "Fiction Book 3", "genre": "Fiction", "library_id": "test-lib-id"}, + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{"data": results}) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + + var response map[string]interface{} + err := json.Unmarshal(rr.Body.Bytes(), &response) + require.NoError(t, err) + + data := response["data"].([]interface{}) + assert.LessOrEqual(t, len(data), 2, "Should respect limit parameter") + }) + + t.Run("User context - Filter with sorting", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items/filtered?library_id=test-lib-id&genre_filter=Fiction&sort=title+ASC", 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) + return + } + + // Return filtered and sorted data + results := []map[string]interface{}{ + {"id": "1", "title": "A Fiction Book", "genre": "Fiction", "library_id": "test-lib-id"}, + {"id": "2", "title": "B Fiction Book", "genre": "Fiction", "library_id": "test-lib-id"}, + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{"data": results}) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + + var response map[string]interface{} + err := json.Unmarshal(rr.Body.Bytes(), &response) + require.NoError(t, err) + + data := response["data"].([]interface{}) + assert.GreaterOrEqual(t, len(data), 1) + }) +} diff --git a/cmd/server/tests/sorting_test.go b/cmd/server/tests/sorting_test.go new file mode 100644 index 0000000..cdc8dfb --- /dev/null +++ b/cmd/server/tests/sorting_test.go @@ -0,0 +1,264 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestListMediaItemsSorting tests the sorting functionality for different contexts +func TestListMediaItemsSorting(t *testing.T) { + + t.Run("No user context - GET /api/media-items without authentication", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items?library_id=test-lib-id&sort=title+ASC", 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{}{ + "data": []map[string]interface{}{ + {"id": "1", "title": "A Book", "library_id": "test-lib-id"}, + {"id": "2", "title": "B Book", "library_id": "test-lib-id"}, + }, + }) + }) + + 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 with title ASC sort", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items?library_id=test-lib-id&sort=title+ASC", 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 + } + + // Return sorted data + results := []map[string]interface{}{ + {"id": "1", "title": "A Book", "author": "Author A", "library_id": "test-lib-id", "library_name": "Test Library"}, + {"id": "2", "title": "B Book", "author": "Author B", "library_id": "test-lib-id", "library_name": "Test Library"}, + {"id": "3", "title": "C Book", "author": "Author C", "library_id": "test-lib-id", "library_name": "Test Library"}, + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{"data": results}) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + + var response map[string]interface{} + err := json.Unmarshal(rr.Body.Bytes(), &response) + require.NoError(t, err) + + data := response["data"].([]interface{}) + assert.GreaterOrEqual(t, len(data), 2, "Should have at least 2 items") + }) + + t.Run("User context - GET /api/media-items with author DESC sort", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items?library_id=test-lib-id&sort=author+DESC", 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) + return + } + + // Return data sorted by author descending + results := []map[string]interface{}{ + {"id": "1", "title": "Book C", "author": "Smith", "library_id": "test-lib-id"}, + {"id": "2", "title": "Book A", "author": "Jones", "library_id": "test-lib-id"}, + {"id": "3", "title": "Book B", "author": "Anderson", "library_id": "test-lib-id"}, + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{"data": results}) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + + var response map[string]interface{} + err := json.Unmarshal(rr.Body.Bytes(), &response) + require.NoError(t, err) + + data := response["data"].([]interface{}) + assert.GreaterOrEqual(t, len(data), 2) + }) + + t.Run("Admin context - GET /api/media-items with page_count DESC sort", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items?library_id=test-lib-id&sort=page_count+DESC", 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) + return + } + + // Return data sorted by page count descending (longest first) + results := []map[string]interface{}{ + {"id": "1", "title": "Long Book", "page_count": 500, "library_id": "test-lib-id"}, + {"id": "2", "title": "Medium Book", "page_count": 300, "library_id": "test-lib-id"}, + {"id": "3", "title": "Short Book", "page_count": 100, "library_id": "test-lib-id"}, + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{"data": results}) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + + var response map[string]interface{} + err := json.Unmarshal(rr.Body.Bytes(), &response) + require.NoError(t, err) + + data := response["data"].([]interface{}) + assert.GreaterOrEqual(t, len(data), 2) + }) + + t.Run("User context - Invalid sort parameter defaults to created_at DESC", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items?library_id=test-lib-id&sort=invalid+field", 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) + return + } + + // Should default to created_at DESC + results := []map[string]interface{}{ + {"id": "1", "title": "Newest Book", "created_at": "2024-01-15T10:00:00Z", "library_id": "test-lib-id"}, + {"id": "2", "title": "Older Book", "created_at": "2023-06-15T10:00:00Z", "library_id": "test-lib-id"}, + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{"data": results}) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + }) + + t.Run("User context - Sort by genre ASC", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items?library_id=test-lib-id&sort=genre+ASC", 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) + return + } + + // Return data sorted by genre + results := []map[string]interface{}{ + {"id": "1", "title": "Book A", "genre": "Fiction", "library_id": "test-lib-id"}, + {"id": "2", "title": "Book B", "genre": "Non-Fiction", "library_id": "test-lib-id"}, + {"id": "3", "title": "Book C", "genre": "Science Fiction", "library_id": "test-lib-id"}, + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{"data": results}) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + + var response map[string]interface{} + err := json.Unmarshal(rr.Body.Bytes(), &response) + require.NoError(t, err) + + data := response["data"].([]interface{}) + assert.GreaterOrEqual(t, len(data), 2) + }) + + t.Run("User context - Sort by copyright_year DESC", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items?library_id=test-lib-id&sort=copyright_year+DESC", 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) + return + } + + // Return data sorted by copyright year (newest first) + results := []map[string]interface{}{ + {"id": "1", "title": "Modern Book", "copyright_year": 2023, "library_id": "test-lib-id"}, + {"id": "2", "title": "90s Book", "copyright_year": 1995, "library_id": "test-lib-id"}, + {"id": "3", "title": "Classic Book", "copyright_year": 1980, "library_id": "test-lib-id"}, + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{"data": results}) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + + var response map[string]interface{} + err := json.Unmarshal(rr.Body.Bytes(), &response) + require.NoError(t, err) + + data := response["data"].([]interface{}) + assert.GreaterOrEqual(t, len(data), 2) + }) + + t.Run("User context - Sort with pagination", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items?library_id=test-lib-id&sort=title+ASC&limit=2&offset=1", 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) + return + } + + // Return paginated results (second page) + results := []map[string]interface{}{ + {"id": "2", "title": "B Book", "library_id": "test-lib-id"}, + {"id": "3", "title": "C Book", "library_id": "test-lib-id"}, + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{"data": results}) + }) + + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + + var response map[string]interface{} + err := json.Unmarshal(rr.Body.Bytes(), &response) + require.NoError(t, err) + + data := response["data"].([]interface{}) + assert.LessOrEqual(t, len(data), 2, "Should respect limit parameter") + }) +}