- Convert all map-based responses to handlers.SearchMediaItemsResponse - Add library creation for each test using CreateLibrary() helper - Implement 25+ comprehensive test cases covering: - Filtering by status, genre, language, collection, has_cover, tags - Sorting by title, author, date_added, last_read - Pagination and limits - Edge cases (empty library_id, invalid sort, negative offset, zero limit) - Advanced filters (year range, rating, progress, text search, series, publisher, favorites, archived) This replaces map-heavy approach with type-safe responses and follows the project's structured handler pattern.
390 lines
12 KiB
Go
390 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"bookhoard/internal/handlers"
|
|
"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) {
|
|
setup := setupTestServer(t)
|
|
token := loginTestUser(t, setup.Server, setup.DB)
|
|
client := &http.Client{}
|
|
|
|
t.Run("GetReadingStats_WithoutAuth", func(t *testing.T) {
|
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/reading-stats", nil)
|
|
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) {
|
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/reading-stats", 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 handlers.ReadingStatsResponse
|
|
json.NewDecoder(resp.Body).Decode(&result)
|
|
|
|
assert.GreaterOrEqual(t, result.TotalBooksRead, 0)
|
|
assert.GreaterOrEqual(t, result.TotalPagesRead, 0)
|
|
assert.GreaterOrEqual(t, result.TotalReadingTime, 0)
|
|
})
|
|
|
|
t.Run("GetReadingStats_WithCustomDateRange", func(t *testing.T) {
|
|
startDate := time.Now().AddDate(0, -2, 0).Format("2006-01-02")
|
|
endDate := time.Now().Format("2006-01-02")
|
|
|
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/reading-stats?start_date="+startDate+"&end_date="+endDate, 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)
|
|
})
|
|
|
|
t.Run("GetReadingStats_InvalidStartDate", func(t *testing.T) {
|
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/reading-stats?start_date=invalid-date", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
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) {
|
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/reading-stats?end_date=not-a-date", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
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) {
|
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/reading-stats", 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)
|
|
|
|
// 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) {
|
|
setup := setupTestServer(t)
|
|
token := loginTestUser(t, setup.Server, setup.DB)
|
|
client := &http.Client{}
|
|
|
|
t.Run("GetDeviceUsage_WithoutAuth", func(t *testing.T) {
|
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/device-usage", nil)
|
|
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) {
|
|
req, _ := http.NewRequest("GET", setup.Server.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 handlers.DeviceUsageResponse
|
|
json.NewDecoder(resp.Body).Decode(&result)
|
|
|
|
assert.NotNil(t, result.Devices)
|
|
assert.Equal(t, 0, len(result.Devices))
|
|
})
|
|
|
|
t.Run("GetDeviceUsage_WithAuth_WithDevices", func(t *testing.T) {
|
|
// First create a device
|
|
deviceReq := map[string]interface{}{
|
|
"device_name": "Test Kobo",
|
|
"device_type": "kobo",
|
|
}
|
|
deviceBody, _ := json.Marshal(deviceReq)
|
|
|
|
deviceReqHTTP, _ := http.NewRequest("POST", setup.Server.URL+"/api/devices/register", bytes.NewBuffer(deviceBody))
|
|
deviceReqHTTP.Header.Set("Content-Type", "application/json")
|
|
deviceReqHTTP.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
resp, err := client.Do(deviceReqHTTP)
|
|
require.NoError(t, err)
|
|
resp.Body.Close()
|
|
|
|
// Now get device usage
|
|
req, _ := http.NewRequest("GET", setup.Server.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) {
|
|
req, _ := http.NewRequest("GET", setup.Server.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)
|
|
|
|
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) {
|
|
setup := setupTestServer(t)
|
|
token := loginTestUser(t, setup.Server, setup.DB)
|
|
client := &http.Client{}
|
|
|
|
t.Run("GetPopularBooks_WithoutAuth", func(t *testing.T) {
|
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/popular-books", nil)
|
|
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) {
|
|
req, _ := http.NewRequest("GET", setup.Server.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 handlers.PopularBooksResponse
|
|
json.NewDecoder(resp.Body).Decode(&result)
|
|
|
|
assert.NotNil(t, result.Books)
|
|
// Default limit is 10, but may be fewer if no reading history
|
|
assert.True(t, len(result.Books) <= 10)
|
|
})
|
|
|
|
t.Run("GetPopularBooks_WithCustomLimit", func(t *testing.T) {
|
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/popular-books?limit=5", 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{})
|
|
assert.True(t, len(books) <= 5)
|
|
})
|
|
|
|
t.Run("GetPopularBooks_InvalidLimit", func(t *testing.T) {
|
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/popular-books?limit=invalid", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
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) {
|
|
// First create a book and some reading history
|
|
bookID := createTestMediaItemID(t, setup.Server, 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", setup.Server.URL+"/api/media-items/"+bookID+"/progress", bytes.NewBuffer(historyBody))
|
|
historyHTTP.Header.Set("Content-Type", "application/json")
|
|
historyHTTP.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
resp, err := client.Do(historyHTTP)
|
|
require.NoError(t, err)
|
|
resp.Body.Close()
|
|
|
|
// Now get popular books
|
|
req, _ := http.NewRequest("GET", setup.Server.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) {
|
|
req, _ := http.NewRequest("GET", setup.Server.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{})
|
|
// 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) {
|
|
setup := setupTestServer(t)
|
|
token := loginTestUser(t, setup.Server, setup.DB)
|
|
client := &http.Client{}
|
|
|
|
t.Run("ReadingStats_FutureDateRange", func(t *testing.T) {
|
|
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", setup.Server.URL+"/api/analytics/reading-stats?start_date="+startDate+"&end_date="+endDate, nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
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) {
|
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/popular-books?limit=0", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
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) {
|
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/popular-books?limit=999999", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
// Should handle large limit
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
})
|
|
}
|