refactor(backend): unify architecture - migrate analytics handler to core (ADR-001)

This commit is contained in:
senke 2026-02-15 16:18:13 +01:00
parent 36c03e1cba
commit 1159874adf
10 changed files with 2462 additions and 4025 deletions

View file

@ -0,0 +1,44 @@
# ADR-001: Backend Architecture Unification
## Status
**Accepted** — 2026-02-15
## Context
The Veza backend API uses two architectural patterns:
1. **Core (DDD-style)**: `internal/core/track/`, `internal/core/auth/`, `internal/core/marketplace/`, `internal/core/social/` — handlers and services colocated per domain
2. **Legacy (flat handlers)**: `internal/handlers/` — analytics_handler, playlist_handler, comment_handler, playback_analytics_handler, etc.
This inconsistency creates confusion for developers and makes it unclear where new code should live.
## Decision
**Option A**: Migrate all handlers to `internal/core/` (domain-driven structure).
We choose Option A for long-term consistency with the existing core domains (track, auth, marketplace, social). New features will follow the same pattern.
## Migration Strategy
1. **Incremental migration** — One domain at a time, with tests passing after each step
2. **Order of migration** (by priority and coupling):
- ✅ `analytics_handler``internal/core/analytics/` (completed 2026-02-15)
- `playback_analytics_handler``internal/core/playback/` (or integrate into track)
- `playlist_handler``internal/core/playlist/`
- `comment_handler``internal/core/comment/` (or integrate into track)
- `room_handler`, `session``internal/core/chat/` or `internal/core/session/`
- `health.go`, `upload.go`, `bitrate_handler` — evaluate per ADR update
3. **Service layer** — Handlers in core use existing `internal/services/*` packages; no new service packages unless domain logic warrants it
4. **Response helpers** — Core handlers continue to use `handlers.RespondWithAppError`, `handlers.RespondSuccess` from the handlers package (shared HTTP utilities)
## Consequences
- **Positive**: Single, consistent architecture; easier onboarding
- **Negative**: Migration effort; temporary coexistence of both patterns during transition
- **Risk**: Breaking changes if imports are not updated correctly — mitigate with `go build ./...` and tests after each migration
## References
- [veza-backend-api/README.md](../../README.md) — Patterns section
- [AUDIT_TECHNIQUE_INTEGRAL_2026_02_15.md](../../../AUDIT_TECHNIQUE_INTEGRAL_2026_02_15.md) — Phase 3 plan

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,7 @@ package api
import ( import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"veza-backend-api/internal/handlers" "veza-backend-api/internal/core/analytics"
"veza-backend-api/internal/services" "veza-backend-api/internal/services"
) )
@ -14,7 +14,7 @@ func (r *APIRouter) setupAnalyticsRoutes(router *gin.RouterGroup) {
} }
analyticsService := services.NewAnalyticsService(r.db.GormDB, r.logger) analyticsService := services.NewAnalyticsService(r.db.GormDB, r.logger)
analyticsHandler := handlers.NewAnalyticsHandler(analyticsService, r.logger) analyticsHandler := analytics.NewHandler(analyticsService, r.logger)
if r.config != nil && r.config.JobWorker != nil { if r.config != nil && r.config.JobWorker != nil {
analyticsHandler.SetJobWorker(r.config.JobWorker) analyticsHandler.SetJobWorker(r.config.JobWorker)

View file

@ -0,0 +1,363 @@
package analytics
import (
"context"
"net/http"
"strconv"
"time"
apperrors "veza-backend-api/internal/errors"
"veza-backend-api/internal/handlers"
"veza-backend-api/internal/common"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"gorm.io/gorm"
"veza-backend-api/internal/services"
"veza-backend-api/internal/types"
)
// AnalyticsServiceInterface defines the interface for AnalyticsService
type AnalyticsServiceInterface interface {
GetTrackStats(ctx context.Context, trackID uuid.UUID) (*types.TrackStats, error)
GetPlaysOverTime(ctx context.Context, trackID uuid.UUID, startDate, endDate time.Time, interval string) ([]services.PlayTimePoint, error)
}
// AnalyticsJobWorkerInterface defines the interface for JobWorker (analytics related)
type AnalyticsJobWorkerInterface interface {
EnqueueAnalyticsJob(eventName string, userID *uuid.UUID, payload map[string]interface{})
}
// AnalyticsServiceWithDB extends AnalyticsServiceInterface with DB access for GetAnalytics
type AnalyticsServiceWithDB interface {
AnalyticsServiceInterface
GetDB() *gorm.DB
}
// Handler handles analytics HTTP requests (core domain, ADR-001)
type Handler struct {
analyticsService AnalyticsServiceInterface
jobWorker AnalyticsJobWorkerInterface
logger *zap.Logger
}
// NewHandler creates a new analytics handler
func NewHandler(analyticsService *services.AnalyticsService, logger *zap.Logger) *Handler {
if logger == nil {
logger = zap.NewNop()
}
return &Handler{
analyticsService: analyticsService,
logger: logger,
}
}
// NewHandlerWithInterface creates a handler with interfaces (for testing)
func NewHandlerWithInterface(analyticsService AnalyticsServiceInterface, logger *zap.Logger) *Handler {
if logger == nil {
logger = zap.NewNop()
}
return &Handler{
analyticsService: analyticsService,
logger: logger,
}
}
// SetJobWorker sets the JobWorker for recording analytics events
func (h *Handler) SetJobWorker(jobWorker AnalyticsJobWorkerInterface) {
h.jobWorker = jobWorker
}
// RecordEventRequest represents the request for recording a custom analytics event
type RecordEventRequest struct {
EventName string `json:"event_name" binding:"required,min=1,max=100" validate:"required,min=1,max=100"`
Payload map[string]interface{} `json:"payload,omitempty" validate:"omitempty"`
}
// RecordEvent handles POST /api/v1/analytics/events
func (h *Handler) RecordEvent(c *gin.Context) {
var req RecordEventRequest
if !common.BindAndValidateJSON(c, &req) {
return
}
var userID *uuid.UUID
if uid, ok := c.Get("user_id"); ok {
if uidUUID, ok := uid.(uuid.UUID); ok {
userID = &uidUUID
}
}
if h.jobWorker == nil {
handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "analytics service not available"))
return
}
h.jobWorker.EnqueueAnalyticsJob(req.EventName, userID, req.Payload)
handlers.RespondSuccess(c, http.StatusOK, gin.H{
"message": "event recorded",
"event_name": req.EventName,
})
}
// GetAnalytics handles GET /api/v1/analytics
func (h *Handler) GetAnalytics(c *gin.Context) {
userIDInterface, exists := c.Get("user_id")
if !exists {
handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeUnauthorized, "authentication required"))
return
}
userID, ok := userIDInterface.(uuid.UUID)
if !ok {
handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "invalid user id"))
return
}
daysStr := c.DefaultQuery("days", "30")
days, err := strconv.Atoi(daysStr)
if err != nil || days < 1 {
days = 30
}
var startDate, endDate *time.Time
if startDateStr := c.Query("start_date"); startDateStr != "" {
if parsed, err := time.Parse(time.RFC3339, startDateStr); err == nil {
startDate = &parsed
}
}
if endDateStr := c.Query("end_date"); endDateStr != "" {
if parsed, err := time.Parse(time.RFC3339, endDateStr); err == nil {
endDate = &parsed
}
}
if startDate == nil || endDate == nil {
now := time.Now()
if endDate == nil {
endDate = &now
}
if startDate == nil {
calculatedStart := endDate.AddDate(0, 0, -days)
startDate = &calculatedStart
}
}
ctx := c.Request.Context()
analyticsSvc, ok := h.analyticsService.(AnalyticsServiceWithDB)
if !ok {
handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "analytics service type error"))
return
}
var tracks []struct {
ID uuid.UUID `gorm:"column:id"`
Title string `gorm:"column:title"`
PlayCount int64 `gorm:"column:play_count"`
LikeCount int64 `gorm:"column:like_count"`
DownloadCount int64 `gorm:"column:download_count"`
}
if err := analyticsSvc.GetDB().WithContext(ctx).
Table("tracks").
Select("id, title, play_count, like_count, COALESCE(download_count, 0) as download_count").
Where("creator_id = ?", userID).
Find(&tracks).Error; err != nil {
h.logger.Error("Failed to fetch user tracks", zap.Error(err))
handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "failed to fetch tracks"))
return
}
totalTracks := len(tracks)
var totalPlays, totalLikes, totalDownloads int64
for _, track := range tracks {
totalPlays += track.PlayCount
totalLikes += track.LikeCount
totalDownloads += track.DownloadCount
}
avgPlayCount := float64(0)
if totalTracks > 0 {
avgPlayCount = float64(totalPlays) / float64(totalTracks)
}
topTracks := make([]gin.H, 0, 5)
sortedTracks := make([]struct {
ID uuid.UUID
Title string
PlayCount int64
LikeCount int64
}, len(tracks))
for i, t := range tracks {
sortedTracks[i] = struct {
ID uuid.UUID
Title string
PlayCount int64
LikeCount int64
}{t.ID, t.Title, t.PlayCount, t.LikeCount}
}
for i := 0; i < len(sortedTracks)-1; i++ {
for j := i + 1; j < len(sortedTracks); j++ {
if sortedTracks[i].PlayCount < sortedTracks[j].PlayCount {
sortedTracks[i], sortedTracks[j] = sortedTracks[j], sortedTracks[i]
}
}
}
for i := 0; i < 5 && i < len(sortedTracks); i++ {
topTracks = append(topTracks, gin.H{
"id": sortedTracks[i].ID.String(),
"title": sortedTracks[i].Title,
"play_count": sortedTracks[i].PlayCount,
"like_count": sortedTracks[i].LikeCount,
})
}
var playlists []struct {
ID uuid.UUID `gorm:"column:id"`
Name string `gorm:"column:name"`
PlayCount int64 `gorm:"column:play_count"`
LikeCount int64 `gorm:"column:like_count"`
ShareCount int64 `gorm:"column:share_count"`
}
playlistsError := analyticsSvc.GetDB().WithContext(ctx).
Table("playlists").
Select("id, name, COALESCE(play_count, 0) as play_count, COALESCE(like_count, 0) as like_count, COALESCE(share_count, 0) as share_count").
Where("user_id = ?", userID).
Find(&playlists).Error
if playlistsError != nil {
h.logger.Warn("Failed to fetch user playlists, continuing with empty playlists data", zap.Error(playlistsError))
playlists = nil
}
totalPlaylists := len(playlists)
var playlistPlays, playlistLikes, playlistShares int64
for _, playlist := range playlists {
playlistPlays += playlist.PlayCount
playlistLikes += playlist.LikeCount
playlistShares += playlist.ShareCount
}
avgPlaylistPlayCount := float64(0)
if totalPlaylists > 0 {
avgPlaylistPlayCount = float64(playlistPlays) / float64(totalPlaylists)
}
topPlaylists := make([]gin.H, 0, 5)
sortedPlaylists := make([]struct {
ID uuid.UUID
Name string
PlayCount int64
LikeCount int64
}, len(playlists))
for i, p := range playlists {
sortedPlaylists[i] = struct {
ID uuid.UUID
Name string
PlayCount int64
LikeCount int64
}{p.ID, p.Name, p.PlayCount, p.LikeCount}
}
for i := 0; i < len(sortedPlaylists)-1; i++ {
for j := i + 1; j < len(sortedPlaylists); j++ {
if sortedPlaylists[i].PlayCount < sortedPlaylists[j].PlayCount {
sortedPlaylists[i], sortedPlaylists[j] = sortedPlaylists[j], sortedPlaylists[i]
}
}
}
for i := 0; i < 5 && i < len(sortedPlaylists); i++ {
topPlaylists = append(topPlaylists, gin.H{
"id": sortedPlaylists[i].ID.String(),
"name": sortedPlaylists[i].Name,
"play_count": sortedPlaylists[i].PlayCount,
"like_count": sortedPlaylists[i].LikeCount,
})
}
analyticsData := gin.H{
"tracks": gin.H{
"total_tracks": totalTracks,
"total_plays": totalPlays,
"total_likes": totalLikes,
"total_downloads": totalDownloads,
"average_play_count": avgPlayCount,
"top_tracks": topTracks,
},
"playlists": gin.H{
"total_playlists": totalPlaylists,
"total_plays": playlistPlays,
"total_likes": playlistLikes,
"total_shares": playlistShares,
"average_play_count": avgPlaylistPlayCount,
"top_playlists": topPlaylists,
},
"period": gin.H{
"start_date": startDate.Format(time.RFC3339),
"end_date": endDate.Format(time.RFC3339),
"days": days,
},
}
handlers.RespondSuccess(c, http.StatusOK, analyticsData)
}
// GetTrackAnalyticsDashboard handles GET /api/v1/analytics/tracks/:id
func (h *Handler) GetTrackAnalyticsDashboard(c *gin.Context) {
trackIDStr := c.Param("id")
if trackIDStr == "" {
handlers.RespondWithAppError(c, apperrors.NewValidationError("track id is required"))
return
}
trackID, err := uuid.Parse(trackIDStr)
if err != nil {
handlers.RespondWithAppError(c, apperrors.NewValidationError("invalid track id"))
return
}
stats, err := h.analyticsService.GetTrackStats(c.Request.Context(), trackID)
if err != nil {
if err.Error() == "track not found" {
handlers.RespondWithAppError(c, apperrors.NewNotFoundError("track"))
return
}
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get track stats", err))
return
}
startDate := time.Now().AddDate(0, 0, -30)
endDate := time.Now()
playsOverTime, err := h.analyticsService.GetPlaysOverTime(c.Request.Context(), trackID, startDate, endDate, "day")
if err != nil {
playsOverTime = []services.PlayTimePoint{}
}
dashboard := gin.H{
"track_id": trackID.String(),
"stats": gin.H{
"total_plays": stats.TotalPlays,
"unique_listeners": stats.UniqueListeners,
"average_duration": stats.AverageDuration,
"completion_rate": stats.CompletionRate,
},
"plays_over_time": playsOverTime,
"period": gin.H{
"start_date": startDate.Format(time.RFC3339),
"end_date": endDate.Format(time.RFC3339),
"days": 30,
},
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{
"dashboard": dashboard,
})
}

View file

@ -0,0 +1,153 @@
package analytics
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"veza-backend-api/internal/services"
"veza-backend-api/internal/types"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"go.uber.org/zap"
)
// MockAnalyticsService implements AnalyticsServiceInterface for testing
type MockAnalyticsService struct {
mock.Mock
}
func (m *MockAnalyticsService) GetTrackStats(ctx context.Context, trackID uuid.UUID) (*types.TrackStats, error) {
args := m.Called(ctx, trackID)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*types.TrackStats), args.Error(1)
}
func (m *MockAnalyticsService) GetPlaysOverTime(ctx context.Context, trackID uuid.UUID, startDate, endDate time.Time, interval string) ([]services.PlayTimePoint, error) {
args := m.Called(ctx, trackID, startDate, endDate, interval)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]services.PlayTimePoint), args.Error(1)
}
// MockAnalyticsJobWorker mocks JobWorker for analytics
type MockAnalyticsJobWorker struct {
mock.Mock
}
func (m *MockAnalyticsJobWorker) EnqueueAnalyticsJob(eventName string, userID *uuid.UUID, payload map[string]interface{}) {
m.Called(eventName, userID, payload)
}
func setupTestRouter(mockService *MockAnalyticsService, mockJobWorker *MockAnalyticsJobWorker) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
logger := zap.NewNop()
handler := NewHandlerWithInterface(mockService, logger)
if mockJobWorker != nil {
handler.SetJobWorker(mockJobWorker)
}
api := router.Group("/api/v1/analytics")
api.Use(func(c *gin.Context) {
userIDStr := c.GetHeader("X-User-ID")
if userIDStr != "" {
uid, err := uuid.Parse(userIDStr)
if err == nil {
c.Set("user_id", uid)
}
}
c.Next()
})
{
api.GET("", handler.GetAnalytics)
api.GET("/tracks/:id", handler.GetTrackAnalyticsDashboard)
api.POST("/events", handler.RecordEvent)
}
return router
}
func TestHandler_RecordEvent_Success(t *testing.T) {
mockService := new(MockAnalyticsService)
mockJobWorker := new(MockAnalyticsJobWorker)
router := setupTestRouter(mockService, mockJobWorker)
userID := uuid.New()
reqBody := RecordEventRequest{
EventName: "track_liked",
Payload: map[string]interface{}{"track_id": uuid.New().String()},
}
mockJobWorker.On("EnqueueAnalyticsJob", "track_liked", &userID, reqBody.Payload).Return()
body, _ := json.Marshal(reqBody)
req, _ := http.NewRequest("POST", "/api/v1/analytics/events", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", userID.String())
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
mockJobWorker.AssertExpectations(t)
}
func TestHandler_RecordEvent_NoJobWorker(t *testing.T) {
mockService := new(MockAnalyticsService)
router := setupTestRouter(mockService, nil)
userID := uuid.New()
reqBody := RecordEventRequest{
EventName: "track_liked",
Payload: map[string]interface{}{"track_id": uuid.New().String()},
}
body, _ := json.Marshal(reqBody)
req, _ := http.NewRequest("POST", "/api/v1/analytics/events", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", userID.String())
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
}
func TestHandler_GetTrackAnalyticsDashboard_Success(t *testing.T) {
mockService := new(MockAnalyticsService)
mockJobWorker := new(MockAnalyticsJobWorker)
router := setupTestRouter(mockService, mockJobWorker)
trackID := uuid.New()
expectedStats := &types.TrackStats{
TotalPlays: 100,
UniqueListeners: 50,
AverageDuration: 120,
CompletionRate: 0.8,
}
expectedPoints := []services.PlayTimePoint{
{Date: time.Now(), Count: 10},
}
mockService.On("GetTrackStats", mock.Anything, trackID).Return(expectedStats, nil)
mockService.On("GetPlaysOverTime", mock.Anything, trackID, mock.Anything, mock.Anything, "day").Return(expectedPoints, nil)
req, _ := http.NewRequest("GET", "/api/v1/analytics/tracks/"+trackID.String(), nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
mockService.AssertExpectations(t)
}

View file

@ -1,706 +0,0 @@
package handlers
import (
"context"
"net/http"
"strconv"
"time"
apperrors "veza-backend-api/internal/errors"
"veza-backend-api/internal/services"
"veza-backend-api/internal/types"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// AnalyticsServiceInterface defines the interface for AnalyticsService
type AnalyticsServiceInterface interface {
RecordPlay(ctx context.Context, trackID uuid.UUID, userID *uuid.UUID, duration int, device, ipAddress string) error
GetTrackStats(ctx context.Context, trackID uuid.UUID) (*types.TrackStats, error)
GetTopTracks(ctx context.Context, limit int, startDate, endDate *time.Time) ([]services.TopTrack, error)
GetPlaysOverTime(ctx context.Context, trackID uuid.UUID, startDate, endDate time.Time, interval string) ([]services.PlayTimePoint, error)
GetUserStats(ctx context.Context, userID uuid.UUID) (*types.UserStats, error)
}
// AnalyticsJobWorkerInterface defines the interface for JobWorker (analytics related)
type AnalyticsJobWorkerInterface interface {
EnqueueAnalyticsJob(eventName string, userID *uuid.UUID, payload map[string]interface{})
}
// AnalyticsHandler gère les opérations d'analytics de lecture de tracks
type AnalyticsHandler struct {
analyticsService AnalyticsServiceInterface
jobWorker AnalyticsJobWorkerInterface
commonHandler *CommonHandler
}
// NewAnalyticsHandler crée un nouveau handler d'analytics
func NewAnalyticsHandler(analyticsService *services.AnalyticsService, logger *zap.Logger) *AnalyticsHandler {
return &AnalyticsHandler{
analyticsService: analyticsService,
commonHandler: NewCommonHandler(logger),
}
}
// NewAnalyticsHandlerWithInterface creates a new analytics handler with interfaces for testing
func NewAnalyticsHandlerWithInterface(analyticsService AnalyticsServiceInterface, logger *zap.Logger) *AnalyticsHandler {
return &AnalyticsHandler{
analyticsService: analyticsService,
commonHandler: NewCommonHandler(logger),
}
}
// SetJobWorker définit le JobWorker pour enregistrer des événements analytics
func (h *AnalyticsHandler) SetJobWorker(jobWorker AnalyticsJobWorkerInterface) {
h.jobWorker = jobWorker
}
// RecordPlayRequest représente la requête pour enregistrer une lecture
// MOD-P1-001: Ajout tags validate pour validation systématique
type RecordPlayRequest struct {
Duration int `json:"duration" binding:"required,min=1" validate:"required,min=1"`
Device string `json:"device,omitempty" validate:"omitempty,max=100"`
}
// RecordPlay gère l'enregistrement d'une lecture de track
// @Summary Record play
// @Description Record a play event for a track. Can be called anonymously or with authentication.
// @Tags Analytics
// @Accept json
// @Produce json
// @Param id path string true "Track ID (UUID)"
// @Param request body handlers.RecordPlayRequest true "Play event data"
// @Success 200 {object} handlers.APIResponse{data=object{message=string}}
// @Failure 400 {object} handlers.APIResponse "Validation error"
// @Failure 404 {object} handlers.APIResponse "Track not found"
// @Failure 500 {object} handlers.APIResponse "Internal server error"
// @Router /tracks/{id}/play [post]
func (h *AnalyticsHandler) RecordPlay(c *gin.Context) {
trackIDStr := c.Param("id")
if trackIDStr == "" {
RespondWithAppError(c, apperrors.NewValidationError("track id is required"))
return
}
trackID, err := uuid.Parse(trackIDStr) // Changed to uuid.Parse
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("invalid track id"))
return
}
var req RecordPlayRequest
if appErr := h.commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
RespondWithAppError(c, appErr)
return
}
// Récupérer user_id si authentifié (optionnel pour analytics anonymes)
var userID *uuid.UUID
if uid, ok := c.Get("user_id"); ok {
if uidUUID, ok := uid.(uuid.UUID); ok {
userID = &uidUUID
}
}
// Récupérer IP address et device
ipAddress := c.ClientIP()
device := req.Device
if device == "" {
device = c.GetHeader("User-Agent")
}
err = h.analyticsService.RecordPlay(c.Request.Context(), trackID, userID, req.Duration, device, ipAddress)
if err != nil {
if err.Error() == "track not found" {
RespondWithAppError(c, apperrors.NewNotFoundError("track"))
return
}
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to record play", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"message": "play recorded"})
}
// GetTrackStats gère la récupération des statistiques d'un track
// @Summary Get track statistics
// @Description Get statistics for a track (plays, likes, etc.)
// @Tags Analytics
// @Accept json
// @Produce json
// @Param id path string true "Track ID (UUID)"
// @Success 200 {object} handlers.APIResponse{data=object{stats=object}}
// @Failure 400 {object} handlers.APIResponse "Validation error"
// @Failure 404 {object} handlers.APIResponse "Track not found"
// @Failure 500 {object} handlers.APIResponse "Internal server error"
// @Router /tracks/{id}/stats [get]
func (h *AnalyticsHandler) GetTrackStats(c *gin.Context) {
trackIDStr := c.Param("id")
if trackIDStr == "" {
RespondWithAppError(c, apperrors.NewValidationError("track id is required"))
return
}
trackID, err := uuid.Parse(trackIDStr) // Changed to uuid.Parse
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("invalid track id"))
return
}
stats, err := h.analyticsService.GetTrackStats(c.Request.Context(), trackID)
if err != nil {
if err.Error() == "track not found" {
RespondWithAppError(c, apperrors.NewNotFoundError("track"))
return
}
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get track stats", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"stats": stats})
}
// GetTopTracks gère la récupération des tracks les plus écoutés
// @Summary Get top tracks
// @Description Get list of top tracks by play count, optionally filtered by date range
// @Tags Analytics
// @Accept json
// @Produce json
// @Param limit query int false "Number of tracks to return" default(10) minimum(1) maximum(100)
// @Param start_date query string false "Start date filter (RFC3339 format)"
// @Param end_date query string false "End date filter (RFC3339 format)"
// @Success 200 {object} handlers.APIResponse{data=object{tracks=array}}
// @Failure 400 {object} handlers.APIResponse "Validation error"
// @Failure 500 {object} handlers.APIResponse "Internal server error"
// @Router /analytics/tracks/top [get]
func (h *AnalyticsHandler) GetTopTracks(c *gin.Context) {
// Parse limit
limit := 10
if limitStr := c.Query("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
limit = l
} else {
RespondWithAppError(c, apperrors.NewValidationError("invalid limit (must be between 1 and 100)"))
return
}
}
// Parse start_date (optionnel)
var startDate *time.Time
if startDateStr := c.Query("start_date"); startDateStr != "" {
parsed, err := time.Parse(time.RFC3339, startDateStr)
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("invalid start_date format (use RFC3339)"))
return
}
startDate = &parsed
}
// Parse end_date (optionnel)
var endDate *time.Time
if endDateStr := c.Query("end_date"); endDateStr != "" {
parsed, err := time.Parse(time.RFC3339, endDateStr)
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("invalid end_date format (use RFC3339)"))
return
}
endDate = &parsed
}
topTracks, err := h.analyticsService.GetTopTracks(c.Request.Context(), limit, startDate, endDate)
if err != nil {
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get top tracks", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"tracks": topTracks})
}
// GetPlaysOverTime gère la récupération des lectures sur une période
// @Summary Get plays over time
// @Description Get play statistics over time for a track, grouped by time period
// @Tags Analytics
// @Accept json
// @Produce json
// @Param id path string true "Track ID (UUID)"
// @Param start_date query string false "Start date (RFC3339 format)"
// @Param end_date query string false "End date (RFC3339 format)"
// @Param interval query string false "Time period grouping (hour, day, week, month)" default(day)
// @Success 200 {object} handlers.APIResponse{data=object{points=array}}
// @Failure 400 {object} handlers.APIResponse "Validation error"
// @Failure 404 {object} handlers.APIResponse "Track not found"
// @Failure 500 {object} handlers.APIResponse "Internal server error"
// @Router /tracks/{id}/analytics/plays [get]
func (h *AnalyticsHandler) GetPlaysOverTime(c *gin.Context) {
trackIDStr := c.Param("id")
if trackIDStr == "" {
RespondWithAppError(c, apperrors.NewValidationError("track id is required"))
return
}
trackID, err := uuid.Parse(trackIDStr) // Changed to uuid.Parse
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("invalid track id"))
return
}
// Parse start_date (optionnel, défaut: 30 jours)
startDate := time.Now().AddDate(0, 0, -30)
if startDateStr := c.Query("start_date"); startDateStr != "" {
parsed, err := time.Parse(time.RFC3339, startDateStr)
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("invalid start_date format (use RFC3339)"))
return
}
startDate = parsed
}
// Parse end_date (optionnel, défaut: maintenant)
endDate := time.Now()
if endDateStr := c.Query("end_date"); endDateStr != "" {
parsed, err := time.Parse(time.RFC3339, endDateStr)
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("invalid end_date format (use RFC3339)"))
return
}
endDate = parsed
}
// Parse interval (optionnel, défaut: day)
interval := c.DefaultQuery("interval", "day")
validIntervals := map[string]bool{"hour": true, "day": true, "week": true, "month": true}
if !validIntervals[interval] {
RespondWithAppError(c, apperrors.NewValidationError("invalid interval (must be: hour, day, week, month)"))
return
}
points, err := h.analyticsService.GetPlaysOverTime(c.Request.Context(), trackID, startDate, endDate, interval)
if err != nil {
if err.Error() == "track not found" {
RespondWithAppError(c, apperrors.NewNotFoundError("track"))
return
}
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get plays over time", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"points": points})
}
// GetUserStats gère la récupération des statistiques d'un utilisateur
// @Summary Get user statistics
// @Description Get analytics statistics for a user (total plays, tracks, etc.)
// @Tags Analytics
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "User ID (UUID)"
// @Success 200 {object} handlers.APIResponse{data=object{stats=object}}
// @Failure 400 {object} handlers.APIResponse "Validation error"
// @Failure 401 {object} handlers.APIResponse "Unauthorized"
// @Failure 403 {object} handlers.APIResponse "Forbidden - can only view own stats"
// @Failure 404 {object} handlers.APIResponse "User not found"
// @Failure 500 {object} handlers.APIResponse "Internal server error"
// @Router /users/{id}/analytics/stats [get]
func (h *AnalyticsHandler) GetUserStats(c *gin.Context) {
userIDStr := c.Param("id")
if userIDStr == "" {
RespondWithAppError(c, apperrors.NewValidationError("user id is required"))
return
}
userID, err := uuid.Parse(userIDStr)
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("invalid user id"))
return
}
// Vérifier que l'utilisateur peut accéder à ses propres stats
var authenticatedUserID *uuid.UUID
if uid, ok := c.Get("user_id"); ok {
if uidUUID, ok := uid.(uuid.UUID); ok {
authenticatedUserID = &uidUUID
}
}
if authenticatedUserID != nil && *authenticatedUserID != userID {
RespondWithAppError(c, apperrors.NewForbiddenError("cannot access other user's stats"))
return
}
stats, err := h.analyticsService.GetUserStats(c.Request.Context(), userID)
if err != nil {
if err.Error() == "user not found" {
RespondWithAppError(c, apperrors.NewNotFoundError("user"))
return
}
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get user stats", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"stats": stats})
}
// GetTrackAnalyticsDashboard gère la récupération du dashboard d'analytics complet pour un track
// BE-API-036: GET /api/v1/analytics/tracks/:id returns comprehensive track analytics
// @Summary Get Track Analytics Dashboard
// @Description Get comprehensive analytics dashboard for a track
// @Tags Analytics
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Track ID"
// @Success 200 {object} APIResponse{data=object{dashboard=object}}
// @Failure 400 {object} APIResponse "Validation Error"
// @Failure 404 {object} APIResponse "Track not found"
// @Failure 500 {object} APIResponse "Internal Error"
// @Router /analytics/tracks/{id} [get]
func (h *AnalyticsHandler) GetTrackAnalyticsDashboard(c *gin.Context) {
trackIDStr := c.Param("id")
if trackIDStr == "" {
RespondWithAppError(c, apperrors.NewValidationError("track id is required"))
return
}
trackID, err := uuid.Parse(trackIDStr)
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("invalid track id"))
return
}
// Récupérer les statistiques de base
stats, err := h.analyticsService.GetTrackStats(c.Request.Context(), trackID)
if err != nil {
if err.Error() == "track not found" {
RespondWithAppError(c, apperrors.NewNotFoundError("track"))
return
}
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get track stats", err))
return
}
// Récupérer les lectures sur une période (30 derniers jours)
startDate := time.Now().AddDate(0, 0, -30)
endDate := time.Now()
playsOverTime, err := h.analyticsService.GetPlaysOverTime(c.Request.Context(), trackID, startDate, endDate, "day")
if err != nil {
// Ne pas échouer si on ne peut pas récupérer les données temporelles
playsOverTime = []services.PlayTimePoint{}
}
// Construire le dashboard complet
dashboard := gin.H{
"track_id": trackID.String(),
"stats": gin.H{
"total_plays": stats.TotalPlays,
"unique_listeners": stats.UniqueListeners,
"average_duration": stats.AverageDuration,
"completion_rate": stats.CompletionRate,
},
"plays_over_time": playsOverTime,
"period": gin.H{
"start_date": startDate.Format(time.RFC3339),
"end_date": endDate.Format(time.RFC3339),
"days": 30,
},
}
RespondSuccess(c, http.StatusOK, gin.H{
"dashboard": dashboard,
})
}
// RecordEventRequest représente la requête pour enregistrer un événement analytics personnalisé
// BE-API-035: POST /api/v1/analytics/events to record custom analytics events
type RecordEventRequest struct {
EventName string `json:"event_name" binding:"required,min=1,max=100" validate:"required,min=1,max=100"`
Payload map[string]interface{} `json:"payload,omitempty" validate:"omitempty"`
}
// RecordEvent gère l'enregistrement d'un événement analytics personnalisé
// BE-API-035: Implement analytics events endpoint
// @Summary Record Analytics Event
// @Description Record a custom analytics event
// @Tags Analytics
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body RecordEventRequest true "Event Data"
// @Success 200 {object} APIResponse{data=object{message=string}}
// @Failure 400 {object} APIResponse "Validation Error"
// @Failure 401 {object} APIResponse "Unauthorized"
// @Failure 500 {object} APIResponse "Internal Error"
// @Router /analytics/events [post]
func (h *AnalyticsHandler) RecordEvent(c *gin.Context) {
var req RecordEventRequest
if appErr := h.commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
RespondWithAppError(c, appErr)
return
}
// Récupérer user_id si authentifié (optionnel pour analytics anonymes)
var userID *uuid.UUID
if uid, ok := c.Get("user_id"); ok {
if uidUUID, ok := uid.(uuid.UUID); ok {
userID = &uidUUID
}
}
// Vérifier que le JobWorker est disponible
if h.jobWorker == nil {
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "analytics service not available"))
return
}
// Enqueue l'événement analytics via le JobWorker
h.jobWorker.EnqueueAnalyticsJob(req.EventName, userID, req.Payload)
RespondSuccess(c, http.StatusOK, gin.H{
"message": "event recorded",
"event_name": req.EventName,
})
}
// GetAnalytics gère la récupération des analytics agrégées pour l'utilisateur
// BE-API-037: GET /api/v1/analytics endpoint for aggregated analytics
// @Summary Get Analytics Data
// @Description Get aggregated analytics data for tracks and playlists
// @Tags Analytics
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param days query int false "Number of days (default: 30)"
// @Param start_date query string false "Start date (ISO 8601)"
// @Param end_date query string false "End date (ISO 8601)"
// @Success 200 {object} APIResponse{data=object{tracks=object,playlists=object,period=object}}
// @Failure 401 {object} APIResponse "Unauthorized"
// @Failure 500 {object} APIResponse "Internal Error"
// @Router /analytics [get]
func (h *AnalyticsHandler) GetAnalytics(c *gin.Context) {
// Récupérer l'utilisateur authentifié
userIDInterface, exists := c.Get("user_id")
if !exists {
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeUnauthorized, "authentication required"))
return
}
userID, ok := userIDInterface.(uuid.UUID)
if !ok {
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "invalid user id"))
return
}
// Parser les paramètres de date
daysStr := c.DefaultQuery("days", "30")
days, err := strconv.Atoi(daysStr)
if err != nil || days < 1 {
days = 30
}
var startDate, endDate *time.Time
if startDateStr := c.Query("start_date"); startDateStr != "" {
if parsed, err := time.Parse(time.RFC3339, startDateStr); err == nil {
startDate = &parsed
}
}
if endDateStr := c.Query("end_date"); endDateStr != "" {
if parsed, err := time.Parse(time.RFC3339, endDateStr); err == nil {
endDate = &parsed
}
}
// Si les dates ne sont pas fournies, calculer depuis days
if startDate == nil || endDate == nil {
now := time.Now()
if endDate == nil {
endDate = &now
}
if startDate == nil {
calculatedStart := endDate.AddDate(0, 0, -days)
startDate = &calculatedStart
}
}
ctx := c.Request.Context()
// Accéder à la DB via le service (nécessite un cast)
analyticsSvc, ok := h.analyticsService.(*services.AnalyticsService)
if !ok {
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "analytics service type error"))
return
}
// Récupérer les tracks de l'utilisateur avec leurs stats
var tracks []struct {
ID uuid.UUID `gorm:"column:id"`
Title string `gorm:"column:title"`
PlayCount int64 `gorm:"column:play_count"`
LikeCount int64 `gorm:"column:like_count"`
DownloadCount int64 `gorm:"column:download_count"`
}
if err := analyticsSvc.GetDB().WithContext(ctx).
Table("tracks").
Select("id, title, play_count, like_count, COALESCE(download_count, 0) as download_count").
Where("creator_id = ?", userID).
Find(&tracks).Error; err != nil {
h.commonHandler.logger.Error("Failed to fetch user tracks", zap.Error(err))
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "failed to fetch tracks"))
return
}
// Calculer les stats des tracks
totalTracks := len(tracks)
var totalPlays, totalLikes, totalDownloads int64
for _, track := range tracks {
totalPlays += track.PlayCount
totalLikes += track.LikeCount
totalDownloads += track.DownloadCount
}
avgPlayCount := float64(0)
if totalTracks > 0 {
avgPlayCount = float64(totalPlays) / float64(totalTracks)
}
// Top 5 tracks
topTracks := make([]gin.H, 0, 5)
sortedTracks := make([]struct {
ID uuid.UUID
Title string
PlayCount int64
LikeCount int64
}, len(tracks))
for i, t := range tracks {
sortedTracks[i] = struct {
ID uuid.UUID
Title string
PlayCount int64
LikeCount int64
}{t.ID, t.Title, t.PlayCount, t.LikeCount}
}
// Trier par play_count
for i := 0; i < len(sortedTracks)-1; i++ {
for j := i + 1; j < len(sortedTracks); j++ {
if sortedTracks[i].PlayCount < sortedTracks[j].PlayCount {
sortedTracks[i], sortedTracks[j] = sortedTracks[j], sortedTracks[i]
}
}
}
for i := 0; i < 5 && i < len(sortedTracks); i++ {
topTracks = append(topTracks, gin.H{
"id": sortedTracks[i].ID.String(),
"title": sortedTracks[i].Title,
"play_count": sortedTracks[i].PlayCount,
"like_count": sortedTracks[i].LikeCount,
})
}
// Récupérer les playlists de l'utilisateur
// CRITIQUE FIX #15: Gérer gracieusement l'erreur si la table playlists n'existe pas ou est vide
var playlists []struct {
ID uuid.UUID `gorm:"column:id"`
Name string `gorm:"column:title"`
PlayCount int64 `gorm:"column:play_count"`
LikeCount int64 `gorm:"column:like_count"`
ShareCount int64 `gorm:"column:share_count"`
}
playlistsError := analyticsSvc.GetDB().WithContext(ctx).
Table("playlists").
Select("id, title as name, COALESCE(play_count, 0) as play_count, COALESCE(like_count, 0) as like_count, COALESCE(share_count, 0) as share_count").
Where("user_id = ?", userID).
Find(&playlists).Error
// Si erreur lors de la récupération des playlists, logger mais continuer avec des données vides
// Cela permet de retourner les analytics des tracks même si les playlists ne sont pas disponibles
if playlistsError != nil {
h.commonHandler.logger.Warn("Failed to fetch user playlists, continuing with empty playlists data", zap.Error(playlistsError))
playlists = []struct {
ID uuid.UUID `gorm:"column:id"`
Name string `gorm:"column:title"`
PlayCount int64 `gorm:"column:play_count"`
LikeCount int64 `gorm:"column:like_count"`
ShareCount int64 `gorm:"column:share_count"`
}{}
}
// Calculer les stats des playlists
totalPlaylists := len(playlists)
var playlistPlays, playlistLikes, playlistShares int64
for _, playlist := range playlists {
playlistPlays += playlist.PlayCount
playlistLikes += playlist.LikeCount
playlistShares += playlist.ShareCount
}
avgPlaylistPlayCount := float64(0)
if totalPlaylists > 0 {
avgPlaylistPlayCount = float64(playlistPlays) / float64(totalPlaylists)
}
// Top 5 playlists
topPlaylists := make([]gin.H, 0, 5)
sortedPlaylists := make([]struct {
ID uuid.UUID
Name string
PlayCount int64
LikeCount int64
}, len(playlists))
for i, p := range playlists {
sortedPlaylists[i] = struct {
ID uuid.UUID
Name string
PlayCount int64
LikeCount int64
}{p.ID, p.Name, p.PlayCount, p.LikeCount}
}
// Trier par play_count
for i := 0; i < len(sortedPlaylists)-1; i++ {
for j := i + 1; j < len(sortedPlaylists); j++ {
if sortedPlaylists[i].PlayCount < sortedPlaylists[j].PlayCount {
sortedPlaylists[i], sortedPlaylists[j] = sortedPlaylists[j], sortedPlaylists[i]
}
}
}
for i := 0; i < 5 && i < len(sortedPlaylists); i++ {
topPlaylists = append(topPlaylists, gin.H{
"id": sortedPlaylists[i].ID.String(),
"name": sortedPlaylists[i].Name,
"play_count": sortedPlaylists[i].PlayCount,
"like_count": sortedPlaylists[i].LikeCount,
})
}
// Construire la réponse
analyticsData := gin.H{
"tracks": gin.H{
"total_tracks": totalTracks,
"total_plays": totalPlays,
"total_likes": totalLikes,
"total_downloads": totalDownloads,
"average_play_count": avgPlayCount,
"top_tracks": topTracks,
},
"playlists": gin.H{
"total_playlists": totalPlaylists,
"total_plays": playlistPlays,
"total_likes": playlistLikes,
"total_shares": playlistShares,
"average_play_count": avgPlaylistPlayCount,
"top_playlists": topPlaylists,
},
"period": gin.H{
"start_date": startDate.Format(time.RFC3339),
"end_date": endDate.Format(time.RFC3339),
"days": days,
},
}
RespondSuccess(c, http.StatusOK, analyticsData)
}

View file

@ -1,441 +0,0 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"veza-backend-api/internal/services"
"veza-backend-api/internal/types"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"go.uber.org/zap"
)
// MockAnalyticsService mocks AnalyticsService
type MockAnalyticsService struct {
mock.Mock
}
func (m *MockAnalyticsService) RecordPlay(ctx context.Context, trackID uuid.UUID, userID *uuid.UUID, duration int, device, ipAddress string) error {
args := m.Called(ctx, trackID, userID, duration, device, ipAddress)
return args.Error(0)
}
func (m *MockAnalyticsService) GetTrackStats(ctx context.Context, trackID uuid.UUID) (*types.TrackStats, error) {
args := m.Called(ctx, trackID)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*types.TrackStats), args.Error(1)
}
func (m *MockAnalyticsService) GetTopTracks(ctx context.Context, limit int, startDate, endDate *time.Time) ([]services.TopTrack, error) {
args := m.Called(ctx, limit, startDate, endDate)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]services.TopTrack), args.Error(1)
}
func (m *MockAnalyticsService) GetPlaysOverTime(ctx context.Context, trackID uuid.UUID, startDate, endDate time.Time, interval string) ([]services.PlayTimePoint, error) {
args := m.Called(ctx, trackID, startDate, endDate, interval)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]services.PlayTimePoint), args.Error(1)
}
func (m *MockAnalyticsService) GetUserStats(ctx context.Context, userID uuid.UUID) (*types.UserStats, error) {
args := m.Called(ctx, userID)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*types.UserStats), args.Error(1)
}
// MockAnalyticsJobWorker mocks JobWorker for analytics
type MockAnalyticsJobWorker struct {
mock.Mock
}
func (m *MockAnalyticsJobWorker) EnqueueAnalyticsJob(eventName string, userID *uuid.UUID, payload map[string]interface{}) {
m.Called(eventName, userID, payload)
}
func setupTestAnalyticsRouter(mockService *MockAnalyticsService, mockJobWorker *MockAnalyticsJobWorker) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
logger := zap.NewNop()
handler := NewAnalyticsHandlerWithInterface(mockService, logger)
if mockJobWorker != nil {
handler.SetJobWorker(mockJobWorker)
}
api := router.Group("/api/v1/analytics")
api.Use(func(c *gin.Context) {
userIDStr := c.GetHeader("X-User-ID")
if userIDStr != "" {
uid, err := uuid.Parse(userIDStr)
if err == nil {
c.Set("user_id", uid)
}
}
c.Next()
})
{
api.POST("/tracks/:id/play", handler.RecordPlay)
api.GET("/tracks/:id/stats", handler.GetTrackStats)
api.GET("/tracks/top", handler.GetTopTracks)
api.GET("/tracks/:id/plays-over-time", handler.GetPlaysOverTime)
api.GET("/users/:id/stats", handler.GetUserStats)
api.GET("/tracks/:id", handler.GetTrackAnalyticsDashboard)
api.POST("/events", handler.RecordEvent)
}
return router
}
func TestAnalyticsHandler_RecordPlay_Success(t *testing.T) {
// Setup
mockService := new(MockAnalyticsService)
mockJobWorker := new(MockAnalyticsJobWorker)
router := setupTestAnalyticsRouter(mockService, mockJobWorker)
trackID := uuid.New()
userID := uuid.New()
reqBody := RecordPlayRequest{
Duration: 120,
Device: "iPhone",
}
mockService.On("RecordPlay", mock.Anything, trackID, &userID, 120, "iPhone", mock.Anything).Return(nil)
body, _ := json.Marshal(reqBody)
// Execute
req, _ := http.NewRequest("POST", "/api/v1/analytics/tracks/"+trackID.String()+"/play", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", userID.String())
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Assert
assert.Equal(t, http.StatusOK, w.Code)
mockService.AssertExpectations(t)
}
func TestAnalyticsHandler_RecordPlay_InvalidTrackID(t *testing.T) {
// Setup
mockService := new(MockAnalyticsService)
mockJobWorker := new(MockAnalyticsJobWorker)
router := setupTestAnalyticsRouter(mockService, mockJobWorker)
reqBody := RecordPlayRequest{
Duration: 120,
}
body, _ := json.Marshal(reqBody)
// Execute - Invalid UUID
req, _ := http.NewRequest("POST", "/api/v1/analytics/tracks/invalid-id/play", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Assert
assert.Equal(t, http.StatusBadRequest, w.Code)
mockService.AssertNotCalled(t, "RecordPlay")
}
func TestAnalyticsHandler_RecordPlay_Anonymous(t *testing.T) {
// Setup
mockService := new(MockAnalyticsService)
mockJobWorker := new(MockAnalyticsJobWorker)
router := setupTestAnalyticsRouter(mockService, mockJobWorker)
trackID := uuid.New()
reqBody := RecordPlayRequest{
Duration: 120,
}
mockService.On("RecordPlay", mock.Anything, trackID, (*uuid.UUID)(nil), 120, mock.Anything, mock.Anything).Return(nil)
body, _ := json.Marshal(reqBody)
// Execute - No X-User-ID header (anonymous)
req, _ := http.NewRequest("POST", "/api/v1/analytics/tracks/"+trackID.String()+"/play", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Assert
assert.Equal(t, http.StatusOK, w.Code)
mockService.AssertExpectations(t)
}
func TestAnalyticsHandler_GetTrackStats_Success(t *testing.T) {
// Setup
mockService := new(MockAnalyticsService)
mockJobWorker := new(MockAnalyticsJobWorker)
router := setupTestAnalyticsRouter(mockService, mockJobWorker)
trackID := uuid.New()
expectedStats := &types.TrackStats{
TotalPlays: 100,
UniqueListeners: 50,
AverageDuration: 120,
CompletionRate: 0.8,
}
mockService.On("GetTrackStats", mock.Anything, trackID).Return(expectedStats, nil)
// Execute
req, _ := http.NewRequest("GET", "/api/v1/analytics/tracks/"+trackID.String()+"/stats", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Assert
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.True(t, response["success"].(bool))
mockService.AssertExpectations(t)
}
func TestAnalyticsHandler_GetTrackStats_TrackNotFound(t *testing.T) {
// Setup
mockService := new(MockAnalyticsService)
mockJobWorker := new(MockAnalyticsJobWorker)
router := setupTestAnalyticsRouter(mockService, mockJobWorker)
trackID := uuid.New()
mockService.On("GetTrackStats", mock.Anything, trackID).Return(nil, assert.AnError)
// Execute
req, _ := http.NewRequest("GET", "/api/v1/analytics/tracks/"+trackID.String()+"/stats", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Assert
assert.Equal(t, http.StatusInternalServerError, w.Code)
mockService.AssertExpectations(t)
}
func TestAnalyticsHandler_GetTopTracks_Success(t *testing.T) {
// Setup
mockService := new(MockAnalyticsService)
mockJobWorker := new(MockAnalyticsJobWorker)
router := setupTestAnalyticsRouter(mockService, mockJobWorker)
expectedTracks := []services.TopTrack{
{TrackID: uuid.New(), TotalPlays: 100},
{TrackID: uuid.New(), TotalPlays: 50},
}
mockService.On("GetTopTracks", mock.Anything, 10, (*time.Time)(nil), (*time.Time)(nil)).Return(expectedTracks, nil)
// Execute
req, _ := http.NewRequest("GET", "/api/v1/analytics/tracks/top?limit=10", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Assert
assert.Equal(t, http.StatusOK, w.Code)
mockService.AssertExpectations(t)
}
func TestAnalyticsHandler_GetTopTracks_InvalidLimit(t *testing.T) {
// Setup
mockService := new(MockAnalyticsService)
mockJobWorker := new(MockAnalyticsJobWorker)
router := setupTestAnalyticsRouter(mockService, mockJobWorker)
// Execute - Limit too high
req, _ := http.NewRequest("GET", "/api/v1/analytics/tracks/top?limit=200", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Assert
assert.Equal(t, http.StatusBadRequest, w.Code)
mockService.AssertNotCalled(t, "GetTopTracks")
}
func TestAnalyticsHandler_GetPlaysOverTime_Success(t *testing.T) {
// Setup
mockService := new(MockAnalyticsService)
mockJobWorker := new(MockAnalyticsJobWorker)
router := setupTestAnalyticsRouter(mockService, mockJobWorker)
trackID := uuid.New()
expectedPoints := []services.PlayTimePoint{
{Date: time.Now(), Count: 10},
}
mockService.On("GetPlaysOverTime", mock.Anything, trackID, mock.Anything, mock.Anything, "day").Return(expectedPoints, nil)
// Execute
req, _ := http.NewRequest("GET", "/api/v1/analytics/tracks/"+trackID.String()+"/plays-over-time?interval=day", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Assert
assert.Equal(t, http.StatusOK, w.Code)
mockService.AssertExpectations(t)
}
func TestAnalyticsHandler_GetPlaysOverTime_InvalidInterval(t *testing.T) {
// Setup
mockService := new(MockAnalyticsService)
mockJobWorker := new(MockAnalyticsJobWorker)
router := setupTestAnalyticsRouter(mockService, mockJobWorker)
trackID := uuid.New()
// Execute - Invalid interval
req, _ := http.NewRequest("GET", "/api/v1/analytics/tracks/"+trackID.String()+"/plays-over-time?interval=invalid", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Assert
assert.Equal(t, http.StatusBadRequest, w.Code)
mockService.AssertNotCalled(t, "GetPlaysOverTime")
}
func TestAnalyticsHandler_GetUserStats_Success(t *testing.T) {
// Setup
mockService := new(MockAnalyticsService)
mockJobWorker := new(MockAnalyticsJobWorker)
router := setupTestAnalyticsRouter(mockService, mockJobWorker)
userID := uuid.New()
expectedStats := &types.UserStats{
TotalPlays: 50,
UniqueTracks: 10,
TotalDuration: 3600,
}
mockService.On("GetUserStats", mock.Anything, userID).Return(expectedStats, nil)
// Execute
req, _ := http.NewRequest("GET", "/api/v1/analytics/users/"+userID.String()+"/stats", nil)
req.Header.Set("X-User-ID", userID.String())
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Assert
assert.Equal(t, http.StatusOK, w.Code)
mockService.AssertExpectations(t)
}
func TestAnalyticsHandler_GetUserStats_Forbidden(t *testing.T) {
// Setup
mockService := new(MockAnalyticsService)
mockJobWorker := new(MockAnalyticsJobWorker)
router := setupTestAnalyticsRouter(mockService, mockJobWorker)
userID := uuid.New()
otherUserID := uuid.New()
// Execute - Trying to access another user's stats
req, _ := http.NewRequest("GET", "/api/v1/analytics/users/"+userID.String()+"/stats", nil)
req.Header.Set("X-User-ID", otherUserID.String())
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Assert
assert.Equal(t, http.StatusForbidden, w.Code)
mockService.AssertNotCalled(t, "GetUserStats")
}
func TestAnalyticsHandler_RecordEvent_Success(t *testing.T) {
// Setup
mockService := new(MockAnalyticsService)
mockJobWorker := new(MockAnalyticsJobWorker)
router := setupTestAnalyticsRouter(mockService, mockJobWorker)
userID := uuid.New()
reqBody := RecordEventRequest{
EventName: "track_liked",
Payload: map[string]interface{}{"track_id": uuid.New().String()},
}
mockJobWorker.On("EnqueueAnalyticsJob", "track_liked", &userID, reqBody.Payload).Return()
body, _ := json.Marshal(reqBody)
// Execute
req, _ := http.NewRequest("POST", "/api/v1/analytics/events", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", userID.String())
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Assert
assert.Equal(t, http.StatusOK, w.Code)
mockJobWorker.AssertExpectations(t)
}
func TestAnalyticsHandler_RecordEvent_NoJobWorker(t *testing.T) {
// Setup
mockService := new(MockAnalyticsService)
router := setupTestAnalyticsRouter(mockService, nil) // No job worker
userID := uuid.New()
reqBody := RecordEventRequest{
EventName: "track_liked",
Payload: map[string]interface{}{"track_id": uuid.New().String()},
}
body, _ := json.Marshal(reqBody)
// Execute
req, _ := http.NewRequest("POST", "/api/v1/analytics/events", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", userID.String())
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Assert
assert.Equal(t, http.StatusInternalServerError, w.Code)
}
func TestAnalyticsHandler_GetTrackAnalyticsDashboard_Success(t *testing.T) {
// Setup
mockService := new(MockAnalyticsService)
mockJobWorker := new(MockAnalyticsJobWorker)
router := setupTestAnalyticsRouter(mockService, mockJobWorker)
trackID := uuid.New()
expectedStats := &types.TrackStats{
TotalPlays: 100,
UniqueListeners: 50,
AverageDuration: 120,
CompletionRate: 0.8,
}
expectedPoints := []services.PlayTimePoint{
{Date: time.Now(), Count: 10},
}
mockService.On("GetTrackStats", mock.Anything, trackID).Return(expectedStats, nil)
mockService.On("GetPlaysOverTime", mock.Anything, trackID, mock.Anything, mock.Anything, "day").Return(expectedPoints, nil)
// Execute
req, _ := http.NewRequest("GET", "/api/v1/analytics/tracks/"+trackID.String(), nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Assert
assert.Equal(t, http.StatusOK, w.Code)
mockService.AssertExpectations(t)
}

View file

@ -12,6 +12,7 @@ import (
"testing" "testing"
"time" "time"
coreanalytics "veza-backend-api/internal/core/analytics"
"veza-backend-api/internal/database" "veza-backend-api/internal/database"
"veza-backend-api/internal/handlers" "veza-backend-api/internal/handlers"
"veza-backend-api/internal/models" "veza-backend-api/internal/models"
@ -62,8 +63,8 @@ func setupAnalyticsTestRouter(t *testing.T) (*gin.Engine, *gorm.DB, *database.Da
jobWorker := workers.NewJobWorker(db, jobService, logger, 100, 1, 3, nil) jobWorker := workers.NewJobWorker(db, jobService, logger, 100, 1, 3, nil)
jobService.SetJobEnqueuer(jobWorker) // Connect JobService to JobWorker jobService.SetJobEnqueuer(jobWorker) // Connect JobService to JobWorker
// Setup handlers // Setup handlers (core/analytics, ADR-001)
analyticsHandler := handlers.NewAnalyticsHandler(analyticsService, logger) analyticsHandler := coreanalytics.NewHandler(analyticsService, logger)
analyticsHandler.SetJobWorker(jobWorker) analyticsHandler.SetJobWorker(jobWorker)
// Create router // Create router
@ -155,7 +156,7 @@ func TestAnalytics_RecordEvent(t *testing.T) {
"action": "button_click", "action": "button_click",
"page": "home", "page": "home",
} }
body, _ := json.Marshal(handlers.RecordEventRequest{ body, _ := json.Marshal(coreanalytics.RecordEventRequest{
EventName: "user_interaction", EventName: "user_interaction",
Payload: payload, Payload: payload,
}) })
@ -186,7 +187,7 @@ func TestAnalytics_RecordEvent(t *testing.T) {
payload := map[string]interface{}{ payload := map[string]interface{}{
"action": "page_view", "action": "page_view",
} }
body, _ := json.Marshal(handlers.RecordEventRequest{ body, _ := json.Marshal(coreanalytics.RecordEventRequest{
EventName: "page_view", EventName: "page_view",
Payload: payload, Payload: payload,
}) })
@ -227,7 +228,7 @@ func TestAnalytics_RecordEvent(t *testing.T) {
}) })
t.Run("Record event with empty event_name", func(t *testing.T) { t.Run("Record event with empty event_name", func(t *testing.T) {
body, _ := json.Marshal(handlers.RecordEventRequest{ body, _ := json.Marshal(coreanalytics.RecordEventRequest{
EventName: "", EventName: "",
Payload: map[string]interface{}{"key": "value"}, Payload: map[string]interface{}{"key": "value"},
}) })
@ -247,7 +248,7 @@ func TestAnalytics_RecordEvent(t *testing.T) {
for i := range longName { for i := range longName {
longName[i] = 'a' longName[i] = 'a'
} }
body, _ := json.Marshal(handlers.RecordEventRequest{ body, _ := json.Marshal(coreanalytics.RecordEventRequest{
EventName: string(longName), EventName: string(longName),
Payload: map[string]interface{}{"key": "value"}, Payload: map[string]interface{}{"key": "value"},
}) })
@ -404,7 +405,7 @@ func TestAnalytics_RecordEvent_JobWorkerMissing(t *testing.T) {
analyticsService := services.NewAnalyticsService(db, logger) analyticsService := services.NewAnalyticsService(db, logger)
// Setup handler WITHOUT JobWorker // Setup handler WITHOUT JobWorker
analyticsHandler := handlers.NewAnalyticsHandler(analyticsService, logger) analyticsHandler := coreanalytics.NewHandler(analyticsService, logger)
// Don't set JobWorker // Don't set JobWorker
// Create router // Create router
@ -428,7 +429,7 @@ func TestAnalytics_RecordEvent_JobWorkerMissing(t *testing.T) {
payload := map[string]interface{}{ payload := map[string]interface{}{
"action": "button_click", "action": "button_click",
} }
body, _ := json.Marshal(handlers.RecordEventRequest{ body, _ := json.Marshal(coreanalytics.RecordEventRequest{
EventName: "user_interaction", EventName: "user_interaction",
Payload: payload, Payload: payload,
}) })