refactor(backend): unify architecture - migrate analytics handler to core (ADR-001)
This commit is contained in:
parent
36c03e1cba
commit
1159874adf
10 changed files with 2462 additions and 4025 deletions
|
|
@ -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
|
|
@ -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)
|
||||||
|
|
|
||||||
363
veza-backend-api/internal/core/analytics/handler.go
Normal file
363
veza-backend-api/internal/core/analytics/handler.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
153
veza-backend-api/internal/core/analytics/handler_test.go
Normal file
153
veza-backend-api/internal/core/analytics/handler_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue