498 lines
15 KiB
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")
|
|
}
|
|
}
|