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_ReturnsBadRequest", 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.StatusBadRequest, 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{} err := json.NewDecoder(rec.Body).Decode(&result) require.NoError(t, err) 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{} err := json.NewDecoder(rec.Body).Decode(&result) require.NoError(t, err) 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{} err := json.NewDecoder(rec.Body).Decode(&result) require.NoError(t, err) 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{} err := json.NewDecoder(rec.Body).Decode(&result) require.NoError(t, err) 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{} err := json.NewDecoder(rec.Body).Decode(&result) require.NoError(t, err) 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{} err := json.NewDecoder(rec.Body).Decode(&result) require.NoError(t, err) 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{} err := json.NewDecoder(rec.Body).Decode(&result) require.NoError(t, err) 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{} err := json.NewDecoder(rec.Body).Decode(&result) require.NoError(t, err) 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{} err := json.NewDecoder(rec.Body).Decode(&result) require.NoError(t, err) 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 }