feat(v0.11.0): F381-F385 creator analytics handler and routes
Add CreatorAnalyticsHandler with endpoints: - GET /api/v1/creator/analytics/dashboard (F381) - GET /api/v1/creator/analytics/plays (F382) - GET /api/v1/creator/analytics/sales (F383) - GET /api/v1/creator/analytics/discovery (F381) - GET /api/v1/creator/analytics/geographic (F381) - GET /api/v1/creator/analytics/audience (F384) - GET /api/v1/creator/analytics/live/:streamId (F385) - GET /api/v1/creator/analytics/tracks (F381) - GET /api/v1/creator/analytics/export (F383) All endpoints require authentication and only return data for the authenticated creator. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
41a447224a
commit
8b6f0bb430
3 changed files with 650 additions and 11 deletions
|
|
@ -20,19 +20,40 @@ func (r *APIRouter) setupAnalyticsRoutes(router *gin.RouterGroup) {
|
|||
analyticsHandler.SetJobWorker(r.config.JobWorker)
|
||||
}
|
||||
|
||||
analytics := router.Group("/analytics")
|
||||
analyticsGroup := router.Group("/analytics")
|
||||
if r.config != nil && r.config.AuthMiddleware != nil {
|
||||
analytics.Use(r.config.AuthMiddleware.RequireAuth())
|
||||
r.applyCSRFProtection(analytics)
|
||||
analyticsGroup.Use(r.config.AuthMiddleware.RequireAuth())
|
||||
r.applyCSRFProtection(analyticsGroup)
|
||||
}
|
||||
{
|
||||
analytics.GET("/creator/stats", analyticsHandler.GetCreatorStats)
|
||||
analytics.GET("/creator/charts", analyticsHandler.GetCreatorCharts)
|
||||
analytics.GET("/creator/export", analyticsHandler.GetCreatorExport)
|
||||
analytics.GET("", analyticsHandler.GetAnalytics)
|
||||
analytics.POST("/events", analyticsHandler.RecordEvent)
|
||||
analytics.GET("/tracks/:id", analyticsHandler.GetTrackAnalyticsDashboard)
|
||||
analytics.GET("/traffic-sources", analyticsHandler.GetTrafficSources)
|
||||
analytics.GET("/device-breakdown", analyticsHandler.GetDeviceBreakdown)
|
||||
analyticsGroup.GET("/creator/stats", analyticsHandler.GetCreatorStats)
|
||||
analyticsGroup.GET("/creator/charts", analyticsHandler.GetCreatorCharts)
|
||||
analyticsGroup.GET("/creator/export", analyticsHandler.GetCreatorExport)
|
||||
analyticsGroup.GET("", analyticsHandler.GetAnalytics)
|
||||
analyticsGroup.POST("/events", analyticsHandler.RecordEvent)
|
||||
analyticsGroup.GET("/tracks/:id", analyticsHandler.GetTrackAnalyticsDashboard)
|
||||
analyticsGroup.GET("/traffic-sources", analyticsHandler.GetTrafficSources)
|
||||
analyticsGroup.GET("/device-breakdown", analyticsHandler.GetDeviceBreakdown)
|
||||
}
|
||||
|
||||
// v0.11.0: Creator Analytics (F381-F395)
|
||||
creatorAnalyticsService := services.NewCreatorAnalyticsService(r.db.GormDB, r.logger)
|
||||
creatorHandler := analytics.NewCreatorAnalyticsHandler(creatorAnalyticsService, r.logger)
|
||||
|
||||
creatorGroup := router.Group("/creator/analytics")
|
||||
if r.config != nil && r.config.AuthMiddleware != nil {
|
||||
creatorGroup.Use(r.config.AuthMiddleware.RequireAuth())
|
||||
r.applyCSRFProtection(creatorGroup)
|
||||
}
|
||||
{
|
||||
creatorGroup.GET("/dashboard", creatorHandler.GetDashboard) // F381
|
||||
creatorGroup.GET("/plays", creatorHandler.GetPlayEvolution) // F382
|
||||
creatorGroup.GET("/sales", creatorHandler.GetSales) // F383
|
||||
creatorGroup.GET("/discovery", creatorHandler.GetDiscoverySources) // F381
|
||||
creatorGroup.GET("/geographic", creatorHandler.GetGeographic) // F381
|
||||
creatorGroup.GET("/audience", creatorHandler.GetAudience) // F384
|
||||
creatorGroup.GET("/live/:streamId", creatorHandler.GetLiveMetrics) // F385
|
||||
creatorGroup.GET("/tracks", creatorHandler.GetTracks) // F381
|
||||
creatorGroup.GET("/export", creatorHandler.ExportAnalytics) // F383
|
||||
}
|
||||
}
|
||||
|
|
|
|||
350
veza-backend-api/internal/core/analytics/creator_handler.go
Normal file
350
veza-backend-api/internal/core/analytics/creator_handler.go
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
package analytics
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apperrors "veza-backend-api/internal/errors"
|
||||
"veza-backend-api/internal/handlers"
|
||||
"veza-backend-api/internal/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// CreatorAnalyticsHandler handles creator analytics HTTP requests (F381-F395)
|
||||
type CreatorAnalyticsHandler struct {
|
||||
service *services.CreatorAnalyticsService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewCreatorAnalyticsHandler creates a new creator analytics handler
|
||||
func NewCreatorAnalyticsHandler(service *services.CreatorAnalyticsService, logger *zap.Logger) *CreatorAnalyticsHandler {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &CreatorAnalyticsHandler{service: service, logger: logger}
|
||||
}
|
||||
|
||||
// getCreatorID extracts and validates the creator user ID from context
|
||||
func (h *CreatorAnalyticsHandler) getCreatorID(c *gin.Context) (uuid.UUID, bool) {
|
||||
userIDInterface, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeUnauthorized, "authentication required"))
|
||||
return uuid.Nil, false
|
||||
}
|
||||
userID, ok := userIDInterface.(uuid.UUID)
|
||||
if !ok {
|
||||
handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "invalid user id"))
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return userID, true
|
||||
}
|
||||
|
||||
// parseDateRange parses start/end dates from query params with fallback to days
|
||||
func parseDateRange(c *gin.Context) (time.Time, time.Time) {
|
||||
now := time.Now()
|
||||
daysStr := c.DefaultQuery("days", "30")
|
||||
days, err := strconv.Atoi(daysStr)
|
||||
if err != nil || days < 1 {
|
||||
days = 30
|
||||
}
|
||||
if days > 365 {
|
||||
days = 365
|
||||
}
|
||||
|
||||
endDate := now
|
||||
startDate := now.AddDate(0, 0, -days)
|
||||
|
||||
if s := c.Query("start_date"); s != "" {
|
||||
if parsed, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
startDate = parsed
|
||||
}
|
||||
}
|
||||
if e := c.Query("end_date"); e != "" {
|
||||
if parsed, err := time.Parse(time.RFC3339, e); err == nil {
|
||||
endDate = parsed
|
||||
}
|
||||
}
|
||||
|
||||
return startDate, endDate
|
||||
}
|
||||
|
||||
// GetDashboard handles GET /api/v1/creator/analytics/dashboard (F381)
|
||||
func (h *CreatorAnalyticsHandler) GetDashboard(c *gin.Context) {
|
||||
creatorID, ok := h.getCreatorID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
startDate, endDate := parseDateRange(c)
|
||||
|
||||
dashboard, err := h.service.GetCreatorDashboard(c.Request.Context(), creatorID, startDate, endDate)
|
||||
if err != nil {
|
||||
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get dashboard", err))
|
||||
return
|
||||
}
|
||||
|
||||
handlers.RespondSuccess(c, http.StatusOK, gin.H{
|
||||
"dashboard": dashboard,
|
||||
"period": gin.H{
|
||||
"start_date": startDate.Format(time.RFC3339),
|
||||
"end_date": endDate.Format(time.RFC3339),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetPlayEvolution handles GET /api/v1/creator/analytics/plays (F382)
|
||||
func (h *CreatorAnalyticsHandler) GetPlayEvolution(c *gin.Context) {
|
||||
creatorID, ok := h.getCreatorID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
startDate, endDate := parseDateRange(c)
|
||||
interval := c.DefaultQuery("interval", "day")
|
||||
if interval != "day" && interval != "week" && interval != "month" {
|
||||
interval = "day"
|
||||
}
|
||||
|
||||
points, err := h.service.GetPlayEvolution(c.Request.Context(), creatorID, startDate, endDate, interval)
|
||||
if err != nil {
|
||||
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get play evolution", err))
|
||||
return
|
||||
}
|
||||
|
||||
handlers.RespondSuccess(c, http.StatusOK, gin.H{
|
||||
"data": points,
|
||||
"interval": interval,
|
||||
"period": gin.H{
|
||||
"start_date": startDate.Format(time.RFC3339),
|
||||
"end_date": endDate.Format(time.RFC3339),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetSales handles GET /api/v1/creator/analytics/sales (F383)
|
||||
func (h *CreatorAnalyticsHandler) GetSales(c *gin.Context) {
|
||||
creatorID, ok := h.getCreatorID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
startDate, endDate := parseDateRange(c)
|
||||
|
||||
summary, err := h.service.GetSalesSummary(c.Request.Context(), creatorID, startDate, endDate)
|
||||
if err != nil {
|
||||
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get sales", err))
|
||||
return
|
||||
}
|
||||
|
||||
handlers.RespondSuccess(c, http.StatusOK, gin.H{
|
||||
"sales": summary,
|
||||
"period": gin.H{
|
||||
"start_date": startDate.Format(time.RFC3339),
|
||||
"end_date": endDate.Format(time.RFC3339),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetDiscoverySources handles GET /api/v1/creator/analytics/discovery (F381)
|
||||
func (h *CreatorAnalyticsHandler) GetDiscoverySources(c *gin.Context) {
|
||||
creatorID, ok := h.getCreatorID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
startDate, endDate := parseDateRange(c)
|
||||
|
||||
sources, err := h.service.GetDiscoverySources(c.Request.Context(), creatorID, startDate, endDate)
|
||||
if err != nil {
|
||||
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get discovery sources", err))
|
||||
return
|
||||
}
|
||||
|
||||
handlers.RespondSuccess(c, http.StatusOK, gin.H{
|
||||
"sources": sources,
|
||||
})
|
||||
}
|
||||
|
||||
// GetGeographic handles GET /api/v1/creator/analytics/geographic (F381)
|
||||
func (h *CreatorAnalyticsHandler) GetGeographic(c *gin.Context) {
|
||||
creatorID, ok := h.getCreatorID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
startDate, endDate := parseDateRange(c)
|
||||
|
||||
breakdown, err := h.service.GetGeographicBreakdown(c.Request.Context(), creatorID, startDate, endDate)
|
||||
if err != nil {
|
||||
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get geographic data", err))
|
||||
return
|
||||
}
|
||||
|
||||
handlers.RespondSuccess(c, http.StatusOK, gin.H{
|
||||
"geographic": breakdown,
|
||||
})
|
||||
}
|
||||
|
||||
// GetAudience handles GET /api/v1/creator/analytics/audience (F384)
|
||||
func (h *CreatorAnalyticsHandler) GetAudience(c *gin.Context) {
|
||||
creatorID, ok := h.getCreatorID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
startDate, endDate := parseDateRange(c)
|
||||
|
||||
profile, err := h.service.GetAudienceProfile(c.Request.Context(), creatorID, startDate, endDate)
|
||||
if err != nil {
|
||||
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get audience profile", err))
|
||||
return
|
||||
}
|
||||
|
||||
handlers.RespondSuccess(c, http.StatusOK, gin.H{
|
||||
"audience": profile,
|
||||
})
|
||||
}
|
||||
|
||||
// GetLiveMetrics handles GET /api/v1/creator/analytics/live/:streamId (F385)
|
||||
func (h *CreatorAnalyticsHandler) GetLiveMetrics(c *gin.Context) {
|
||||
creatorID, ok := h.getCreatorID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
streamIDStr := c.Param("streamId")
|
||||
streamID, err := uuid.Parse(streamIDStr)
|
||||
if err != nil {
|
||||
handlers.RespondWithAppError(c, apperrors.NewValidationError("invalid stream id"))
|
||||
return
|
||||
}
|
||||
|
||||
metrics, err := h.service.GetLiveStreamMetrics(c.Request.Context(), creatorID, streamID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
handlers.RespondWithAppError(c, apperrors.NewNotFoundError("live stream"))
|
||||
return
|
||||
}
|
||||
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get live metrics", err))
|
||||
return
|
||||
}
|
||||
|
||||
handlers.RespondSuccess(c, http.StatusOK, gin.H{
|
||||
"metrics": metrics,
|
||||
})
|
||||
}
|
||||
|
||||
// GetTracks handles GET /api/v1/creator/analytics/tracks (F381 per-track breakdown)
|
||||
func (h *CreatorAnalyticsHandler) GetTracks(c *gin.Context) {
|
||||
creatorID, ok := h.getCreatorID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
startDate, endDate := parseDateRange(c)
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
if limit < 1 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
offset := (page - 1) * limit
|
||||
|
||||
tracks, total, err := h.service.GetPerTrackStats(c.Request.Context(), creatorID, startDate, endDate, limit, offset)
|
||||
if err != nil {
|
||||
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get track stats", err))
|
||||
return
|
||||
}
|
||||
|
||||
totalPages := int((total + int64(limit) - 1) / int64(limit))
|
||||
|
||||
handlers.RespondSuccess(c, http.StatusOK, gin.H{
|
||||
"data": tracks,
|
||||
"pagination": gin.H{
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"total_pages": totalPages,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ExportAnalytics handles GET /api/v1/creator/analytics/export (F383)
|
||||
// Exports analytics data as CSV
|
||||
func (h *CreatorAnalyticsHandler) ExportAnalytics(c *gin.Context) {
|
||||
creatorID, ok := h.getCreatorID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
startDate, endDate := parseDateRange(c)
|
||||
exportType := c.DefaultQuery("type", "plays") // plays, sales
|
||||
|
||||
switch exportType {
|
||||
case "sales":
|
||||
h.exportSalesCSV(c, creatorID, startDate, endDate)
|
||||
default:
|
||||
h.exportPlaysCSV(c, creatorID, startDate, endDate)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *CreatorAnalyticsHandler) exportPlaysCSV(c *gin.Context, creatorID uuid.UUID, startDate, endDate time.Time) {
|
||||
tracks, _, err := h.service.GetPerTrackStats(c.Request.Context(), creatorID, startDate, endDate, 1000, 0)
|
||||
if err != nil {
|
||||
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to export", err))
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=veza-analytics-%s.csv", time.Now().Format("2006-01-02")))
|
||||
c.Header("Content-Type", "text/csv")
|
||||
|
||||
writer := csv.NewWriter(c.Writer)
|
||||
writer.Write([]string{"track_id", "title", "total_plays", "complete_listens", "unique_listeners", "avg_play_duration", "avg_completion"})
|
||||
for _, t := range tracks {
|
||||
writer.Write([]string{
|
||||
t.TrackID,
|
||||
t.Title,
|
||||
strconv.FormatInt(t.TotalPlays, 10),
|
||||
strconv.FormatInt(t.CompleteListens, 10),
|
||||
strconv.FormatInt(t.UniqueListeners, 10),
|
||||
fmt.Sprintf("%.1f", t.AvgPlayDuration),
|
||||
fmt.Sprintf("%.1f", t.AvgCompletion),
|
||||
})
|
||||
}
|
||||
writer.Flush()
|
||||
}
|
||||
|
||||
func (h *CreatorAnalyticsHandler) exportSalesCSV(c *gin.Context, creatorID uuid.UUID, startDate, endDate time.Time) {
|
||||
summary, err := h.service.GetSalesSummary(c.Request.Context(), creatorID, startDate, endDate)
|
||||
if err != nil {
|
||||
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to export sales", err))
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=veza-sales-%s.csv", time.Now().Format("2006-01-02")))
|
||||
c.Header("Content-Type", "text/csv")
|
||||
|
||||
writer := csv.NewWriter(c.Writer)
|
||||
writer.Write([]string{"date", "revenue", "sales"})
|
||||
for _, r := range summary.RevenueByPeriod {
|
||||
writer.Write([]string{
|
||||
r.Date,
|
||||
fmt.Sprintf("%.2f", r.Revenue),
|
||||
strconv.FormatInt(r.Sales, 10),
|
||||
})
|
||||
}
|
||||
writer.Flush()
|
||||
}
|
||||
268
veza-backend-api/internal/core/analytics/creator_handler_test.go
Normal file
268
veza-backend-api/internal/core/analytics/creator_handler_test.go
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
package analytics
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"veza-backend-api/internal/models"
|
||||
"veza-backend-api/internal/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupCreatorHandlerTest(t *testing.T) (*CreatorAnalyticsHandler, *gorm.DB, uuid.UUID) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
db.Exec("PRAGMA foreign_keys = ON")
|
||||
|
||||
err = db.AutoMigrate(
|
||||
&models.User{},
|
||||
&models.Track{},
|
||||
&models.TrackPlay{},
|
||||
&models.PlaybackAnalytics{},
|
||||
&models.LiveStream{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
db.Exec("ALTER TABLE track_plays ADD COLUMN source TEXT DEFAULT ''")
|
||||
db.Exec("ALTER TABLE track_plays ADD COLUMN country_code TEXT DEFAULT ''")
|
||||
|
||||
creator := &models.User{
|
||||
Username: "creator", Email: "c@test.com",
|
||||
PasswordHash: "hash", Slug: "creator", IsActive: true,
|
||||
}
|
||||
require.NoError(t, db.Create(creator).Error)
|
||||
|
||||
track := &models.Track{
|
||||
UserID: creator.ID, Title: "Hit Song", FilePath: "/hit.mp3",
|
||||
FileSize: 1024, Format: "MP3", Duration: 240,
|
||||
IsPublic: true, Status: models.TrackStatusCompleted,
|
||||
}
|
||||
require.NoError(t, db.Create(track).Error)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
listener := &models.User{
|
||||
Username: "l" + string(rune('0'+i)), Email: "l" + string(rune('0'+i)) + "@test.com",
|
||||
PasswordHash: "hash", Slug: "l" + string(rune('0'+i)), IsActive: true,
|
||||
}
|
||||
require.NoError(t, db.Create(listener).Error)
|
||||
pa := &models.PlaybackAnalytics{
|
||||
TrackID: track.ID, UserID: listener.ID,
|
||||
PlayTime: 200, CompletionRate: 85,
|
||||
StartedAt: time.Now().Add(-time.Duration(i) * time.Hour),
|
||||
}
|
||||
require.NoError(t, db.Create(pa).Error)
|
||||
}
|
||||
|
||||
svc := services.NewCreatorAnalyticsService(db, zap.NewNop())
|
||||
handler := NewCreatorAnalyticsHandler(svc, zap.NewNop())
|
||||
|
||||
return handler, db, creator.ID
|
||||
}
|
||||
|
||||
func makeAuthContext(creatorID uuid.UUID, method, path string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
c.Request = req
|
||||
c.Set("user_id", creatorID)
|
||||
return c, w
|
||||
}
|
||||
|
||||
func TestCreatorHandler_GetDashboard(t *testing.T) {
|
||||
handler, _, creatorID := setupCreatorHandlerTest(t)
|
||||
c, w := makeAuthContext(creatorID, http.MethodGet, "/creator/analytics/dashboard?days=30")
|
||||
|
||||
handler.GetDashboard(c)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var resp map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, resp, "data")
|
||||
|
||||
data := resp["data"].(map[string]interface{})
|
||||
assert.Contains(t, data, "dashboard")
|
||||
assert.Contains(t, data, "period")
|
||||
}
|
||||
|
||||
func TestCreatorHandler_GetDashboard_Unauthorized(t *testing.T) {
|
||||
handler, _, _ := setupCreatorHandlerTest(t)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/creator/analytics/dashboard", nil)
|
||||
// No user_id in context
|
||||
|
||||
handler.GetDashboard(c)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
}
|
||||
|
||||
func TestCreatorHandler_GetPlayEvolution(t *testing.T) {
|
||||
handler, _, creatorID := setupCreatorHandlerTest(t)
|
||||
c, w := makeAuthContext(creatorID, http.MethodGet, "/creator/analytics/plays?days=30&interval=day")
|
||||
|
||||
handler.GetPlayEvolution(c)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var resp map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
data := resp["data"].(map[string]interface{})
|
||||
assert.Contains(t, data, "data")
|
||||
assert.Contains(t, data, "interval")
|
||||
}
|
||||
|
||||
func TestCreatorHandler_GetPlayEvolution_InvalidInterval(t *testing.T) {
|
||||
handler, _, creatorID := setupCreatorHandlerTest(t)
|
||||
c, w := makeAuthContext(creatorID, http.MethodGet, "/creator/analytics/plays?interval=invalid")
|
||||
|
||||
handler.GetPlayEvolution(c)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code) // Should default to "day"
|
||||
}
|
||||
|
||||
func TestCreatorHandler_GetSales(t *testing.T) {
|
||||
handler, _, creatorID := setupCreatorHandlerTest(t)
|
||||
c, w := makeAuthContext(creatorID, http.MethodGet, "/creator/analytics/sales?days=30")
|
||||
|
||||
handler.GetSales(c)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func TestCreatorHandler_GetDiscoverySources(t *testing.T) {
|
||||
handler, _, creatorID := setupCreatorHandlerTest(t)
|
||||
c, w := makeAuthContext(creatorID, http.MethodGet, "/creator/analytics/discovery?days=30")
|
||||
|
||||
handler.GetDiscoverySources(c)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func TestCreatorHandler_GetAudience(t *testing.T) {
|
||||
handler, _, creatorID := setupCreatorHandlerTest(t)
|
||||
c, w := makeAuthContext(creatorID, http.MethodGet, "/creator/analytics/audience?days=30")
|
||||
|
||||
handler.GetAudience(c)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func TestCreatorHandler_GetGeographic(t *testing.T) {
|
||||
handler, _, creatorID := setupCreatorHandlerTest(t)
|
||||
c, w := makeAuthContext(creatorID, http.MethodGet, "/creator/analytics/geographic?days=30")
|
||||
|
||||
handler.GetGeographic(c)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func TestCreatorHandler_GetTracks(t *testing.T) {
|
||||
handler, _, creatorID := setupCreatorHandlerTest(t)
|
||||
c, w := makeAuthContext(creatorID, http.MethodGet, "/creator/analytics/tracks?page=1&limit=10")
|
||||
|
||||
handler.GetTracks(c)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var resp map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
data := resp["data"].(map[string]interface{})
|
||||
assert.Contains(t, data, "data")
|
||||
assert.Contains(t, data, "pagination")
|
||||
}
|
||||
|
||||
func TestCreatorHandler_GetLiveMetrics(t *testing.T) {
|
||||
handler, db, creatorID := setupCreatorHandlerTest(t)
|
||||
|
||||
now := time.Now()
|
||||
stream := &models.LiveStream{
|
||||
UserID: creatorID, Title: "Live Now",
|
||||
IsLive: true, ViewerCount: 10, StartedAt: &now,
|
||||
}
|
||||
require.NoError(t, db.Create(stream).Error)
|
||||
|
||||
c, w := makeAuthContext(creatorID, http.MethodGet, "/creator/analytics/live/"+stream.ID.String())
|
||||
c.Params = gin.Params{{Key: "streamId", Value: stream.ID.String()}}
|
||||
|
||||
handler.GetLiveMetrics(c)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func TestCreatorHandler_GetLiveMetrics_InvalidID(t *testing.T) {
|
||||
handler, _, creatorID := setupCreatorHandlerTest(t)
|
||||
c, w := makeAuthContext(creatorID, http.MethodGet, "/creator/analytics/live/invalid")
|
||||
c.Params = gin.Params{{Key: "streamId", Value: "invalid"}}
|
||||
|
||||
handler.GetLiveMetrics(c)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestCreatorHandler_ExportAnalytics(t *testing.T) {
|
||||
handler, _, creatorID := setupCreatorHandlerTest(t)
|
||||
c, w := makeAuthContext(creatorID, http.MethodGet, "/creator/analytics/export?type=plays&days=30")
|
||||
|
||||
handler.ExportAnalytics(c)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Header().Get("Content-Type"), "text/csv")
|
||||
assert.Contains(t, w.Header().Get("Content-Disposition"), "attachment")
|
||||
}
|
||||
|
||||
func TestCreatorHandler_ExportAnalytics_Sales(t *testing.T) {
|
||||
handler, _, creatorID := setupCreatorHandlerTest(t)
|
||||
c, w := makeAuthContext(creatorID, http.MethodGet, "/creator/analytics/export?type=sales&days=30")
|
||||
|
||||
handler.ExportAnalytics(c)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Header().Get("Content-Type"), "text/csv")
|
||||
}
|
||||
|
||||
func TestCreatorHandler_DataPrivacy(t *testing.T) {
|
||||
// Ensure that a creator can only see their own data
|
||||
handler, _, creatorID := setupCreatorHandlerTest(t)
|
||||
|
||||
// Request dashboard for the real creator
|
||||
c1, w1 := makeAuthContext(creatorID, http.MethodGet, "/creator/analytics/dashboard?days=30")
|
||||
handler.GetDashboard(c1)
|
||||
assert.Equal(t, http.StatusOK, w1.Code)
|
||||
|
||||
var resp1 map[string]interface{}
|
||||
json.Unmarshal(w1.Body.Bytes(), &resp1)
|
||||
data1 := resp1["data"].(map[string]interface{})
|
||||
dashboard1 := data1["dashboard"].(map[string]interface{})
|
||||
plays1 := dashboard1["total_plays"].(float64)
|
||||
|
||||
// Request dashboard for a random user (should see 0 plays)
|
||||
randomID := uuid.New()
|
||||
c2, w2 := makeAuthContext(randomID, http.MethodGet, "/creator/analytics/dashboard?days=30")
|
||||
handler.GetDashboard(c2)
|
||||
assert.Equal(t, http.StatusOK, w2.Code)
|
||||
|
||||
var resp2 map[string]interface{}
|
||||
json.Unmarshal(w2.Body.Bytes(), &resp2)
|
||||
data2 := resp2["data"].(map[string]interface{})
|
||||
dashboard2 := data2["dashboard"].(map[string]interface{})
|
||||
plays2 := dashboard2["total_plays"].(float64)
|
||||
|
||||
assert.Greater(t, plays1, float64(0), "real creator should have plays")
|
||||
assert.Equal(t, float64(0), plays2, "random user should see no data")
|
||||
}
|
||||
Loading…
Reference in a new issue