From 66e0b61200510d796db2a3e956681fee95a4f5ad Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 20 Feb 2026 17:04:15 -0500 Subject: [PATCH] test(collections): replace broken unit tests with integration tests Removed unit tests that couldn't work without a database (nil db would panic). Added comprehensive integration tests for the PreviewCollection endpoint covering: - Authentication (no auth, valid auth) - Input validation (missing/invalid library ID, invalid JSON) - Manual book selection - Rule-based filtering - Limit parameter handling - Duplicate and invalid book ID handling --- cmd/server/tests/collections_preview_test.go | 391 ++++++++++++++++++ internal/handlers/collections_preview_test.go | 181 -------- 2 files changed, 391 insertions(+), 181 deletions(-) create mode 100644 cmd/server/tests/collections_preview_test.go diff --git a/cmd/server/tests/collections_preview_test.go b/cmd/server/tests/collections_preview_test.go new file mode 100644 index 0000000..0c4ed22 --- /dev/null +++ b/cmd/server/tests/collections_preview_test.go @@ -0,0 +1,391 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "bookhoard/internal/database" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPreviewCollection(t *testing.T) { + setup := setupDeviceTest(t) + defer setup.Server.Close() + + libraryID := setup.CreateLibrary(t, "Preview Test Library", "ebooks") + bookIDs := createPreviewTestMediaItems(t, setup.DB, libraryID, 5) + + t.Run("NoAuth_ReturnsUnauthorized", func(t *testing.T) { + reqBody := map[string]interface{}{ + "library_id": libraryID, + "rules": []map[string]interface{}{}, + "manual_book_ids": []string{}, + "limit": 20, + } + body, _ := json.Marshal(reqBody) + + req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + }) + + t.Run("MissingLibraryID_ReturnsBadRequest", func(t *testing.T) { + reqBody := map[string]interface{}{ + "library_id": "", + "rules": []map[string]interface{}{}, + "manual_book_ids": []string{}, + "limit": 20, + } + body, _ := json.Marshal(reqBody) + + req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+setup.UserToken) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) + + t.Run("InvalidLibraryID_ReturnsBadRequest", func(t *testing.T) { + reqBody := map[string]interface{}{ + "library_id": "not-a-valid-uuid", + "rules": []map[string]interface{}{}, + "manual_book_ids": []string{}, + "limit": 20, + } + body, _ := json.Marshal(reqBody) + + req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+setup.UserToken) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) + + t.Run("NonExistentLibrary_ReturnsInternalServerError", func(t *testing.T) { + reqBody := map[string]interface{}{ + "library_id": uuid.New().String(), + "rules": []map[string]interface{}{}, + "manual_book_ids": []string{}, + "limit": 20, + } + body, _ := json.Marshal(reqBody) + + req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+setup.UserToken) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusInternalServerError, rec.Code) + }) + + t.Run("EmptyRules_ReturnsEmptyItems", func(t *testing.T) { + reqBody := map[string]interface{}{ + "library_id": libraryID, + "rules": []map[string]interface{}{}, + "manual_book_ids": []string{}, + "limit": 20, + } + body, _ := json.Marshal(reqBody) + + req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+setup.UserToken) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var result map[string]interface{} + json.NewDecoder(rec.Body).Decode(&result) + + items := result["items"].([]interface{}) + assert.Equal(t, 0, len(items), "Empty rules should return no matched items") + }) + + t.Run("ManualBookSelection_ReturnsSelectedBooks", func(t *testing.T) { + reqBody := map[string]interface{}{ + "library_id": libraryID, + "rules": []map[string]interface{}{}, + "manual_book_ids": []string{ + bookIDs[0], + bookIDs[2], + }, + "limit": 20, + } + body, _ := json.Marshal(reqBody) + + req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+setup.UserToken) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var result map[string]interface{} + json.NewDecoder(rec.Body).Decode(&result) + + items := result["items"].([]interface{}) + assert.Equal(t, 2, len(items), "Should return exactly 2 manually selected books") + }) + + t.Run("RuleFiltering_ReturnsMatchingBooks", func(t *testing.T) { + reqBody := map[string]interface{}{ + "library_id": libraryID, + "rules": []map[string]interface{}{ + { + "id": "rule-1", + "field": "genre", + "operator": "equals", + "value": "Science Fiction", + "priority": 5, + }, + }, + "manual_book_ids": []string{}, + "limit": 20, + } + body, _ := json.Marshal(reqBody) + + req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+setup.UserToken) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var result map[string]interface{} + json.NewDecoder(rec.Body).Decode(&result) + + items := result["items"].([]interface{}) + assert.Greater(t, len(items), 0, "Should return books matching the genre rule") + }) + + t.Run("CombinedRulesAndManualSelection_ReturnsAllMatches", func(t *testing.T) { + reqBody := map[string]interface{}{ + "library_id": libraryID, + "rules": []map[string]interface{}{ + { + "id": "rule-1", + "field": "genre", + "operator": "equals", + "value": "Science Fiction", + "priority": 5, + }, + }, + "manual_book_ids": []string{bookIDs[4]}, + "limit": 20, + } + body, _ := json.Marshal(reqBody) + + req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+setup.UserToken) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var result map[string]interface{} + json.NewDecoder(rec.Body).Decode(&result) + + items := result["items"].([]interface{}) + assert.Greater(t, len(items), 0, "Should return books from rules and manual selection") + }) + + t.Run("LimitRespected_ReturnsLimitedItems", func(t *testing.T) { + reqBody := map[string]interface{}{ + "library_id": libraryID, + "rules": []map[string]interface{}{}, + "manual_book_ids": []string{ + bookIDs[0], + bookIDs[1], + bookIDs[2], + bookIDs[3], + bookIDs[4], + }, + "limit": 2, + } + body, _ := json.Marshal(reqBody) + + req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+setup.UserToken) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var result map[string]interface{} + json.NewDecoder(rec.Body).Decode(&result) + + items := result["items"].([]interface{}) + assert.LessOrEqual(t, len(items), 2, "Should respect limit parameter") + }) + + t.Run("LimitExceedsMax_DefaultsToTwenty", func(t *testing.T) { + reqBody := map[string]interface{}{ + "library_id": libraryID, + "rules": []map[string]interface{}{}, + "manual_book_ids": []string{ + bookIDs[0], + }, + "limit": 150, + } + body, _ := json.Marshal(reqBody) + + req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+setup.UserToken) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var result map[string]interface{} + json.NewDecoder(rec.Body).Decode(&result) + + items := result["items"].([]interface{}) + assert.Equal(t, 1, len(items), "Should still return items when limit exceeds max") + }) + + t.Run("LimitZero_DefaultsToTwenty", func(t *testing.T) { + reqBody := map[string]interface{}{ + "library_id": libraryID, + "rules": []map[string]interface{}{}, + "manual_book_ids": []string{ + bookIDs[0], + }, + "limit": 0, + } + body, _ := json.Marshal(reqBody) + + req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+setup.UserToken) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var result map[string]interface{} + json.NewDecoder(rec.Body).Decode(&result) + + items := result["items"].([]interface{}) + assert.Equal(t, 1, len(items), "Limit 0 should default to 20 and still return matched items") + }) + + t.Run("InvalidManualBookID_SkippedGracefully", func(t *testing.T) { + reqBody := map[string]interface{}{ + "library_id": libraryID, + "rules": []map[string]interface{}{}, + "manual_book_ids": []string{ + "not-a-valid-uuid", + bookIDs[0], + }, + "limit": 20, + } + body, _ := json.Marshal(reqBody) + + req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+setup.UserToken) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var result map[string]interface{} + json.NewDecoder(rec.Body).Decode(&result) + + items := result["items"].([]interface{}) + assert.Equal(t, 1, len(items), "Invalid book IDs should be skipped, valid ones included") + }) + + t.Run("DuplicateManualBookIDs_ReturnsUniqueItems", func(t *testing.T) { + reqBody := map[string]interface{}{ + "library_id": libraryID, + "rules": []map[string]interface{}{}, + "manual_book_ids": []string{ + bookIDs[0], + bookIDs[0], + bookIDs[0], + }, + "limit": 20, + } + body, _ := json.Marshal(reqBody) + + req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+setup.UserToken) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var result map[string]interface{} + json.NewDecoder(rec.Body).Decode(&result) + + items := result["items"].([]interface{}) + assert.Equal(t, 1, len(items), "Duplicate book IDs should result in unique items") + }) + + t.Run("InvalidJSON_ReturnsBadRequest", func(t *testing.T) { + req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer([]byte("invalid json"))) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+setup.UserToken) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) +} + +func createPreviewTestMediaItems(t *testing.T, db *database.Queries, libraryID string, count int) []string { + ctx := context.Background() + + libUUID, err := uuid.Parse(libraryID) + require.NoError(t, err, "Should parse library ID") + + var bookIDs []string + + genres := []string{"Science Fiction", "Fantasy", "Mystery", "Science Fiction", "Romance"} + + for i := 0; i < count; i++ { + genre := genres[i%len(genres)] + + media, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{ + LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, + Title: "Preview Test Book " + string(rune('A'+i)), + Author: pgtype.Text{String: "Test Author " + string(rune('A'+i)), Valid: true}, + Genre: pgtype.Text{String: genre, Valid: true}, + FilePath: "/tmp/test" + string(rune('A'+i)) + ".epub", + FileSize: pgtype.Int8{Int64: 1024, Valid: true}, + MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, + }) + require.NoError(t, err, "Should create media item") + + mediaUUID, err := uuid.FromBytes(media.ID.Bytes[0:16]) + require.NoError(t, err, "Should parse media UUID") + + bookIDs = append(bookIDs, mediaUUID.String()) + } + + return bookIDs +} diff --git a/internal/handlers/collections_preview_test.go b/internal/handlers/collections_preview_test.go index 07c8e97..401f2ca 100644 --- a/internal/handlers/collections_preview_test.go +++ b/internal/handlers/collections_preview_test.go @@ -13,7 +13,6 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v4" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestPreviewCollection_NoAuth(t *testing.T) { @@ -97,183 +96,3 @@ func TestPreviewCollection_InvalidLibraryID(t *testing.T) { assert.Equal(t, http.StatusBadRequest, rec.Code) } - -func TestPreviewCollection_LimitValidation(t *testing.T) { - e := echo.New() - handler := &CollectionHandler{} - libraryID := uuid.New() - - tests := []struct { - name string - limit int - expectedStatus int - }{ - { - name: "valid limit 10", - limit: 10, - expectedStatus: http.StatusBadRequest, // No actual library, so will return error - }, - { - name: "valid limit 20", - limit: 20, - expectedStatus: http.StatusBadRequest, // No actual library, so will return error - }, - { - name: "limit too high (101)", - limit: 101, - expectedStatus: http.StatusBadRequest, // Limit validation - }, - { - name: "limit zero", - limit: 0, - expectedStatus: http.StatusBadRequest, // Should default to 20, but library doesn't exist - }, - { - name: "negative limit", - limit: -5, - expectedStatus: http.StatusBadRequest, // No actual library - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - reqBody := map[string]interface{}{ - "library_id": libraryID.String(), - "rules": []map[string]interface{}{}, - "manual_book_ids": []string{}, - "limit": tt.limit, - } - body, _ := json.Marshal(reqBody) - - req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - - // Mock user - user := database.Users{ - ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, - } - c := e.NewContext(req, rec) - c.Set("user", user) - - err := handler.PreviewCollection(c) - require.NoError(t, err) - - assert.Equal(t, tt.expectedStatus, rec.Code) - }) - } -} - -func TestPreviewCollection_RuleValidation(t *testing.T) { - e := echo.New() - handler := &CollectionHandler{} - libraryID := uuid.New() - - tests := []struct { - name string - rules interface{} - expectedStatus int - }{ - { - name: "empty rules array", - rules: []map[string]interface{}{}, - expectedStatus: http.StatusBadRequest, // Library doesn't exist - }, - { - name: "valid rule structure", - rules: []map[string]interface{}{ - { - "id": "rule1", - "field": "genre", - "operator": "equals", - "value": "Sci-Fi", - "priority": 1, - }, - }, - expectedStatus: http.StatusBadRequest, // Library doesn't exist - }, - { - name: "multiple rules", - rules: []map[string]interface{}{ - { - "id": "rule1", - "field": "genre", - "operator": "equals", - "value": "Sci-Fi", - "priority": 1, - }, - { - "id": "rule2", - "field": "author", - "operator": "contains", - "value": "Asimov", - "priority": 2, - }, - }, - expectedStatus: http.StatusBadRequest, // Library doesn't exist - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - reqBody := map[string]interface{}{ - "library_id": libraryID.String(), - "rules": tt.rules, - "manual_book_ids": []string{}, - "limit": 20, - } - body, _ := json.Marshal(reqBody) - - req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - - // Mock user - user := database.Users{ - ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, - } - c := e.NewContext(req, rec) - c.Set("user", user) - - err := handler.PreviewCollection(c) - require.NoError(t, err) - - assert.Equal(t, tt.expectedStatus, rec.Code) - }) - } -} - -func TestPreviewCollection_ManualBookSelection(t *testing.T) { - e := echo.New() - handler := &CollectionHandler{} - libraryID := uuid.New() - - reqBody := map[string]interface{}{ - "library_id": libraryID.String(), - "rules": []map[string]interface{}{}, - "manual_book_ids": []string{ - uuid.New().String(), - uuid.New().String(), - uuid.New().String(), - }, - "limit": 20, - } - body, _ := json.Marshal(reqBody) - - req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - - // Mock user - user := database.Users{ - ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, - } - c := e.NewContext(req, rec) - c.Set("user", user) - - err := handler.PreviewCollection(c) - require.NoError(t, err) - - // Should return 400 because library doesn't exist - assert.Equal(t, http.StatusBadRequest, rec.Code) -}