veza/veza-backend-api/tests/analytics/analytics_test.go

498 lines
15 KiB
Go

//go:build integration || analytics
// +build integration analytics
package analytics
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
coreanalytics "veza-backend-api/internal/core/analytics"
"veza-backend-api/internal/database"
"veza-backend-api/internal/handlers"
"veza-backend-api/internal/models"
"veza-backend-api/internal/services"
"veza-backend-api/internal/workers"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap/zaptest"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// setupAnalyticsTestRouter crée un router de test avec les services nécessaires pour les tests d'analytics
func setupAnalyticsTestRouter(t *testing.T) (*gin.Engine, *gorm.DB, *database.Database, *services.AnalyticsService, *workers.JobWorker, func()) {
gin.SetMode(gin.TestMode)
logger := zaptest.NewLogger(t)
// Setup in-memory SQLite database
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
db.Exec("PRAGMA foreign_keys = ON")
// Auto-migrate models
err = db.AutoMigrate(
&models.User{},
&models.Track{},
&models.TrackPlay{},
&workers.Job{},
)
require.NoError(t, err)
// Get underlying sql.DB from GORM for raw SQL queries
sqlDB, err := db.DB()
require.NoError(t, err)
dbWrapper := &database.Database{
DB: sqlDB,
GormDB: db,
Logger: logger,
}
// Setup services
analyticsService := services.NewAnalyticsService(db, logger)
jobService := services.NewJobService(logger)
jobWorker := workers.NewJobWorker(db, jobService, logger, 100, 1, 3, nil)
jobService.SetJobEnqueuer(jobWorker) // Connect JobService to JobWorker
// Setup handlers (core/analytics, ADR-001)
analyticsHandler := coreanalytics.NewHandler(analyticsService, logger)
analyticsHandler.SetJobWorker(jobWorker)
// Create router
router := gin.New()
// Mock auth middleware - set user_id from header if present
router.Use(func(c *gin.Context) {
if userIDStr := c.GetHeader("X-User-ID"); userIDStr != "" {
if userID, err := uuid.Parse(userIDStr); err == nil {
c.Set("user_id", userID)
}
}
c.Next()
})
// Setup analytics routes
analytics := router.Group("/api/v1/analytics")
{
analytics.POST("/events", analyticsHandler.RecordEvent)
analytics.GET("/tracks/:id", analyticsHandler.GetTrackAnalyticsDashboard)
}
cleanup := func() {
// Database will be closed automatically
}
return router, db, dbWrapper, analyticsService, jobWorker, cleanup
}
// createTestUser crée un utilisateur de test
func createTestUser(t *testing.T, db *gorm.DB, email, username string) *models.User {
user := &models.User{
ID: uuid.New(),
Username: username,
Email: email,
PasswordHash: "hashed_password",
Slug: username,
IsActive: true,
IsVerified: true,
}
err := db.Create(user).Error
require.NoError(t, err)
return user
}
// createTestTrack crée un track de test
func createTestTrack(t *testing.T, db *gorm.DB, userID uuid.UUID, title string, duration int) *models.Track {
track := &models.Track{
ID: uuid.New(),
UserID: userID,
Title: title,
FilePath: fmt.Sprintf("/tracks/%s.mp3", uuid.New().String()),
FileSize: 5 * 1024 * 1024,
Format: "MP3",
Duration: duration,
IsPublic: true,
Status: models.TrackStatusCompleted,
}
err := db.Create(track).Error
require.NoError(t, err)
return track
}
// createTestTrackPlay crée un play de test
func createTestTrackPlay(t *testing.T, db *gorm.DB, trackID uuid.UUID, userID *uuid.UUID, duration int, playedAt time.Time) *models.TrackPlay {
play := &models.TrackPlay{
ID: uuid.New(),
TrackID: trackID,
UserID: userID,
Duration: duration,
PlayedAt: playedAt,
Device: "Test Device",
IPAddress: "127.0.0.1",
}
err := db.Create(play).Error
require.NoError(t, err)
return play
}
// TestAnalytics_RecordEvent teste l'enregistrement d'un événement analytics personnalisé
func TestAnalytics_RecordEvent(t *testing.T) {
router, db, _, _, _, cleanup := setupAnalyticsTestRouter(t)
defer cleanup()
user := createTestUser(t, db, "test@example.com", "testuser")
t.Run("Record event with authenticated user", func(t *testing.T) {
payload := map[string]interface{}{
"action": "button_click",
"page": "home",
}
body, _ := json.Marshal(coreanalytics.RecordEventRequest{
EventName: "user_interaction",
Payload: payload,
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/analytics/events", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", user.ID.String())
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response handlers.APIResponse
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.True(t, response.Success)
// Verify job was created
var job workers.Job
err = db.Where("type = ?", "analytics").First(&job).Error
assert.NoError(t, err)
assert.Equal(t, "analytics", job.Type)
assert.Equal(t, "pending", job.Status)
})
t.Run("Record event without authentication", func(t *testing.T) {
payload := map[string]interface{}{
"action": "page_view",
}
body, _ := json.Marshal(coreanalytics.RecordEventRequest{
EventName: "page_view",
Payload: payload,
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/analytics/events", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response handlers.APIResponse
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.True(t, response.Success)
// Verify job was created (anonymous event)
var job workers.Job
err = db.Where("type = ?", "analytics").Order("created_at DESC").First(&job).Error
assert.NoError(t, err)
})
t.Run("Record event with invalid request", func(t *testing.T) {
// Missing event_name
body, _ := json.Marshal(map[string]interface{}{
"payload": map[string]interface{}{"key": "value"},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/analytics/events", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", user.ID.String())
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
})
t.Run("Record event with empty event_name", func(t *testing.T) {
body, _ := json.Marshal(coreanalytics.RecordEventRequest{
EventName: "",
Payload: map[string]interface{}{"key": "value"},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/analytics/events", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", user.ID.String())
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
})
t.Run("Record event with very long event_name", func(t *testing.T) {
longName := make([]byte, 101)
for i := range longName {
longName[i] = 'a'
}
body, _ := json.Marshal(coreanalytics.RecordEventRequest{
EventName: string(longName),
Payload: map[string]interface{}{"key": "value"},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/analytics/events", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", user.ID.String())
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
})
}
// TestAnalytics_GetTrackAnalyticsDashboard teste la récupération du dashboard d'analytics pour un track
func TestAnalytics_GetTrackAnalyticsDashboard(t *testing.T) {
router, db, _, _, _, cleanup := setupAnalyticsTestRouter(t)
defer cleanup()
user := createTestUser(t, db, "test@example.com", "testuser")
track := createTestTrack(t, db, user.ID, "Test Track", 180)
t.Run("Get dashboard for track with plays", func(t *testing.T) {
// Create multiple plays
now := time.Now()
userID := user.ID
createTestTrackPlay(t, db, track.ID, &userID, 120, now.Add(-2*24*time.Hour))
createTestTrackPlay(t, db, track.ID, &userID, 150, now.Add(-1*24*time.Hour))
createTestTrackPlay(t, db, track.ID, nil, 100, now)
createTestTrackPlay(t, db, track.ID, nil, 180, now) // Completed play (90%+)
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/analytics/tracks/%s", track.ID.String()), nil)
req.Header.Set("X-User-ID", user.ID.String())
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response handlers.APIResponse
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.True(t, response.Success)
// Verify dashboard structure
dataBytes, _ := json.Marshal(response.Data)
var data map[string]interface{}
err = json.Unmarshal(dataBytes, &data)
require.NoError(t, err)
dashboard, ok := data["dashboard"].(map[string]interface{})
require.True(t, ok)
// Verify track_id
assert.Equal(t, track.ID.String(), dashboard["track_id"])
// Verify stats
stats, ok := dashboard["stats"].(map[string]interface{})
require.True(t, ok)
assert.Greater(t, stats["total_plays"], float64(0))
assert.GreaterOrEqual(t, stats["unique_listeners"], float64(0))
assert.GreaterOrEqual(t, stats["average_duration"], float64(0))
assert.GreaterOrEqual(t, stats["completion_rate"], float64(0))
// Verify plays_over_time
playsOverTime, ok := dashboard["plays_over_time"].([]interface{})
require.True(t, ok)
assert.GreaterOrEqual(t, len(playsOverTime), 0)
// Verify period
period, ok := dashboard["period"].(map[string]interface{})
require.True(t, ok)
assert.NotEmpty(t, period["start_date"])
assert.NotEmpty(t, period["end_date"])
assert.Equal(t, float64(30), period["days"])
})
t.Run("Get dashboard for track without plays", func(t *testing.T) {
newTrack := createTestTrack(t, db, user.ID, "New Track", 180)
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/analytics/tracks/%s", newTrack.ID.String()), nil)
req.Header.Set("X-User-ID", user.ID.String())
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response handlers.APIResponse
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.True(t, response.Success)
// Verify dashboard structure
dataBytes, _ := json.Marshal(response.Data)
var data map[string]interface{}
err = json.Unmarshal(dataBytes, &data)
require.NoError(t, err)
dashboard, ok := data["dashboard"].(map[string]interface{})
require.True(t, ok)
// Verify stats are zero
stats, ok := dashboard["stats"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, float64(0), stats["total_plays"])
assert.Equal(t, float64(0), stats["unique_listeners"])
assert.Equal(t, float64(0), stats["average_duration"])
assert.Equal(t, float64(0), stats["completion_rate"])
})
t.Run("Get dashboard for non-existent track", func(t *testing.T) {
nonExistentID := uuid.New()
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/analytics/tracks/%s", nonExistentID.String()), nil)
req.Header.Set("X-User-ID", user.ID.String())
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
})
t.Run("Get dashboard with invalid track ID", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/analytics/tracks/invalid-uuid", nil)
req.Header.Set("X-User-ID", user.ID.String())
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
})
}
// TestAnalytics_RecordEvent_JobWorkerMissing teste le cas où le JobWorker n'est pas disponible
func TestAnalytics_RecordEvent_JobWorkerMissing(t *testing.T) {
gin.SetMode(gin.TestMode)
logger := zaptest.NewLogger(t)
// Setup in-memory SQLite database
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
db.Exec("PRAGMA foreign_keys = ON")
// Auto-migrate models
err = db.AutoMigrate(
&models.User{},
&models.Track{},
)
require.NoError(t, err)
// Setup services
analyticsService := services.NewAnalyticsService(db, logger)
// Setup handler WITHOUT JobWorker
analyticsHandler := coreanalytics.NewHandler(analyticsService, logger)
// Don't set JobWorker
// Create router
router := gin.New()
router.Use(func(c *gin.Context) {
if userIDStr := c.GetHeader("X-User-ID"); userIDStr != "" {
if userID, err := uuid.Parse(userIDStr); err == nil {
c.Set("user_id", userID)
}
}
c.Next()
})
analytics := router.Group("/api/v1/analytics")
{
analytics.POST("/events", analyticsHandler.RecordEvent)
}
user := createTestUser(t, db, "test@example.com", "testuser")
payload := map[string]interface{}{
"action": "button_click",
}
body, _ := json.Marshal(coreanalytics.RecordEventRequest{
EventName: "user_interaction",
Payload: payload,
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/analytics/events", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", user.ID.String())
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
var response handlers.APIResponse
err = json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.False(t, response.Success)
}
// TestAnalytics_GetTrackAnalyticsDashboard_PlaysOverTime teste que les données temporelles sont correctement formatées
func TestAnalytics_GetTrackAnalyticsDashboard_PlaysOverTime(t *testing.T) {
router, db, _, _, _, cleanup := setupAnalyticsTestRouter(t)
defer cleanup()
user := createTestUser(t, db, "test@example.com", "testuser")
track := createTestTrack(t, db, user.ID, "Test Track", 180)
// Create plays at different times
now := time.Now()
userID := user.ID
createTestTrackPlay(t, db, track.ID, &userID, 120, now.Add(-5*24*time.Hour))
createTestTrackPlay(t, db, track.ID, &userID, 150, now.Add(-3*24*time.Hour))
createTestTrackPlay(t, db, track.ID, nil, 100, now.Add(-1*24*time.Hour))
createTestTrackPlay(t, db, track.ID, nil, 180, now)
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/analytics/tracks/%s", track.ID.String()), nil)
req.Header.Set("X-User-ID", user.ID.String())
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response handlers.APIResponse
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.True(t, response.Success)
// Verify plays_over_time structure
dataBytes, _ := json.Marshal(response.Data)
var data map[string]interface{}
err = json.Unmarshal(dataBytes, &data)
require.NoError(t, err)
dashboard, _ := data["dashboard"].(map[string]interface{})
playsOverTime, ok := dashboard["plays_over_time"].([]interface{})
require.True(t, ok)
// Verify that plays_over_time contains valid data points
if len(playsOverTime) > 0 {
point, ok := playsOverTime[0].(map[string]interface{})
require.True(t, ok)
assert.Contains(t, point, "date")
assert.Contains(t, point, "count")
}
}