Replace direct error equality check with errors.Is() in media_scanner_hash_test. In analytics_test.go, improve defer patterns by capturing resp.Body as a named parameter to avoid stale references, and add require.NoError() checks on all json.NewDecoder().Decode() calls that were previously silently ignoring decode errors.
438 lines
13 KiB
Go
438 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"bookhoard/internal/handlers"
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"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)
|
|
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 func(Body io.ReadCloser) {
|
|
_ = Body.Close()
|
|
}(resp.Body)
|
|
|
|
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 "+setup.Token)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
defer func(Body io.ReadCloser) {
|
|
_ = Body.Close()
|
|
}(resp.Body)
|
|
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
var result handlers.ReadingStatsResponse
|
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
|
require.NoError(t, err)
|
|
|
|
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 "+setup.Token)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
defer func(Body io.ReadCloser) {
|
|
_ = Body.Close()
|
|
}(resp.Body)
|
|
|
|
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 "+setup.Token)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
defer func(Body io.ReadCloser) {
|
|
_ = Body.Close()
|
|
}(resp.Body)
|
|
|
|
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 "+setup.Token)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
defer func(Body io.ReadCloser) {
|
|
_ = Body.Close()
|
|
}(resp.Body)
|
|
|
|
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 "+setup.Token)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
defer func(Body io.ReadCloser) {
|
|
_ = Body.Close()
|
|
}(resp.Body)
|
|
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
var result map[string]interface{}
|
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
|
require.NoError(t, err)
|
|
|
|
// 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)
|
|
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 func(Body io.ReadCloser) {
|
|
_ = Body.Close()
|
|
}(resp.Body)
|
|
|
|
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 "+setup.Token)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
var result handlers.DeviceUsageResponse
|
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
|
require.NoError(t, err)
|
|
|
|
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 "+setup.Token)
|
|
|
|
resp, err := client.Do(deviceReqHTTP)
|
|
require.NoError(t, err)
|
|
defer func(Body io.ReadCloser) {
|
|
_ = Body.Close()
|
|
}(resp.Body)
|
|
|
|
// Now get device usage
|
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/device-usage", nil)
|
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
|
|
|
resp, err = client.Do(req)
|
|
require.NoError(t, err)
|
|
defer func(Body io.ReadCloser) {
|
|
_ = Body.Close()
|
|
}(resp.Body)
|
|
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
var result map[string]interface{}
|
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
|
require.NoError(t, err)
|
|
|
|
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 "+setup.Token)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
defer func(Body io.ReadCloser) {
|
|
_ = Body.Close()
|
|
}(resp.Body)
|
|
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
var result map[string]interface{}
|
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
|
require.NoError(t, err)
|
|
|
|
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)
|
|
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 func(Body io.ReadCloser) {
|
|
_ = Body.Close()
|
|
}(resp.Body)
|
|
|
|
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 "+setup.Token)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
defer func(Body io.ReadCloser) {
|
|
_ = Body.Close()
|
|
}(resp.Body)
|
|
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
var result handlers.PopularBooksResponse
|
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
|
require.NoError(t, err)
|
|
|
|
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 "+setup.Token)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
defer func(Body io.ReadCloser) {
|
|
_ = Body.Close()
|
|
}(resp.Body)
|
|
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
var result map[string]interface{}
|
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
|
require.NoError(t, err)
|
|
|
|
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 "+setup.Token)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
defer func(Body io.ReadCloser) {
|
|
_ = Body.Close()
|
|
}(resp.Body)
|
|
|
|
// Should default to 10 on invalid limit
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
var result map[string]interface{}
|
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
|
require.NoError(t, err)
|
|
|
|
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)
|
|
|
|
// 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 "+setup.Token)
|
|
|
|
resp, err := client.Do(historyHTTP)
|
|
require.NoError(t, err)
|
|
defer func(Body io.ReadCloser) {
|
|
_ = Body.Close()
|
|
}(resp.Body)
|
|
|
|
// Now get popular books
|
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/popular-books", nil)
|
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
|
|
|
resp, err = client.Do(req)
|
|
require.NoError(t, err)
|
|
defer func(Body io.ReadCloser) {
|
|
_ = Body.Close()
|
|
}(resp.Body)
|
|
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
var result map[string]interface{}
|
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
|
require.NoError(t, err)
|
|
|
|
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 "+setup.Token)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
defer func(Body io.ReadCloser) {
|
|
_ = Body.Close()
|
|
}(resp.Body)
|
|
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
var result map[string]interface{}
|
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
|
require.NoError(t, err)
|
|
|
|
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)
|
|
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 "+setup.Token)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
defer func(Body io.ReadCloser) {
|
|
_ = Body.Close()
|
|
}(resp.Body)
|
|
|
|
// Should succeed but return empty stats
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
var result map[string]interface{}
|
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
|
require.NoError(t, err)
|
|
|
|
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 "+setup.Token)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
defer func(Body io.ReadCloser) {
|
|
_ = Body.Close()
|
|
}(resp.Body)
|
|
|
|
// Should handle limit=0 gracefully
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
var result map[string]interface{}
|
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
|
require.NoError(t, err)
|
|
|
|
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 "+setup.Token)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
defer func(Body io.ReadCloser) {
|
|
_ = Body.Close()
|
|
}(resp.Body)
|
|
|
|
// Should handle large limit
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
})
|
|
}
|