From 351c68b0b8ff659b2a1819e13882cdff1be1c3a7 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 1 Feb 2026 13:21:34 -0500 Subject: [PATCH] test: add comprehensive test coverage for API endpoints and services --- cmd/server/tests/analytics_test.go | 470 +++++++++++++++ cmd/server/tests/book_matching_test.go | 691 ++++++++++++++++++++++ cmd/server/tests/collections_bulk_test.go | 439 ++++++++++++++ cmd/server/tests/conflicts_bulk_test.go | 457 ++++++++++++++ cmd/server/tests/media_bulk_test.go | 352 +++++++++++ cmd/server/tests/opds_test.go | 396 +++++++++++++ cmd/server/tests/refresh_token_test.go | 413 +++++++++++++ internal/middleware/middleware_test.go | 319 ++++++++++ internal/services/scheduler_test.go | 143 +++++ internal/services/worker_test.go | 347 +++++++++++ internal/utils/isbn_test.go | 248 ++++++++ 11 files changed, 4275 insertions(+) create mode 100644 cmd/server/tests/analytics_test.go create mode 100644 cmd/server/tests/book_matching_test.go create mode 100644 cmd/server/tests/collections_bulk_test.go create mode 100644 cmd/server/tests/conflicts_bulk_test.go create mode 100644 cmd/server/tests/media_bulk_test.go create mode 100644 cmd/server/tests/opds_test.go create mode 100644 cmd/server/tests/refresh_token_test.go create mode 100644 internal/middleware/middleware_test.go create mode 100644 internal/services/scheduler_test.go create mode 100644 internal/services/worker_test.go create mode 100644 internal/utils/isbn_test.go diff --git a/cmd/server/tests/analytics_test.go b/cmd/server/tests/analytics_test.go new file mode 100644 index 0000000..d310bb4 --- /dev/null +++ b/cmd/server/tests/analytics_test.go @@ -0,0 +1,470 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestAnalyticsReadingStats tests the reading statistics endpoint +func TestAnalyticsReadingStats(t *testing.T) { + t.Run("GetReadingStats_WithoutAuth", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + req, _ := http.NewRequest("GET", ts.URL+"/api/analytics/reading-stats", nil) + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("GetReadingStats_WithAuth_DefaultDates", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req, _ := http.NewRequest("GET", ts.URL+"/api/analytics/reading-stats", nil) + req.Header.Set("Authorization", "Bearer "+token) + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + assert.Contains(t, result, "total_books_read") + assert.Contains(t, result, "total_pages_read") + assert.Contains(t, result, "total_reading_time_minutes") + assert.Contains(t, result, "average_session_time_minutes") + assert.Contains(t, result, "completion_rate") + assert.Contains(t, result, "daily_reading_minutes") + }) + + t.Run("GetReadingStats_WithCustomDateRange", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + startDate := time.Now().AddDate(0, -2, 0).Format("2006-01-02") + endDate := time.Now().Format("2006-01-02") + + req, _ := http.NewRequest("GET", ts.URL+"/api/analytics/reading-stats?start_date="+startDate+"&end_date="+endDate, nil) + req.Header.Set("Authorization", "Bearer "+token) + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("GetReadingStats_InvalidStartDate", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req, _ := http.NewRequest("GET", ts.URL+"/api/analytics/reading-stats?start_date=invalid-date", nil) + req.Header.Set("Authorization", "Bearer "+token) + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("GetReadingStats_InvalidEndDate", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req, _ := http.NewRequest("GET", ts.URL+"/api/analytics/reading-stats?end_date=not-a-date", nil) + req.Header.Set("Authorization", "Bearer "+token) + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("GetReadingStats_EmptyHistory", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req, _ := http.NewRequest("GET", ts.URL+"/api/analytics/reading-stats", nil) + req.Header.Set("Authorization", "Bearer "+token) + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + // Should return zero values for empty history + assert.Equal(t, 0.0, result["total_books_read"]) + assert.Equal(t, 0.0, result["total_pages_read"]) + }) +} + +// TestAnalyticsDeviceUsage tests the device usage endpoint +func TestAnalyticsDeviceUsage(t *testing.T) { + t.Run("GetDeviceUsage_WithoutAuth", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + req, _ := http.NewRequest("GET", ts.URL+"/api/analytics/device-usage", nil) + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("GetDeviceUsage_WithAuth_NoDevices", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req, _ := http.NewRequest("GET", ts.URL+"/api/analytics/device-usage", nil) + req.Header.Set("Authorization", "Bearer "+token) + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + devices, ok := result["devices"].([]interface{}) + assert.True(t, ok) + assert.Equal(t, 0, len(devices)) + }) + + t.Run("GetDeviceUsage_WithAuth_WithDevices", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + // First create a device + deviceReq := map[string]interface{}{ + "device_name": "Test Kobo", + "device_type": "kobo", + } + deviceBody, _ := json.Marshal(deviceReq) + + deviceReqHTTP, _ := http.NewRequest("POST", ts.URL+"/api/devices/register", bytes.NewBuffer(deviceBody)) + deviceReqHTTP.Header.Set("Content-Type", "application/json") + deviceReqHTTP.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(deviceReqHTTP) + require.NoError(t, err) + resp.Body.Close() + + // Now get device usage + req, _ := http.NewRequest("GET", ts.URL+"/api/analytics/device-usage", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err = client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + devices, ok := result["devices"].([]interface{}) + assert.True(t, ok) + // May be 0 if device has no usage data yet + assert.NotNil(t, devices) + }) + + t.Run("GetDeviceUsage_ResponseStructure", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req, _ := http.NewRequest("GET", ts.URL+"/api/analytics/device-usage", nil) + req.Header.Set("Authorization", "Bearer "+token) + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + assert.Contains(t, result, "devices") + + // Check device structure if any devices exist + if devices, ok := result["devices"].([]interface{}); ok && len(devices) > 0 { + firstDevice := devices[0].(map[string]interface{}) + assert.Contains(t, firstDevice, "device_id") + assert.Contains(t, firstDevice, "device_name") + assert.Contains(t, firstDevice, "device_type") + assert.Contains(t, firstDevice, "sync_count") + assert.Contains(t, firstDevice, "last_sync") + assert.Contains(t, firstDevice, "total_time_seconds") + assert.Contains(t, firstDevice, "total_time_minutes") + } + }) +} + +// TestAnalyticsPopularBooks tests the popular books endpoint +func TestAnalyticsPopularBooks(t *testing.T) { + t.Run("GetPopularBooks_WithoutAuth", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + req, _ := http.NewRequest("GET", ts.URL+"/api/analytics/popular-books", nil) + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("GetPopularBooks_WithAuth_DefaultLimit", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req, _ := http.NewRequest("GET", ts.URL+"/api/analytics/popular-books", nil) + req.Header.Set("Authorization", "Bearer "+token) + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + assert.Contains(t, result, "books") + books := result["books"].([]interface{}) + assert.NotNil(t, books) + // Default limit is 10, but may be fewer if no reading history + assert.True(t, len(books) <= 10) + }) + + t.Run("GetPopularBooks_WithCustomLimit", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req, _ := http.NewRequest("GET", ts.URL+"/api/analytics/popular-books?limit=5", nil) + req.Header.Set("Authorization", "Bearer "+token) + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + books := result["books"].([]interface{}) + assert.True(t, len(books) <= 5) + }) + + t.Run("GetPopularBooks_InvalidLimit", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req, _ := http.NewRequest("GET", ts.URL+"/api/analytics/popular-books?limit=invalid", nil) + req.Header.Set("Authorization", "Bearer "+token) + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + // Should default to 10 on invalid limit + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + books := result["books"].([]interface{}) + assert.True(t, len(books) <= 10) + }) + + t.Run("GetPopularBooks_ResponseStructure", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + // First create a book and some reading history + bookID := createTestEbookID(t, ts, token) + + // Create reading history for the book + historyReq := map[string]interface{}{ + "media_item_id": bookID, + "progress_percentage": 50.0, + "pages_read": 100, + "time_spent_seconds": 1800, + } + historyBody, _ := json.Marshal(historyReq) + + historyHTTP, _ := http.NewRequest("POST", ts.URL+"/api/media-items/"+bookID+"/progress", bytes.NewBuffer(historyBody)) + historyHTTP.Header.Set("Content-Type", "application/json") + historyHTTP.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(historyHTTP) + require.NoError(t, err) + resp.Body.Close() + + // Now get popular books + req, _ := http.NewRequest("GET", ts.URL+"/api/analytics/popular-books", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err = client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + books := result["books"].([]interface{}) + + // Check book structure if any books exist + if len(books) > 0 { + firstBook := books[0].(map[string]interface{}) + assert.Contains(t, firstBook, "media_item_id") + assert.Contains(t, firstBook, "title") + assert.Contains(t, firstBook, "author") + assert.Contains(t, firstBook, "read_count") + assert.Contains(t, firstBook, "avg_completion") + assert.Contains(t, firstBook, "last_read") + } + }) + + t.Run("GetPopularBooks_NoReadingHistory", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req, _ := http.NewRequest("GET", ts.URL+"/api/analytics/popular-books", nil) + req.Header.Set("Authorization", "Bearer "+token) + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + books := result["books"].([]interface{}) + // Should return empty array if no reading history + assert.Equal(t, 0, len(books)) + }) +} + +// TestAnalyticsEdgeCases tests edge cases for analytics endpoints +func TestAnalyticsEdgeCases(t *testing.T) { + t.Run("ReadingStats_FutureDateRange", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + startDate := time.Now().AddDate(0, 0, 7).Format("2006-01-02") + endDate := time.Now().AddDate(0, 0, 14).Format("2006-01-02") + + req, _ := http.NewRequest("GET", ts.URL+"/api/analytics/reading-stats?start_date="+startDate+"&end_date="+endDate, nil) + req.Header.Set("Authorization", "Bearer "+token) + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + // Should succeed but return empty stats + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + assert.Equal(t, 0.0, result["total_books_read"]) + }) + + t.Run("PopularBooks_LimitZero", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req, _ := http.NewRequest("GET", ts.URL+"/api/analytics/popular-books?limit=0", nil) + req.Header.Set("Authorization", "Bearer "+token) + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + // Should handle limit=0 gracefully + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + books := result["books"].([]interface{}) + assert.Equal(t, 0, len(books)) + }) + + t.Run("PopularBooks_VeryLargeLimit", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req, _ := http.NewRequest("GET", ts.URL+"/api/analytics/popular-books?limit=999999", nil) + req.Header.Set("Authorization", "Bearer "+token) + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + // Should handle large limit + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) +} diff --git a/cmd/server/tests/book_matching_test.go b/cmd/server/tests/book_matching_test.go new file mode 100644 index 0000000..542f761 --- /dev/null +++ b/cmd/server/tests/book_matching_test.go @@ -0,0 +1,691 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestBookMatchingQueryBooks tests the book query endpoint +func TestBookMatchingQueryBooks(t *testing.T) { + t.Run("QueryBooks_WithoutAuth", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + req := map[string]interface{}{ + "title": "Test Book", + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/sync/books/query", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("QueryBooks_WithAuth_ByTitle", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + _ = createTestEbookID(t, ts, token) + + req := map[string]interface{}{ + "title": "Test Ebook", + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/sync/books/query", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + assert.Contains(t, result, "matches") + assert.Contains(t, result, "action") + }) + + t.Run("QueryBooks_InvalidRequestBody", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + // Send invalid JSON + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/sync/books/query", bytes.NewBuffer([]byte("invalid json"))) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("QueryBooks_NoResults", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req := map[string]interface{}{ + "title": "NonExistentBookTitleThatDoesNotExist123456789", + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/sync/books/query", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + matches := result["matches"].([]interface{}) + assert.Equal(t, 0, len(matches)) + }) +} + +// TestBookMatchingBulkLink tests bulk linking operations +func TestBookMatchingBulkLink(t *testing.T) { + t.Run("BulkLinkBooks_WithoutAuth", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + req := map[string]interface{}{ + "links": []map[string]interface{}{ + { + "unlinked_book_id": uuid.New(), + "media_item_id": uuid.New(), + "confidence_score": 0.9, + }, + }, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/sync/bulk-link-books", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("BulkLinkBooks_WithAuth_EmptyLinks", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req := map[string]interface{}{ + "links": []map[string]interface{}{}, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/sync/bulk-link-books", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + assert.Equal(t, 0, result["total"]) + assert.Equal(t, 0, result["successful"]) + assert.Equal(t, 0, result["failed"]) + }) + + t.Run("BulkLinkBooks_InvalidUnlinkedBookID", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + bookID := createTestEbookID(t, ts, token) + + req := map[string]interface{}{ + "links": []map[string]interface{}{ + { + "unlinked_book_id": uuid.New(), + "media_item_id": bookID, + "confidence_score": 0.9, + }, + }, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/sync/bulk-link-books", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + assert.Contains(t, result, "results") + assert.Contains(t, result, "total") + assert.Contains(t, result, "successful") + assert.Contains(t, result, "failed") + + results := result["results"].([]interface{}) + firstResult := results[0].(map[string]interface{}) + assert.Equal(t, "error", firstResult["status"]) + }) + + t.Run("BulkLinkBooks_MultipleLinks", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req := map[string]interface{}{ + "links": []map[string]interface{}{ + { + "unlinked_book_id": uuid.New(), + "media_item_id": uuid.New(), + "confidence_score": 0.9, + }, + { + "unlinked_book_id": uuid.New(), + "media_item_id": uuid.New(), + "confidence_score": 0.8, + }, + { + "unlinked_book_id": uuid.New(), + "media_item_id": uuid.New(), + "confidence_score": 0.95, + }, + }, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/sync/bulk-link-books", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + assert.Equal(t, 3, result["total"]) + results := result["results"].([]interface{}) + assert.Equal(t, 3, len(results)) + }) +} + +// TestBookMatchingAutoLink tests automatic linking +func TestBookMatchingAutoLink(t *testing.T) { + t.Run("AutoLinkBooks_WithoutAuth", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + req := map[string]interface{}{ + "confidence_threshold": 0.8, + "limit": 10, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/sync/auto-link-books", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("AutoLinkBooks_WithAuth_DefaultThreshold", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req := map[string]interface{}{} + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/sync/auto-link-books", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + assert.Contains(t, result, "auto_linked") + assert.Contains(t, result, "results") + }) + + t.Run("AutoLinkBooks_CustomThreshold", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req := map[string]interface{}{ + "confidence_threshold": 0.95, + "limit": 20, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/sync/auto-link-books", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + assert.Contains(t, result, "auto_linked") + }) + + t.Run("AutoLinkBooks_NoUnlinkedBooks", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req := map[string]interface{}{ + "limit": 5, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/sync/auto-link-books", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + // Should succeed even with no books to link + assert.Contains(t, result, "auto_linked") + }) +} + +// TestBookMatchingSuggestions tests getting suggestions for unlinked books +func TestBookMatchingSuggestions(t *testing.T) { + t.Run("GetUnlinkedBookSuggestions_WithoutAuth", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + testID := uuid.New() + httpReq, _ := http.NewRequest("GET", ts.URL+"/api/sync/unlinked-books/"+testID.String()+"/suggestions", nil) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("GetUnlinkedBookSuggestions_InvalidUUID", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + httpReq, _ := http.NewRequest("GET", ts.URL+"/api/sync/unlinked-books/invalid-uuid/suggestions", nil) + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("GetUnlinkedBookSuggestions_BookNotFound", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + testID := uuid.New() + httpReq, _ := http.NewRequest("GET", ts.URL+"/api/sync/unlinked-books/"+testID.String()+"/suggestions", nil) + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("GetUnlinkedBookSuggestions_ResponseStructure", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + // Create a test device and unlinked book would go here + // For now, test with a non-existent ID to check response structure + testID := uuid.New() + httpReq, _ := http.NewRequest("GET", ts.URL+"/api/sync/unlinked-books/"+testID.String()+"/suggestions", nil) + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + // Even when book not found, we expect 404 + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) +} + +// TestBookMatchingDeviceFileAliases tests device file alias operations +func TestBookMatchingDeviceFileAliases(t *testing.T) { + t.Run("GetDeviceFileAliases_WithoutAuth", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + testID := uuid.New() + httpReq, _ := http.NewRequest("GET", ts.URL+"/api/devices/"+testID.String()+"/file-aliases", nil) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("GetDeviceFileAliases_WithAuth", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + testID := uuid.New() + httpReq, _ := http.NewRequest("GET", ts.URL+"/api/devices/"+testID.String()+"/file-aliases", nil) + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + assert.Contains(t, result, "device_id") + assert.Contains(t, result, "aliases") + assert.Contains(t, result, "total") + }) + + t.Run("CreateDeviceFileAlias_WithoutAuth", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + deviceID := uuid.New() + mediaItemID := uuid.New() + + req := map[string]interface{}{ + "media_item_id": mediaItemID.String(), + "file_path": "/mnt/sd/test.epub", + "file_sha256": "", + "confidence_score": 0.9, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/devices/"+deviceID.String()+"/file-aliases", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("CreateDeviceFileAlias_InvalidDeviceID", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + mediaItemID := uuid.New() + + req := map[string]interface{}{ + "media_item_id": mediaItemID.String(), + "file_path": "/mnt/sd/test.epub", + "file_sha256": "", + "confidence_score": 0.9, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/devices/invalid-uuid/file-aliases", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("CreateDeviceFileAlias_InvalidMediaItemID", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + deviceID := uuid.New() + + req := map[string]interface{}{ + "media_item_id": "invalid-uuid", + "file_path": "/mnt/sd/test.epub", + "file_sha256": "", + "confidence_score": 0.9, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/devices/"+deviceID.String()+"/file-aliases", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("UpdateDeviceFileAlias_InvalidAliasID", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + deviceID := uuid.New() + + req := map[string]interface{}{ + "confidence_score": 0.95, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("PUT", ts.URL+"/api/devices/"+deviceID.String()+"/file-aliases/invalid-uuid", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("DeleteDeviceFileAlias_InvalidAliasID", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + deviceID := uuid.New() + + httpReq, _ := http.NewRequest("DELETE", ts.URL+"/api/devices/"+deviceID.String()+"/file-aliases/invalid-uuid", nil) + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +// TestBookMatchingGetBookMatches tests the book matches endpoint +func TestBookMatchingGetBookMatches(t *testing.T) { + t.Run("GetBookMatches_WithoutAuth", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + httpReq, _ := http.NewRequest("GET", ts.URL+"/api/books/match?title=Test", nil) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("GetBookMatches_WithAuth_ByTitle", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + httpReq, _ := http.NewRequest("GET", ts.URL+"/api/books/match?title=Test", nil) + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + assert.Contains(t, result, "matches") + assert.Contains(t, result, "action") + }) + + t.Run("GetBookMatches_InvalidFileSize", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + httpReq, _ := http.NewRequest("GET", ts.URL+"/api/books/match?title=Test&file_size=invalid", nil) + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("GetBookMatches_MultipleIdentifiers", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + httpReq, _ := http.NewRequest("GET", ts.URL+"/api/books/match?identifier=id1&identifier=id2&title=Test", nil) + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + assert.Contains(t, result, "matches") + }) +} diff --git a/cmd/server/tests/collections_bulk_test.go b/cmd/server/tests/collections_bulk_test.go new file mode 100644 index 0000000..5818a0a --- /dev/null +++ b/cmd/server/tests/collections_bulk_test.go @@ -0,0 +1,439 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestCollectionsBulkOperations tests bulk collection operations +func TestCollectionsBulkOperations(t *testing.T) { + t.Run("BulkAddBooks_WithoutAuth", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + req := map[string]interface{}{ + "operations": []map[string]interface{}{ + { + "collection_id": uuid.New().String(), + "book_ids": []string{uuid.New().String()}, + }, + }, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/collections/bulk-add-books", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("BulkAddBooks_EmptyOperations", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req := map[string]interface{}{ + "operations": []map[string]interface{}{}, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/collections/bulk-add-books", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("BulkAddBooks_InvalidCollectionID", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + bookID := createTestEbookID(t, ts, token) + + req := map[string]interface{}{ + "operations": []map[string]interface{}{ + { + "collection_id": "invalid-uuid", + "book_ids": []string{bookID}, + }, + }, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/collections/bulk-add-books", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + assert.Contains(t, result, "results") + assert.Contains(t, result, "total") + assert.Contains(t, result, "success") + assert.Contains(t, result, "failed") + + results := result["results"].([]interface{}) + assert.True(t, len(results) > 0) + + firstResult := results[0].(map[string]interface{}) + assert.Equal(t, "error", firstResult["status"]) + }) + + t.Run("BulkAddBooks_InvalidBookID", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + // Create a collection first + collectionReq := map[string]interface{}{ + "name": "Test Collection", + "description": "A test collection", + } + collectionBody, _ := json.Marshal(collectionReq) + + collectionHTTP, _ := http.NewRequest("POST", ts.URL+"/api/collections", bytes.NewBuffer(collectionBody)) + collectionHTTP.Header.Set("Content-Type", "application/json") + collectionHTTP.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(collectionHTTP) + require.NoError(t, err) + defer resp.Body.Close() + + var collectionResult map[string]interface{} + json.NewDecoder(resp.Body).Decode(&collectionResult) + collectionID := collectionResult["id"].(string) + + // Now try to add invalid book IDs + addReq := map[string]interface{}{ + "operations": []map[string]interface{}{ + { + "collection_id": collectionID, + "book_ids": []string{"invalid-uuid"}, + }, + }, + } + addBody, _ := json.Marshal(addReq) + + addHTTP, _ := http.NewRequest("POST", ts.URL+"/api/collections/bulk-add-books", bytes.NewBuffer(addBody)) + addHTTP.Header.Set("Content-Type", "application/json") + addHTTP.Header.Set("Authorization", "Bearer "+token) + + resp, err = client.Do(addHTTP) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + results := result["results"].([]interface{}) + firstResult := results[0].(map[string]interface{}) + assert.Equal(t, "error", firstResult["status"]) + }) + + t.Run("BulkAddBooks_SingleOperation", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + // Create a collection + collectionReq := map[string]interface{}{ + "name": "Test Collection", + "description": "A test collection", + } + collectionBody, _ := json.Marshal(collectionReq) + + collectionHTTP, _ := http.NewRequest("POST", ts.URL+"/api/collections", bytes.NewBuffer(collectionBody)) + collectionHTTP.Header.Set("Content-Type", "application/json") + collectionHTTP.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(collectionHTTP) + require.NoError(t, err) + defer resp.Body.Close() + + var collectionResult map[string]interface{} + json.NewDecoder(resp.Body).Decode(&collectionResult) + collectionID := collectionResult["id"].(string) + + // Create a book + bookID := createTestEbookID(t, ts, token) + + // Add book to collection + addReq := map[string]interface{}{ + "operations": []map[string]interface{}{ + { + "collection_id": collectionID, + "book_ids": []string{bookID}, + }, + }, + } + addBody, _ := json.Marshal(addReq) + + addHTTP, _ := http.NewRequest("POST", ts.URL+"/api/collections/bulk-add-books", bytes.NewBuffer(addBody)) + addHTTP.Header.Set("Content-Type", "application/json") + addHTTP.Header.Set("Authorization", "Bearer "+token) + + resp, err = client.Do(addHTTP) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + assert.Contains(t, result, "results") + assert.Contains(t, result, "total") + assert.Contains(t, result, "success") + assert.Contains(t, result, "failed") + + results := result["results"].([]interface{}) + firstResult := results[0].(map[string]interface{}) + assert.Equal(t, "success", firstResult["status"]) + }) + + t.Run("BulkAddBooks_MultipleBooksSingleCollection", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + // Create a collection + collectionReq := map[string]interface{}{ + "name": "Test Collection", + "description": "A test collection", + } + collectionBody, _ := json.Marshal(collectionReq) + + collectionHTTP, _ := http.NewRequest("POST", ts.URL+"/api/collections", bytes.NewBuffer(collectionBody)) + collectionHTTP.Header.Set("Content-Type", "application/json") + collectionHTTP.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(collectionHTTP) + require.NoError(t, err) + defer resp.Body.Close() + + var collectionResult map[string]interface{} + json.NewDecoder(resp.Body).Decode(&collectionResult) + collectionID := collectionResult["id"].(string) + + // Create multiple books + bookID1 := createTestEbookID(t, ts, token) + bookID2 := createTestEbookID(t, ts, token) + bookID3 := createTestEbookID(t, ts, token) + + // Add all books to collection + addReq := map[string]interface{}{ + "operations": []map[string]interface{}{ + { + "collection_id": collectionID, + "book_ids": []string{bookID1, bookID2, bookID3}, + }, + }, + } + addBody, _ := json.Marshal(addReq) + + addHTTP, _ := http.NewRequest("POST", ts.URL+"/api/collections/bulk-add-books", bytes.NewBuffer(addBody)) + addHTTP.Header.Set("Content-Type", "application/json") + addHTTP.Header.Set("Authorization", "Bearer "+token) + + resp, err = client.Do(addHTTP) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + assert.Equal(t, 3, result["total"]) + assert.True(t, result["success"].(float64) > 0) + }) + + t.Run("BulkAddBooks_MultipleCollections", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + // Create multiple collections + collectionReq := map[string]interface{}{ + "name": "Test Collection 1", + "description": "First test collection", + } + collectionBody, _ := json.Marshal(collectionReq) + + collectionHTTP, _ := http.NewRequest("POST", ts.URL+"/api/collections", bytes.NewBuffer(collectionBody)) + collectionHTTP.Header.Set("Content-Type", "application/json") + collectionHTTP.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(collectionHTTP) + require.NoError(t, err) + defer resp.Body.Close() + + var collectionResult1 map[string]interface{} + json.NewDecoder(resp.Body).Decode(&collectionResult1) + collectionID1 := collectionResult1["id"].(string) + + collectionReq2 := map[string]interface{}{ + "name": "Test Collection 2", + "description": "Second test collection", + } + collectionBody2, _ := json.Marshal(collectionReq2) + + collectionHTTP2, _ := http.NewRequest("POST", ts.URL+"/api/collections", bytes.NewBuffer(collectionBody2)) + collectionHTTP2.Header.Set("Content-Type", "application/json") + collectionHTTP2.Header.Set("Authorization", "Bearer "+token) + + resp, err = client.Do(collectionHTTP2) + require.NoError(t, err) + defer resp.Body.Close() + + var collectionResult2 map[string]interface{} + json.NewDecoder(resp.Body).Decode(&collectionResult2) + collectionID2 := collectionResult2["id"].(string) + + // Create books + bookID1 := createTestEbookID(t, ts, token) + bookID2 := createTestEbookID(t, ts, token) + + // Add books to multiple collections + addReq := map[string]interface{}{ + "operations": []map[string]interface{}{ + { + "collection_id": collectionID1, + "book_ids": []string{bookID1}, + }, + { + "collection_id": collectionID2, + "book_ids": []string{bookID1, bookID2}, + }, + }, + } + addBody, _ := json.Marshal(addReq) + + addHTTP, _ := http.NewRequest("POST", ts.URL+"/api/collections/bulk-add-books", bytes.NewBuffer(addBody)) + addHTTP.Header.Set("Content-Type", "application/json") + addHTTP.Header.Set("Authorization", "Bearer "+token) + + resp, err = client.Do(addHTTP) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + assert.Contains(t, result, "results") + assert.Equal(t, 3, result["total"]) + }) + + t.Run("BulkAddBooks_DuplicateBooks", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + // Create a collection + collectionReq := map[string]interface{}{ + "name": "Test Collection", + "description": "A test collection", + } + collectionBody, _ := json.Marshal(collectionReq) + + collectionHTTP, _ := http.NewRequest("POST", ts.URL+"/api/collections", bytes.NewBuffer(collectionBody)) + collectionHTTP.Header.Set("Content-Type", "application/json") + collectionHTTP.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(collectionHTTP) + require.NoError(t, err) + defer resp.Body.Close() + + var collectionResult map[string]interface{} + json.NewDecoder(resp.Body).Decode(&collectionResult) + collectionID := collectionResult["id"].(string) + + // Create a book + bookID := createTestEbookID(t, ts, token) + + // Add book to collection + addReq := map[string]interface{}{ + "operations": []map[string]interface{}{ + { + "collection_id": collectionID, + "book_ids": []string{bookID}, + }, + }, + } + addBody, _ := json.Marshal(addReq) + + addHTTP, _ := http.NewRequest("POST", ts.URL+"/api/collections/bulk-add-books", bytes.NewBuffer(addBody)) + addHTTP.Header.Set("Content-Type", "application/json") + addHTTP.Header.Set("Authorization", "Bearer "+token) + + resp, err = client.Do(addHTTP) + require.NoError(t, err) + defer resp.Body.Close() + + // Try to add the same book again + resp2, err := client.Do(addHTTP) + require.NoError(t, err) + defer resp2.Body.Close() + + // Should handle duplicate gracefully (either succeed or return error) + assert.Equal(t, http.StatusOK, resp2.StatusCode) + }) + + t.Run("BulkAddBooks_InvalidRequestBody", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + // Send invalid JSON + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/collections/bulk-add-books", bytes.NewBuffer([]byte("invalid json"))) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} diff --git a/cmd/server/tests/conflicts_bulk_test.go b/cmd/server/tests/conflicts_bulk_test.go new file mode 100644 index 0000000..96f51d1 --- /dev/null +++ b/cmd/server/tests/conflicts_bulk_test.go @@ -0,0 +1,457 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestConflictsBulkOperations tests bulk conflict resolution operations +func TestConflictsBulkOperations(t *testing.T) { + t.Run("BulkResolveConflicts_WithoutAuth", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + req := map[string]interface{}{ + "conflict_ids": []string{uuid.New().String()}, + "strategy": "most_recent", + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("BulkResolveConflicts_EmptyConflictIDs", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req := map[string]interface{}{ + "conflict_ids": []string{}, + "strategy": "most_recent", + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("BulkResolveConflicts_InvalidConflictID", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req := map[string]interface{}{ + "conflict_ids": []string{"invalid-uuid"}, + "strategy": "most_recent", + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + assert.Contains(t, result, "results") + assert.Contains(t, result, "total") + assert.Contains(t, result, "success") + assert.Contains(t, result, "failed") + + results := result["results"].([]interface{}) + firstResult := results[0].(map[string]interface{}) + assert.Equal(t, "error", firstResult["status"]) + }) + + t.Run("BulkResolveConflicts_InvalidStrategy", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req := map[string]interface{}{ + "conflict_ids": []string{uuid.New().String()}, + "strategy": "invalid_strategy", + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("BulkResolveConflicts_MostRecentStrategy", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req := map[string]interface{}{ + "conflict_ids": []string{uuid.New().String(), uuid.New().String()}, + "strategy": "most_recent", + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + assert.Contains(t, result, "results") + assert.Equal(t, 2, result["total"]) + }) + + t.Run("BulkResolveConflicts_HighestProgressStrategy", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req := map[string]interface{}{ + "conflict_ids": []string{uuid.New().String(), uuid.New().String()}, + "strategy": "highest_progress", + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + assert.Contains(t, result, "results") + assert.Equal(t, 2, result["total"]) + }) + + t.Run("BulkResolveConflicts_ManualStrategy_WithoutWinner", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req := map[string]interface{}{ + "conflict_ids": []string{uuid.New().String()}, + "strategy": "manual", + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + assert.Contains(t, result, "results") + }) + + t.Run("BulkResolveConflicts_ManualStrategy_WithWinner", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req := map[string]interface{}{ + "conflict_ids": []string{uuid.New().String()}, + "strategy": "manual", + "winning_source": "device", + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("BulkResolveConflicts_InvalidRequestBody", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + // Send invalid JSON + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer([]byte("invalid json"))) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +// TestConflictsBulkDismiss tests bulk dismiss operations +func TestConflictsBulkDismiss(t *testing.T) { + t.Run("BulkDismissConflicts_WithoutAuth", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + req := map[string]interface{}{ + "conflict_ids": []string{uuid.New().String()}, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("BulkDismissConflicts_EmptyConflictIDs", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req := map[string]interface{}{ + "conflict_ids": []string{}, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("BulkDismissConflicts_InvalidConflictID", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req := map[string]interface{}{ + "conflict_ids": []string{"invalid-uuid", uuid.New().String()}, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + assert.Contains(t, result, "results") + assert.Contains(t, result, "total") + assert.Contains(t, result, "success") + assert.Contains(t, result, "failed") + + results := result["results"].([]interface{}) + firstResult := results[0].(map[string]interface{}) + assert.Equal(t, "error", firstResult["status"]) + }) + + t.Run("BulkDismissConflicts_MultipleConflicts", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req := map[string]interface{}{ + "conflict_ids": []string{ + uuid.New().String(), + uuid.New().String(), + uuid.New().String(), + }, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + assert.Contains(t, result, "results") + assert.Equal(t, 3, result["total"]) + }) + + t.Run("BulkDismissConflicts_InvalidRequestBody", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + // Send invalid JSON + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer([]byte("invalid json"))) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +// TestConflictsBulkEdgeCases tests edge cases for bulk operations +func TestConflictsBulkEdgeCases(t *testing.T) { + t.Run("BulkResolve_NonExistentConflicts", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req := map[string]interface{}{ + "conflict_ids": []string{ + uuid.New().String(), + uuid.New().String(), + uuid.New().String(), + }, + "strategy": "most_recent", + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + // All should fail since conflicts don't exist + assert.Equal(t, float64(0), result["success"]) + assert.Equal(t, float64(3), result["failed"]) + }) + + t.Run("BulkDismiss_MixedValidInvalid", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req := map[string]interface{}{ + "conflict_ids": []string{ + "invalid-uuid-1", + "invalid-uuid-2", + uuid.New().String(), + }, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + results := result["results"].([]interface{}) + assert.Equal(t, 3, len(results)) + }) +} diff --git a/cmd/server/tests/media_bulk_test.go b/cmd/server/tests/media_bulk_test.go new file mode 100644 index 0000000..c4c30b8 --- /dev/null +++ b/cmd/server/tests/media_bulk_test.go @@ -0,0 +1,352 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestMediaBulkOperations tests bulk media operations +func TestMediaBulkOperations(t *testing.T) { + t.Run("BulkDeleteBooks_WithoutAuth", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + req := map[string]interface{}{ + "book_ids": []string{uuid.New().String()}, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/books/bulk-delete", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("BulkDeleteBooks_EmptyBookIDs", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req := map[string]interface{}{ + "book_ids": []string{}, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/books/bulk-delete", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("BulkDeleteBooks_InvalidBookIDs", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req := map[string]interface{}{ + "book_ids": []string{"invalid-uuid", "another-invalid"}, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/books/bulk-delete", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + assert.Contains(t, result, "results") + assert.Contains(t, result, "total") + assert.Contains(t, result, "deleted") + assert.Contains(t, result, "failed") + }) + + t.Run("BulkDeleteBooks_WithValidBooks", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + // Create test books + bookID1 := createTestEbookID(t, ts, token) + bookID2 := createTestEbookID(t, ts, token) + bookID3 := uuid.New().String() + + req := map[string]interface{}{ + "book_ids": []string{bookID1, bookID2, bookID3}, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/books/bulk-delete", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + assert.Contains(t, result, "results") + assert.Equal(t, 3, result["total"]) + assert.True(t, result["deleted"].(float64) >= 2) // At least the valid ones + }) + + t.Run("BulkDeleteBooks_InvalidRequestBody", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + // Send invalid JSON + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/books/bulk-delete", bytes.NewBuffer([]byte("invalid json"))) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("BulkUpdateBooks_WithoutAuth", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + req := map[string]interface{}{ + "book_ids": []string{uuid.New().String()}, + "updates": map[string]interface{}{ + "tags": []string{"test"}, + }, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/books/bulk-update", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("BulkUpdateBooks_EmptyBookIDs", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req := map[string]interface{}{ + "book_ids": []string{}, + "updates": map[string]interface{}{ + "tags": []string{"test"}, + }, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/books/bulk-update", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("BulkUpdateBooks_InvalidBookIDs", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req := map[string]interface{}{ + "book_ids": []string{"invalid-uuid"}, + "updates": map[string]interface{}{ + "tags": []string{"fiction", "test"}, + }, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/books/bulk-update", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + assert.Contains(t, result, "results") + assert.Contains(t, result, "total") + assert.Contains(t, result, "updated") + assert.Contains(t, result, "failed") + }) + + t.Run("BulkUpdateBooks_UpdateTags", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + // Create test books + bookID1 := createTestEbookID(t, ts, token) + bookID2 := createTestEbookID(t, ts, token) + + req := map[string]interface{}{ + "book_ids": []string{bookID1, bookID2}, + "updates": map[string]interface{}{ + "tags": []string{"fiction", "science-fiction", "test"}, + }, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/books/bulk-update", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + assert.Contains(t, result, "results") + assert.Equal(t, 2, result["total"]) + assert.True(t, result["updated"].(float64) > 0) + }) + + t.Run("BulkUpdateBooks_UpdateReadingStatus", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + // Create test books + bookID1 := createTestEbookID(t, ts, token) + + req := map[string]interface{}{ + "book_ids": []string{bookID1}, + "updates": map[string]interface{}{ + "reading_status": "reading", + }, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/books/bulk-update", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + assert.Contains(t, result, "results") + }) + + t.Run("BulkUpdateBooks_UpdateMultipleFields", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + // Create test books + bookID1 := createTestEbookID(t, ts, token) + + req := map[string]interface{}{ + "book_ids": []string{bookID1}, + "updates": map[string]interface{}{ + "tags": []string{"test", "bulk-update"}, + "reading_status": "to-read", + "rating": 4, + }, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/books/bulk-update", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + assert.Contains(t, result, "results") + }) + + t.Run("BulkUpdateBooks_InvalidRequestBody", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + // Send invalid JSON + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/books/bulk-update", bytes.NewBuffer([]byte("invalid json"))) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} diff --git a/cmd/server/tests/opds_test.go b/cmd/server/tests/opds_test.go new file mode 100644 index 0000000..835c4ed --- /dev/null +++ b/cmd/server/tests/opds_test.go @@ -0,0 +1,396 @@ +package main + +import ( + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestOPDSEndpoints tests OPDS (Open Publication Distribution System) endpoints +func TestOPDSEndpoints(t *testing.T) { + t.Run("GetDeviceCatalog_WithoutDeviceAuth", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + deviceID := uuid.New() + httpReq, _ := http.NewRequest("GET", ts.URL+"/opds/devices/"+deviceID.String()+"/catalog", nil) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + // OPDS endpoints may or may not require auth - depends on implementation + // Accept either 200 (public) or 401 (requires auth) + assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusUnauthorized) + }) + + t.Run("GetDeviceCatalog_InvalidDeviceID", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + httpReq, _ := http.NewRequest("GET", ts.URL+"/opds/devices/invalid-uuid/catalog", nil) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + // Should return 400 for invalid UUID + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("GetDeviceCatalog_ValidDevice", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + // Note: Device registration requires different endpoint + // For now, test with a valid UUID format + deviceID := uuid.New() + httpReq, _ := http.NewRequest("GET", ts.URL+"/opds/devices/"+deviceID.String()+"/catalog", nil) + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + // Should return either 200 (OK with empty catalog) or 404 (device not found) + assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNotFound) + }) + + t.Run("SearchDeviceCatalog_InvalidDeviceID", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + httpReq, _ := http.NewRequest("GET", ts.URL+"/opds/devices/invalid-uuid/search?query=test", nil) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("SearchDeviceCatalog_ValidDevice", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + deviceID := uuid.New() + + httpReq, _ := http.NewRequest("GET", ts.URL+"/opds/devices/"+deviceID.String()+"/search?query=test", nil) + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + // Should return 200 or 404 + assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNotFound) + }) + + t.Run("GetDeviceNavigation_InvalidDeviceID", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + httpReq, _ := http.NewRequest("GET", ts.URL+"/opds/devices/invalid-uuid/nav", nil) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("GetDeviceNavigation_ValidDevice", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + deviceID := uuid.New() + + httpReq, _ := http.NewRequest("GET", ts.URL+"/opds/devices/"+deviceID.String()+"/nav", nil) + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + // Should return navigation or 404 + assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNotFound) + }) + + t.Run("DownloadBook_InvalidDeviceID", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + bookID := uuid.New() + httpReq, _ := http.NewRequest("GET", ts.URL+"/opds/devices/invalid-uuid/download/"+bookID.String(), nil) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("DownloadBook_InvalidBookID", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + deviceID := uuid.New() + httpReq, _ := http.NewRequest("GET", ts.URL+"/opds/devices/"+deviceID.String()+"/download/invalid-uuid", nil) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("DownloadBook_ValidIDs", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + deviceID := uuid.New() + bookID := createTestEbookID(t, ts, token) + + httpReq, _ := http.NewRequest("GET", ts.URL+"/opds/devices/"+deviceID.String()+"/download/"+bookID, nil) + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + // May return 404 if device/book not linked, or 500 for file not found + // Should not return 400 (invalid IDs) + assert.NotEqual(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("GetCoverImage_InvalidDeviceID", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + bookID := uuid.New() + httpReq, _ := http.NewRequest("GET", ts.URL+"/opds/devices/invalid-uuid/cover/"+bookID.String(), nil) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("GetCoverImage_InvalidBookID", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + deviceID := uuid.New() + httpReq, _ := http.NewRequest("GET", ts.URL+"/opds/devices/"+deviceID.String()+"/cover/invalid-uuid", nil) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("GetCoverImage_ValidIDs", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + deviceID := uuid.New() + bookID := createTestEbookID(t, ts, token) + + httpReq, _ := http.NewRequest("GET", ts.URL+"/opds/devices/"+deviceID.String()+"/cover/"+bookID, nil) + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + // May return 404 if no cover, but not 400 + assert.NotEqual(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("ListFormats_InvalidDeviceID", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + bookID := uuid.New() + httpReq, _ := http.NewRequest("GET", ts.URL+"/opds/devices/invalid-uuid/formats/"+bookID.String(), nil) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("ListFormats_ValidDeviceID", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + deviceID := uuid.New() + bookID := createTestEbookID(t, ts, token) + + httpReq, _ := http.NewRequest("GET", ts.URL+"/opds/devices/"+deviceID.String()+"/formats/"+bookID, nil) + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + // Should return formats list or 404 + assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNotFound) + }) +} + +// TestOPDSConversion tests on-the-fly conversion for downloads +func TestOPDSConversion(t *testing.T) { + t.Run("DownloadKEPUB_FormatParameter", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + deviceID := uuid.New() + bookID := createTestEbookID(t, ts, token) + + // Request KEPUB format + httpReq, _ := http.NewRequest("GET", ts.URL+"/opds/devices/"+deviceID.String()+"/download/"+bookID+"?format=kepub", nil) + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + // Should attempt conversion (may fail if file doesn't exist) + // Important: Should not return 400 for invalid IDs + assert.NotEqual(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("DownloadEPUB_DefaultFormat", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + deviceID := uuid.New() + bookID := createTestEbookID(t, ts, token) + + // Request default format (no format parameter) + httpReq, _ := http.NewRequest("GET", ts.URL+"/opds/devices/"+deviceID.String()+"/download/"+bookID, nil) + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + // Should attempt to download original format + assert.NotEqual(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Download_UnsupportedFormat", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + deviceID := uuid.New() + bookID := createTestEbookID(t, ts, token) + + // Request unsupported format + httpReq, _ := http.NewRequest("GET", ts.URL+"/opds/devices/"+deviceID.String()+"/download/"+bookID+"?format=pdf", nil) + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + // Should handle gracefully (either 400 for unsupported format or 404/500) + assert.True(t, resp.StatusCode >= 400 && resp.StatusCode < 600) + }) +} + +// TestOPDSEdgeCases tests edge cases for OPDS endpoints +func TestOPDSEdgeCases(t *testing.T) { + t.Run("Catalog_EmptyLibrary", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + deviceID := uuid.New() + + httpReq, _ := http.NewRequest("GET", ts.URL+"/opds/devices/"+deviceID.String()+"/catalog", nil) + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + // Should return empty catalog, not error + assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNotFound) + }) + + t.Run("Search_SpecialCharacters", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + deviceID := uuid.New() + + // Search with special characters + httpReq, _ := http.NewRequest("GET", ts.URL+"/opds/devices/"+deviceID.String()+"/search?query=test%20%26%20more", nil) + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + // Should handle special characters + assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNotFound) + }) + + t.Run("Search_EmptyQuery", func(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + deviceID := uuid.New() + + httpReq, _ := http.NewRequest("GET", ts.URL+"/opds/devices/"+deviceID.String()+"/search?query=", nil) + httpReq.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + // Should handle empty query + assert.True(t, resp.StatusCode >= 200 && resp.StatusCode < 500) + }) +} diff --git a/cmd/server/tests/refresh_token_test.go b/cmd/server/tests/refresh_token_test.go new file mode 100644 index 0000000..226b213 --- /dev/null +++ b/cmd/server/tests/refresh_token_test.go @@ -0,0 +1,413 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestRefreshTokenFlow comprehensive tests for token refresh functionality +func TestRefreshTokenFlow(t *testing.T) { + t.Run("RefreshToken_MissingToken", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + req := map[string]interface{}{} + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/auth/refresh", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("RefreshToken_InvalidTokenFormat", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + req := map[string]interface{}{ + "refresh_token": "not-a-valid-jwt-token", + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/auth/refresh", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("RefreshToken_ExpiredToken", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + // This would require an expired token - for now test with invalid token + req := map[string]interface{}{ + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MjAwMDAwMDB9.expired", + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/auth/refresh", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("RefreshToken_ValidToken", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + // First, login to get tokens + loginReq := map[string]string{ + "login": "testuser@example.com", + "password": "Test@Pass123!", + } + + loginBody, _ := json.Marshal(loginReq) + loginHTTP, _ := http.NewRequest("POST", ts.URL+"/api/auth/login", bytes.NewBuffer(loginBody)) + loginHTTP.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + loginResp, err := client.Do(loginHTTP) + require.NoError(t, err) + defer loginResp.Body.Close() + + require.Equal(t, http.StatusOK, loginResp.StatusCode) + + var loginResult map[string]interface{} + json.NewDecoder(loginResp.Body).Decode(&loginResult) + + refreshToken, ok := loginResult["refresh_token"].(string) + require.True(t, ok, "Should have refresh_token") + + // Now use the refresh token + refreshReq := map[string]interface{}{ + "refresh_token": refreshToken, + } + refreshBody, _ := json.Marshal(refreshReq) + + refreshHTTP, _ := http.NewRequest("POST", ts.URL+"/api/auth/refresh", bytes.NewBuffer(refreshBody)) + refreshHTTP.Header.Set("Content-Type", "application/json") + + refreshResp, err := client.Do(refreshHTTP) + require.NoError(t, err) + defer refreshResp.Body.Close() + + assert.Equal(t, http.StatusOK, refreshResp.StatusCode) + + var refreshResult map[string]interface{} + json.NewDecoder(refreshResp.Body).Decode(&refreshResult) + + assert.Contains(t, refreshResult, "access_token") + assert.NotEmpty(t, refreshResult["access_token"], "New access token should not be empty") + + // The new access token should be different from the original + newAccessToken := refreshResult["access_token"].(string) + originalAccessToken := loginResult["access_token"].(string) + assert.NotEqual(t, originalAccessToken, newAccessToken, "New access token should be different") + }) + + t.Run("RefreshToken_InvalidRequestBody", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + // Send invalid JSON + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/auth/refresh", bytes.NewBuffer([]byte("invalid json"))) + httpReq.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("RefreshToken_MissingContentType", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + req := map[string]interface{}{ + "refresh_token": "some-token", + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/auth/refresh", bytes.NewBuffer(body)) + // Don't set Content-Type + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + // Should still work or return appropriate error + assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusUnsupportedMediaType) + }) +} + +// TestRefreshTokenSecurity tests security aspects of token refresh +func TestRefreshTokenSecurity(t *testing.T) { + t.Run("RefreshToken_ReuseProtection", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + // Login to get tokens + loginReq := map[string]string{ + "login": "testuser@example.com", + "password": "Test@Pass123!", + } + + loginBody, _ := json.Marshal(loginReq) + loginHTTP, _ := http.NewRequest("POST", ts.URL+"/api/auth/login", bytes.NewBuffer(loginBody)) + loginHTTP.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + loginResp, err := client.Do(loginHTTP) + require.NoError(t, err) + defer loginResp.Body.Close() + + require.Equal(t, http.StatusOK, loginResp.StatusCode) + + var loginResult map[string]interface{} + json.NewDecoder(loginResp.Body).Decode(&loginResult) + + refreshToken := loginResult["refresh_token"].(string) + + // Use the refresh token first time + refreshReq := map[string]interface{}{ + "refresh_token": refreshToken, + } + refreshBody, _ := json.Marshal(refreshReq) + + refreshHTTP1, _ := http.NewRequest("POST", ts.URL+"/api/auth/refresh", bytes.NewBuffer(refreshBody)) + refreshHTTP1.Header.Set("Content-Type", "application/json") + + refreshResp1, err := client.Do(refreshHTTP1) + require.NoError(t, err) + defer refreshResp1.Body.Close() + + assert.Equal(t, http.StatusOK, refreshResp1.StatusCode) + + // Try to reuse the same refresh token (should fail if refresh token rotation is enabled) + refreshHTTP2, _ := http.NewRequest("POST", ts.URL+"/api/auth/refresh", bytes.NewBuffer(refreshBody)) + refreshHTTP2.Header.Set("Content-Type", "application/json") + + refreshResp2, err := client.Do(refreshHTTP2) + require.NoError(t, err) + defer refreshResp2.Body.Close() + + // May return 401 if token reuse is detected, or 200 if not implemented + // Either is acceptable depending on security requirements + assert.True(t, refreshResp2.StatusCode == http.StatusOK || refreshResp2.StatusCode == http.StatusUnauthorized) + }) + + t.Run("RefreshToken_TokenTampering", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + // Login to get a valid token + loginReq := map[string]string{ + "login": "testuser@example.com", + "password": "Test@Pass123!", + } + + loginBody, _ := json.Marshal(loginReq) + loginHTTP, _ := http.NewRequest("POST", ts.URL+"/api/auth/login", bytes.NewBuffer(loginBody)) + loginHTTP.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + loginResp, err := client.Do(loginHTTP) + require.NoError(t, err) + defer loginResp.Body.Close() + + require.Equal(t, http.StatusOK, loginResp.StatusCode) + + var loginResult map[string]interface{} + json.NewDecoder(loginResp.Body).Decode(&loginResult) + + refreshToken := loginResult["refresh_token"].(string) + + // Tamper with the token by modifying a character + if len(refreshToken) > 10 { + tamperedToken := refreshToken[:5] + "X" + refreshToken[6:] + + refreshReq := map[string]interface{}{ + "refresh_token": tamperedToken, + } + refreshBody, _ := json.Marshal(refreshReq) + + refreshHTTP, _ := http.NewRequest("POST", ts.URL+"/api/auth/refresh", bytes.NewBuffer(refreshBody)) + refreshHTTP.Header.Set("Content-Type", "application/json") + + refreshResp, err := client.Do(refreshHTTP) + require.NoError(t, err) + defer refreshResp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, refreshResp.StatusCode) + } + }) +} + +// TestRefreshTokenEdgeCases tests edge cases for token refresh +func TestRefreshTokenEdgeCases(t *testing.T) { + t.Run("RefreshToken_EmptyStringToken", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + req := map[string]interface{}{ + "refresh_token": "", + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/auth/refresh", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("RefreshToken_NullToken", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + req := map[string]interface{}{ + "refresh_token": nil, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", ts.URL+"/api/auth/refresh", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("RefreshToken_ResponseStructure", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + // Login to get tokens + loginReq := map[string]string{ + "login": "testuser@example.com", + "password": "Test@Pass123!", + } + + loginBody, _ := json.Marshal(loginReq) + loginHTTP, _ := http.NewRequest("POST", ts.URL+"/api/auth/login", bytes.NewBuffer(loginBody)) + loginHTTP.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + loginResp, err := client.Do(loginHTTP) + require.NoError(t, err) + defer loginResp.Body.Close() + + require.Equal(t, http.StatusOK, loginResp.StatusCode) + + var loginResult map[string]interface{} + json.NewDecoder(loginResp.Body).Decode(&loginResult) + + refreshToken := loginResult["refresh_token"].(string) + + // Refresh the token + refreshReq := map[string]interface{}{ + "refresh_token": refreshToken, + } + refreshBody, _ := json.Marshal(refreshReq) + + refreshHTTP, _ := http.NewRequest("POST", ts.URL+"/api/auth/refresh", bytes.NewBuffer(refreshBody)) + refreshHTTP.Header.Set("Content-Type", "application/json") + + refreshResp, err := client.Do(refreshHTTP) + require.NoError(t, err) + defer refreshResp.Body.Close() + + require.Equal(t, http.StatusOK, refreshResp.StatusCode) + + var refreshResult map[string]interface{} + json.NewDecoder(refreshResp.Body).Decode(&refreshResult) + + // Verify response structure + assert.Contains(t, refreshResult, "access_token") + assert.NotEmpty(t, refreshResult["access_token"]) + + // May or may not contain refresh_token (if rotation is enabled) + // Both are valid responses + }) + + t.Run("RefreshToken_TokenType", func(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + // Login to get tokens + loginReq := map[string]string{ + "login": "testuser@example.com", + "password": "Test@Pass123!", + } + + loginBody, _ := json.Marshal(loginReq) + loginHTTP, _ := http.NewRequest("POST", ts.URL+"/api/auth/login", bytes.NewBuffer(loginBody)) + loginHTTP.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + loginResp, err := client.Do(loginHTTP) + require.NoError(t, err) + defer loginResp.Body.Close() + + require.Equal(t, http.StatusOK, loginResp.StatusCode) + + var loginResult map[string]interface{} + json.NewDecoder(loginResp.Body).Decode(&loginResult) + + refreshToken := loginResult["refresh_token"].(string) + + // Refresh the token + refreshReq := map[string]interface{}{ + "refresh_token": refreshToken, + } + refreshBody, _ := json.Marshal(refreshReq) + + refreshHTTP, _ := http.NewRequest("POST", ts.URL+"/api/auth/refresh", bytes.NewBuffer(refreshBody)) + refreshHTTP.Header.Set("Content-Type", "application/json") + + refreshResp, err := client.Do(refreshHTTP) + require.NoError(t, err) + defer refreshResp.Body.Close() + + require.Equal(t, http.StatusOK, refreshResp.StatusCode) + + var refreshResult map[string]interface{} + json.NewDecoder(refreshResp.Body).Decode(&refreshResult) + + // Verify access token is a string + accessToken, ok := refreshResult["access_token"].(string) + assert.True(t, ok, "access_token should be a string") + assert.NotEmpty(t, accessToken, "access_token should not be empty") + assert.Greater(t, len(accessToken), 20, "access_token should be a reasonably long JWT") + }) +} diff --git a/internal/middleware/middleware_test.go b/internal/middleware/middleware_test.go new file mode 100644 index 0000000..b2619ec --- /dev/null +++ b/internal/middleware/middleware_test.go @@ -0,0 +1,319 @@ +package middleware + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestPasswordValidator_ValidatePassword(t *testing.T) { + tests := []struct { + name string + password string + wantValid bool + }{ + { + name: "valid password with all requirements", + password: "Test@Pass123!", + wantValid: true, + }, + { + name: "valid password with special chars", + password: "MyP@ssw0rd#2024", + wantValid: true, + }, + { + name: "too short", + password: "Test1!", + wantValid: false, + }, + { + name: "no uppercase", + password: "test@pass123!", + wantValid: false, + }, + { + name: "no lowercase", + password: "TEST@PASS123!", + wantValid: false, + }, + { + name: "no number", + password: "Test@Password!", + wantValid: false, + }, + { + name: "no special character", + password: "TestPassword123", + wantValid: false, + }, + { + name: "empty string", + password: "", + wantValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidatePassword(tt.password) + if tt.wantValid { + assert.NoError(t, err) + } else { + assert.Error(t, err) + } + }) + } +} + +func TestPasswordValidator_ErrorMessages(t *testing.T) { + tests := []struct { + name string + password string + expectedInError string + }{ + { + name: "too short error", + password: "Short1!", + expectedInError: "at least 8 characters", + }, + { + name: "no uppercase error", + password: "alllower123!", + expectedInError: "uppercase letter", + }, + { + name: "no lowercase error", + password: "ALLUPPER123!", + expectedInError: "lowercase letter", + }, + { + name: "no number error", + password: "NoNumbers!", + expectedInError: "number", + }, + { + name: "no special char error", + password: "NoSpecialChars123", + expectedInError: "special character", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidatePassword(tt.password) + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedInError) + }) + } +} + +func TestGetPasswordRequirements(t *testing.T) { + requirements := GetPasswordRequirements() + + assert.NotEmpty(t, requirements) + assert.Greater(t, len(requirements), 3) + + // Check for common requirements + requirementText := "" + for _, req := range requirements { + requirementText += req + " " + } + + assert.Contains(t, requirementText, "8") + assert.Contains(t, requirementText, "uppercase") + assert.Contains(t, requirementText, "lowercase") + assert.Contains(t, requirementText, "number") + assert.Contains(t, requirementText, "special") +} + +func TestLoginAttemptTracker_RecordFailedAttempt(t *testing.T) { + tracker := NewLoginAttemptTracker(3, 5*time.Minute, 5*time.Minute) + + username := "testuser" + + // First failed attempt + locked, remainingTime := tracker.RecordFailedAttempt(username) + assert.False(t, locked) + assert.Equal(t, time.Duration(0), remainingTime) + + // Second failed attempt + locked, remainingTime = tracker.RecordFailedAttempt(username) + assert.False(t, locked) + assert.Equal(t, time.Duration(0), remainingTime) + + // Third failed attempt - should lock + locked, remainingTime = tracker.RecordFailedAttempt(username) + assert.True(t, locked) + assert.Greater(t, remainingTime, time.Duration(0)) + + // Verify user is locked + locked, _ = tracker.IsLocked(username) + assert.True(t, locked) + + // Clear attempts + tracker.ClearAttempts(username) + + // Should no longer be locked + locked, _ = tracker.IsLocked(username) + assert.False(t, locked) +} + +func TestLoginAttemptTracker_IsLocked(t *testing.T) { + tracker := NewLoginAttemptTracker(3, 5*time.Minute, 5*time.Minute) + + username := "lockeduser" + + // Record failed attempts up to max + for i := 0; i < 3; i++ { + tracker.RecordFailedAttempt(username) + } + + // Verify user is locked + locked, remainingTime := tracker.IsLocked(username) + assert.True(t, locked) + assert.Greater(t, remainingTime, time.Duration(0)) + + // Clear attempts + tracker.ClearAttempts(username) + + // Should no longer be locked + locked, remainingTime = tracker.IsLocked(username) + assert.False(t, locked) + assert.Equal(t, time.Duration(0), remainingTime) +} + +func TestLoginAttemptTracker_ConcurrentAccess(t *testing.T) { + tracker := NewLoginAttemptTracker(5, 5*time.Minute, 5*time.Minute) + + done := make(chan bool, 10) + + // Concurrent access from multiple goroutines + for i := 0; i < 10; i++ { + go func(index int) { + username := "user" + string(rune('0'+index)) + tracker.RecordFailedAttempt(username) + tracker.IsLocked(username) + tracker.ClearAttempts(username) + done <- true + }(i) + } + + // Wait for all goroutines + for i := 0; i < 10; i++ { + <-done + } + + // Should complete without deadlock or race +} + +func TestDeviceRateLimiter_CheckRateLimit(t *testing.T) { + limiter := NewDeviceRateLimiter() + + config := DeviceRateLimitConfig{ + SyncRequestsPerMinute: 5, + } + + deviceID := "test-device-123" + requestType := "sync" + + // First 5 requests should succeed + for i := 0; i < 5; i++ { + allowed := limiter.CheckRateLimit(deviceID, requestType, config) + assert.True(t, allowed, "Request %d should be allowed", i+1) + } + + // 6th request should be rate limited + allowed := limiter.CheckRateLimit(deviceID, requestType, config) + assert.False(t, allowed, "Request 6 should be rate limited") + + // Get remaining requests + remaining := limiter.GetRemainingRequests(deviceID, requestType, config) + assert.Equal(t, 0, remaining) + + // Reset and verify + limiter.Reset(deviceID) + + // Should be allowed again + allowed = limiter.CheckRateLimit(deviceID, requestType, config) + assert.True(t, allowed, "Request after reset should be allowed") +} + +func TestDeviceRateLimiter_DifferentDevices(t *testing.T) { + limiter := NewDeviceRateLimiter() + + config := DeviceRateLimitConfig{ + SyncRequestsPerMinute: 2, + } + + // Exhaust limit for device1 + for i := 0; i < 2; i++ { + limiter.CheckRateLimit("device1", "sync", config) + } + + // Device1 should be rate limited + allowed := limiter.CheckRateLimit("device1", "sync", config) + assert.False(t, allowed) + + // Device2 should still work + allowed = limiter.CheckRateLimit("device2", "sync", config) + assert.True(t, allowed) +} + +func TestDeviceRateLimiter_GetRemainingRequests(t *testing.T) { + limiter := NewDeviceRateLimiter() + + config := DeviceRateLimitConfig{ + SyncRequestsPerMinute: 10, + } + + deviceID := "test-device-456" + + // Initially should have all requests remaining + remaining := limiter.GetRemainingRequests(deviceID, "scan", config) + assert.Equal(t, 10, remaining) + + // Use 3 requests + for i := 0; i < 3; i++ { + limiter.CheckRateLimit(deviceID, "scan", config) + } + + // Should have 7 remaining + remaining = limiter.GetRemainingRequests(deviceID, "scan", config) + assert.Equal(t, 7, remaining) +} + +func TestNewRateLimiter(t *testing.T) { + config := DefaultRateLimiterConfig() + limiter := NewRateLimiter(config) + + assert.NotNil(t, limiter) + assert.NotNil(t, limiter.mu) +} + +func TestHTTPError_Error(t *testing.T) { + err := NewHTTPError(404, "Not Found", nil) + + assert.NotNil(t, err) + assert.Equal(t, 404, err.Code) + assert.Equal(t, "Not Found", err.Message) +} + +func TestHTTPError_ErrorWithInternal(t *testing.T) { + internalErr := assert.AnError + err := NewHTTPError(500, "Internal Error", internalErr) + + assert.Equal(t, "Internal Error", err.Error()) + assert.Equal(t, 500, err.Code) + assert.Equal(t, "Internal Error", err.Message) + assert.Equal(t, internalErr, err.Err) +} + +func TestNewHTTPError(t *testing.T) { + err := NewHTTPError(404, "Not Found", nil) + + assert.NotNil(t, err) + assert.Equal(t, 404, err.Code) + assert.Equal(t, "Not Found", err.Message) +} diff --git a/internal/services/scheduler_test.go b/internal/services/scheduler_test.go new file mode 100644 index 0000000..edbaef8 --- /dev/null +++ b/internal/services/scheduler_test.go @@ -0,0 +1,143 @@ +package services + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestScheduler_NewScheduler(t *testing.T) { + worker := NewWorker(1) + + // Use nil database interface for basic testing + scheduler := NewScheduler(worker, nil) + + assert.NotNil(t, scheduler) + assert.NotNil(t, scheduler.worker) + assert.NotNil(t, scheduler.timers) + assert.NotNil(t, scheduler.scanSettings) + assert.NotNil(t, scheduler.ctx) + assert.NotNil(t, scheduler.cancel) +} + +func TestScheduler_StartStop(t *testing.T) { + worker := NewWorker(1) + scheduler := NewScheduler(worker, nil) + + // Start should not panic + scheduler.Start() + assert.NotNil(t, scheduler.ctx) + + // Stop should not panic + scheduler.Stop() +} + +func TestScheduler_UpdateScanSettings(t *testing.T) { + worker := NewWorker(1) + scheduler := NewScheduler(worker, nil) + + userID := "test-user-123" + + // Update scan settings + scheduler.UpdateScanSettings(userID, true, 30) + + scheduler.mu.Lock() + settings, exists := scheduler.scanSettings[userID] + scheduler.mu.Unlock() + + assert.True(t, exists) + assert.Equal(t, userID, settings.UserID) + assert.True(t, settings.Enabled) + assert.Equal(t, 30, settings.Frequency) +} + +func TestScheduler_UpdateScanSettings_Disabled(t *testing.T) { + worker := NewWorker(1) + scheduler := NewScheduler(worker, nil) + + userID := "test-user-456" + + // Update scan settings to disabled + scheduler.UpdateScanSettings(userID, false, 60) + + scheduler.mu.Lock() + settings, exists := scheduler.scanSettings[userID] + scheduler.mu.Unlock() + + assert.True(t, exists) + assert.False(t, settings.Enabled) + assert.Equal(t, 60, settings.Frequency) +} + +func TestScheduler_UpdateScanSettings_Overwrite(t *testing.T) { + worker := NewWorker(1) + scheduler := NewScheduler(worker, nil) + + userID := "test-user-789" + + // First update + scheduler.UpdateScanSettings(userID, true, 30) + + // Overwrite with different settings + scheduler.UpdateScanSettings(userID, false, 45) + + scheduler.mu.Lock() + settings, exists := scheduler.scanSettings[userID] + scheduler.mu.Unlock() + + assert.True(t, exists) + assert.False(t, settings.Enabled) + assert.Equal(t, 45, settings.Frequency) +} + +func TestScheduler_StopWithActiveTimers(t *testing.T) { + worker := NewWorker(1) + scheduler := NewScheduler(worker, nil) + + // Add some fake timers + scheduler.mu.Lock() + scheduler.timers["timer1"] = nil + scheduler.timers["timer2"] = nil + scheduler.timers["timer3"] = nil + scheduler.mu.Unlock() + + // Stop should clear timers + scheduler.Stop() + + scheduler.mu.Lock() + timerCount := len(scheduler.timers) + scheduler.mu.Unlock() + + assert.Equal(t, 0, timerCount) +} + +func TestScheduler_ConcurrentAccess(t *testing.T) { + worker := NewWorker(1) + scheduler := NewScheduler(worker, nil) + scheduler.Start() + defer scheduler.Stop() + + // Concurrent updates should not cause race conditions + done := make(chan bool, 10) + + for i := 0; i < 10; i++ { + go func(index int) { + userID := uuid.New().String() + scheduler.UpdateScanSettings(userID, true, 30) + done <- true + }(i) + } + + // Wait for all goroutines + for i := 0; i < 10; i++ { + <-done + } + + // Verify all settings were stored + scheduler.mu.Lock() + settingCount := len(scheduler.scanSettings) + scheduler.mu.Unlock() + + assert.Equal(t, 10, settingCount) +} diff --git a/internal/services/worker_test.go b/internal/services/worker_test.go new file mode 100644 index 0000000..bfb4f60 --- /dev/null +++ b/internal/services/worker_test.go @@ -0,0 +1,347 @@ +package services + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWorker_NewWorker(t *testing.T) { + worker := NewWorker(2) + + assert.NotNil(t, worker) + assert.NotNil(t, worker.jobQueue) + assert.NotNil(t, worker.results) + assert.NotNil(t, worker.ctx) + assert.NotNil(t, worker.cancel) +} + +func TestWorker_EnqueueJob_Success(t *testing.T) { + worker := NewWorker(1) + defer worker.Shutdown() + + job := &Job{ + ID: "test-job-1", + Type: JobTypeScan, + Params: map[string]interface{}{}, + Status: JobStatusPending, + } + + err := worker.EnqueueJob(job) + assert.NoError(t, err) +} + +func TestWorker_EnqueueJob_QueueFull(t *testing.T) { + worker := NewWorker(1) + defer worker.Shutdown() + + // Fill the queue (capacity is 100) + for i := 0; i < 100; i++ { + job := &Job{ + ID: fmt.Sprintf("job-%d", i), + Type: JobTypeScan, + Params: map[string]interface{}{}, + Status: JobStatusPending, + } + worker.jobQueue <- job + } + + // Try to enqueue one more job + job := &Job{ + ID: "overflow-job", + Type: JobTypeScan, + Params: map[string]interface{}{}, + Status: JobStatusPending, + } + + err := worker.EnqueueJob(job) + assert.Error(t, err) + assert.Contains(t, err.Error(), "job queue is full") +} + +func TestWorker_EnqueueJob_WorkerShutdown(t *testing.T) { + worker := NewWorker(1) + worker.Shutdown() + + job := &Job{ + ID: "test-job", + Type: JobTypeScan, + Params: map[string]interface{}{}, + Status: JobStatusPending, + } + + err := worker.EnqueueJob(job) + assert.Error(t, err) + assert.Contains(t, err.Error(), "worker is shutting down") +} + +func TestWorker_GetJobStatus_NotFound(t *testing.T) { + worker := NewWorker(1) + defer worker.Shutdown() + + result, exists := worker.GetJobStatus("non-existent-job") + assert.False(t, exists) + assert.Nil(t, result) +} + +func TestWorker_GetJobStatus_Found(t *testing.T) { + worker := NewWorker(1) + defer worker.Shutdown() + + _ = &Job{ + ID: "test-job-2", + Type: JobTypeScan, + Params: map[string]interface{}{}, + Status: JobStatusPending, + } + + worker.mu.Lock() + worker.results["test-job-2"] = &JobResult{ + JobID: "test-job-2", + Status: JobStatusPending, + } + worker.mu.Unlock() + + result, exists := worker.GetJobStatus("test-job-2") + assert.True(t, exists) + assert.NotNil(t, result) + assert.Equal(t, "test-job-2", result.JobID) + assert.Equal(t, JobStatusPending, result.Status) +} + +func TestWorker_CancelJob_Success(t *testing.T) { + worker := NewWorker(1) + defer worker.Shutdown() + + jobID := "test-job-3" + + worker.mu.Lock() + worker.results[jobID] = &JobResult{ + JobID: jobID, + Status: JobStatusPending, + } + worker.mu.Unlock() + + err := worker.CancelJob(jobID) + assert.NoError(t, err) + + // Verify status was updated + result, exists := worker.GetJobStatus(jobID) + assert.True(t, exists) + assert.Equal(t, JobStatusCancelled, result.Status) +} + +func TestWorker_CancelJob_AlreadyRunning(t *testing.T) { + worker := NewWorker(1) + defer worker.Shutdown() + + jobID := "test-job-4" + + worker.mu.Lock() + worker.results[jobID] = &JobResult{ + JobID: jobID, + Status: JobStatusRunning, + } + worker.mu.Unlock() + + err := worker.CancelJob(jobID) + assert.NoError(t, err) + + // Verify status was updated + result, exists := worker.GetJobStatus(jobID) + assert.True(t, exists) + assert.Equal(t, JobStatusCancelled, result.Status) +} + +func TestWorker_CancelJob_AlreadyCompleted(t *testing.T) { + worker := NewWorker(1) + defer worker.Shutdown() + + jobID := "test-job-5" + + worker.mu.Lock() + worker.results[jobID] = &JobResult{ + JobID: jobID, + Status: JobStatusCompleted, + } + worker.mu.Unlock() + + err := worker.CancelJob(jobID) + assert.Error(t, err) + assert.Contains(t, err.Error(), "job cannot be cancelled") +} + +func TestWorker_CancelJob_NotFound(t *testing.T) { + worker := NewWorker(1) + defer worker.Shutdown() + + err := worker.CancelJob("non-existent-job") + assert.Error(t, err) + assert.Contains(t, err.Error(), "job not found") +} + +func TestWorker_ProcessJob_ScanJob_MissingParams(t *testing.T) { + worker := NewWorker(1) + defer worker.Shutdown() + + tests := []struct { + name string + params map[string]interface{} + wantErr string + }{ + { + name: "missing library_id", + params: map[string]interface{}{}, + wantErr: "library_id required", + }, + { + name: "missing folders", + params: map[string]interface{}{ + "library_id": "test-lib", + }, + wantErr: "folders required", + }, + { + name: "missing admin_id", + params: map[string]interface{}{ + "library_id": "test-lib", + "folders": []string{"/test"}, + }, + wantErr: "admin_id required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testJob := &Job{ + ID: "test-job", + Type: JobTypeScan, + Params: tt.params, + Status: JobStatusPending, + } + + result, err := worker.processScanJob(testJob) + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + assert.Nil(t, result) + }) + } +} + +func TestWorker_ProcessJob_UnknownJobType(t *testing.T) { + worker := NewWorker(1) + defer worker.Shutdown() + + job := &Job{ + ID: "test-job", + Type: JobType("unknown"), + Params: map[string]interface{}{}, + Status: JobStatusPending, + } + + result, err := worker.processScanJob(job) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown job type") + assert.Nil(t, result) +} + +func TestWorker_JobLifecycle(t *testing.T) { + worker := NewWorker(1) + defer worker.Shutdown() + + job := &Job{ + ID: "lifecycle-test", + Type: JobTypeScan, + Params: map[string]interface{}{ + "library_id": "test-lib", + "folders": []string{"/test"}, + "admin_id": "test-admin", + "db": nil, // Will fail but tests the flow + }, + Status: JobStatusPending, + } + + // Enqueue the job + err := worker.EnqueueJob(job) + require.NoError(t, err) + + // Give worker time to process + time.Sleep(100 * time.Millisecond) + + // Check job status + result, exists := worker.GetJobStatus("lifecycle-test") + assert.True(t, exists) + assert.NotNil(t, result) + + // Status should be failed (because we passed nil db) + assert.Equal(t, JobStatusFailed, result.Status) +} + +func TestWorker_Shutdown(t *testing.T) { + worker := NewWorker(2) + + // Enqueue some jobs + for i := 0; i < 5; i++ { + testJob := &Job{ + ID: fmt.Sprintf("shutdown-job-%d", i), + Type: JobTypeScan, + Params: map[string]interface{}{}, + Status: JobStatusPending, + } + worker.EnqueueJob(testJob) + } + + // Shutdown should not block + shutdownDone := make(chan bool) + go func() { + worker.Shutdown() + shutdownDone <- true + }() + + select { + case <-shutdownDone: + // Shutdown completed + case <-time.After(5 * time.Second): + t.Fatal("Shutdown took too long") + } +} + +func TestWorker_ConcurrentJobProcessing(t *testing.T) { + worker := NewWorker(3) // 3 workers + defer worker.Shutdown() + + jobCount := 10 + + // Enqueue multiple jobs + for i := 0; i < jobCount; i++ { + job := &Job{ + ID: fmt.Sprintf("concurrent-job-%d", i), + Type: JobTypeScan, + Params: map[string]interface{}{ + "library_id": fmt.Sprintf("lib-%d", i), + "folders": []string{"/test"}, + "admin_id": "admin", + "db": nil, + }, + Status: JobStatusPending, + } + + go func(j *Job) { + err := worker.EnqueueJob(j) + assert.NoError(t, err) + }(job) + } + + // Wait a bit for processing + time.Sleep(200 * time.Millisecond) + + // Check that all jobs were processed + worker.mu.RLock() + resultCount := len(worker.results) + worker.mu.RUnlock() + + assert.Equal(t, jobCount, resultCount) +} diff --git a/internal/utils/isbn_test.go b/internal/utils/isbn_test.go new file mode 100644 index 0000000..122e2a1 --- /dev/null +++ b/internal/utils/isbn_test.go @@ -0,0 +1,248 @@ +package utils + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNormalizeISBN_ValidISBNs(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "ISBN-10 with hyphens", + input: "0-306-40615-2", + expected: "0306406152", + }, + { + name: "ISBN-13 with hyphens", + input: "978-0-306-40615-7", + expected: "9780306406157", + }, + { + name: "ISBN-10 with spaces", + input: "0 306 40615 2", + expected: "0306406152", + }, + { + name: "ISBN-13 with spaces", + input: "978 0 306 40615 7", + expected: "9780306406157", + }, + { + name: "ISBN-10 with hyphens and spaces", + input: "0-306-40615 2", + expected: "0306406152", + }, + { + name: "ISBN-13 with hyphens and spaces", + input: "978-0-306 40615-7", + expected: "9780306406157", + }, + { + name: "ISBN-10 clean", + input: "0306406152", + expected: "0306406152", + }, + { + name: "ISBN-13 clean", + input: "9780306406157", + expected: "9780306406157", + }, + { + name: "ISBN-10 with X", + input: "0-8044-2957-X", + expected: "080442957X", + }, + { + name: "ISBN-10 with X and hyphens", + input: "0-8044-2957-X", + expected: "080442957X", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := NormalizeISBN(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestNormalizeISBN_EdgeCases(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "empty string", + input: "", + expected: "", + }, + { + name: "only hyphens", + input: "---", + expected: "", + }, + { + name: "only spaces", + input: " ", + expected: "", + }, + { + name: "mixed hyphens and spaces", + input: "- - -", + expected: "", + }, + { + name: "multiple consecutive hyphens", + input: "0--306--40615--2", + expected: "0306406152", + }, + { + name: "multiple consecutive spaces", + input: "0 306 40615 2", + expected: "0306406152", + }, + { + name: "tabs and newlines (treated as spaces)", + input: "0\t306\n40615\r2", + expected: "0306406152", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := NormalizeISBN(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestNormalizeISBN_SpecialCharacters(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "with dots (not removed, only hyphens/spaces)", + input: "978.0.306.40615.7", + expected: "978.0.306.40615.7", + }, + { + name: "mixed dots and hyphens", + input: "978-0.306-40615.7", + expected: "978.0.306-40615.7", + }, + { + name: "with underscores (preserved)", + input: "978_0_306_40615_7", + expected: "978_0_306_40615_7", + }, + { + name: "with slashes (preserved)", + input: "978/0/306/40615/7", + expected: "978/0/306/40615/7", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := NormalizeISBN(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestNormalizeISBN_RealWorldExamples(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "real book ISBN-10", + input: "0-596-00965-X", + expected: "059600965X", + }, + { + name: "real book ISBN-13", + input: "978-0-596-00965-2", + expected: "9780596009652", + }, + { + name: "another real ISBN-10", + input: "1-4028-9462-7", + expected: "1402894627", + }, + { + name: "another real ISBN-13", + input: "978-1-4028-9462-6", + expected: "9781402894626", + }, + { + name: "popular programming book", + input: "978-0-13-595705-9", + expected: "9780135957059", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := NormalizeISBN(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestNormalizeISBN_DoesNotModifyValidISBNs(t *testing.T) { + validISBNs := []string{ + "0306406152", + "9780306406157", + "080442957X", + "1234567890123", + } + + for _, isbn := range validISBNs { + t.Run(isbn, func(t *testing.T) { + result := NormalizeISBN(isbn) + assert.Equal(t, isbn, result, "Valid ISBN should not be modified") + }) + } +} + +func TestNormalizeISBN_PreservesX(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "X at end", + input: "0-8044-2957-X", + expected: "080442957X", + }, + { + name: "lowercase x", + input: "0-8044-2957-x", + expected: "080442957x", + }, + { + name: "X in middle (invalid but preserved)", + input: "0-8044-X-2957", + expected: "08044X2957", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := NormalizeISBN(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +}