//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") } }