veza/veza-backend-api/internal/services/creator_analytics_service_test.go
senke a1000ce7fb style(backend): gofmt -w on 85 files (whitespace only)
backend-ci.yml's `test -z "$(gofmt -l .)"` strict gate (added in
13c21ac11) failed on a backlog of unformatted files. None of the
85 files in this commit had been edited since the gate was added
because no push touched veza-backend-api/** in between, so the
gate never fired until today's CI fixes triggered it.

The diff is exclusively whitespace alignment in struct literals
and trailing-space comments. `go build ./...` and the full test
suite (with VEZA_SKIP_INTEGRATION=1 -short) pass identically.
2026-04-14 12:22:14 +02:00

344 lines
11 KiB
Go

package services
import (
"context"
"testing"
"time"
"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"
"veza-backend-api/internal/models"
)
func setupTestCreatorAnalyticsService(t *testing.T) (*CreatorAnalyticsService, *gorm.DB, uuid.UUID, 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)
// Add source and country_code columns to track_plays (normally done by migration)
db.Exec("ALTER TABLE track_plays ADD COLUMN source TEXT DEFAULT ''")
db.Exec("ALTER TABLE track_plays ADD COLUMN country_code TEXT DEFAULT ''")
// Create follows table (normally done by migration, no GORM model)
db.Exec(`CREATE TABLE IF NOT EXISTS follows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
follower_id TEXT NOT NULL,
followed_id TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`)
// Create test user (creator)
creator := &models.User{
Username: "creator1", Email: "creator@test.com",
PasswordHash: "hash", Slug: "creator1", IsActive: true,
}
require.NoError(t, db.Create(creator).Error)
// Create test track
track := &models.Track{
UserID: creator.ID, Title: "Test Track", FilePath: "/test.mp3",
FileSize: 5 * 1024 * 1024, Format: "MP3", Duration: 180,
IsPublic: true, Status: models.TrackStatusCompleted,
}
require.NoError(t, db.Create(track).Error)
// Create listener users and playback data
for i := 0; i < 15; i++ {
listener := &models.User{
Username: "listener" + string(rune('a'+i)),
Email: "listener" + string(rune('a'+i)) + "@test.com",
PasswordHash: "hash",
Slug: "listener" + string(rune('a'+i)),
IsActive: true,
}
require.NoError(t, db.Create(listener).Error)
// Create playback analytics for each listener
pa := &models.PlaybackAnalytics{
TrackID: track.ID,
UserID: listener.ID,
PlayTime: 120 + i*5,
PauseCount: i % 3,
SeekCount: i % 2,
CompletionRate: float64(60 + i*2),
StartedAt: time.Now().Add(-time.Duration(i) * time.Hour),
}
require.NoError(t, db.Create(pa).Error)
// Create track_plays
tp := &models.TrackPlay{
TrackID: track.ID,
UserID: &listener.ID,
Duration: 120 + i*5,
PlayedAt: time.Now().Add(-time.Duration(i) * time.Hour),
Device: "desktop",
}
require.NoError(t, db.Create(tp).Error)
// Create a follow
db.Exec("INSERT INTO follows (follower_id, followed_id, created_at) VALUES (?, ?, ?)",
listener.ID, creator.ID, time.Now())
}
logger := zap.NewNop()
service := NewCreatorAnalyticsService(db, logger)
return service, db, creator.ID, track.ID
}
func TestCreatorAnalyticsService_GetCreatorDashboard(t *testing.T) {
service, _, creatorID, _ := setupTestCreatorAnalyticsService(t)
ctx := context.Background()
startDate := time.Now().Add(-30 * 24 * time.Hour)
endDate := time.Now().Add(time.Hour)
dashboard, err := service.GetCreatorDashboard(ctx, creatorID, startDate, endDate)
require.NoError(t, err)
require.NotNil(t, dashboard)
assert.Greater(t, dashboard.TotalPlays, int64(0), "should have plays")
assert.Greater(t, dashboard.UniqueListeners, int64(0), "should have unique listeners")
assert.Greater(t, dashboard.TotalPlayTime, int64(0), "should have play time")
assert.Greater(t, dashboard.TotalTracks, int64(0), "should have tracks")
assert.Greater(t, dashboard.TotalFollowers, int64(0), "should have followers")
assert.Greater(t, dashboard.AvgCompletionRate, float64(0), "should have completion rate")
assert.Greater(t, dashboard.AvgPlayDuration, float64(0), "should have avg play duration")
}
func TestCreatorAnalyticsService_GetCreatorDashboard_NoData(t *testing.T) {
service, _, _, _ := setupTestCreatorAnalyticsService(t)
ctx := context.Background()
nonExistentID := uuid.New()
startDate := time.Now().Add(-30 * 24 * time.Hour)
endDate := time.Now()
dashboard, err := service.GetCreatorDashboard(ctx, nonExistentID, startDate, endDate)
require.NoError(t, err)
require.NotNil(t, dashboard)
assert.Equal(t, int64(0), dashboard.TotalPlays)
}
func TestCreatorAnalyticsService_GetPlayEvolution(t *testing.T) {
service, _, creatorID, _ := setupTestCreatorAnalyticsService(t)
ctx := context.Background()
startDate := time.Now().Add(-30 * 24 * time.Hour)
endDate := time.Now().Add(time.Hour)
// Test day interval
points, err := service.GetPlayEvolution(ctx, creatorID, startDate, endDate, "day")
require.NoError(t, err)
assert.NotEmpty(t, points, "should have data points")
for _, p := range points {
assert.NotEmpty(t, p.Date, "date should not be empty")
assert.GreaterOrEqual(t, p.TotalPlays, int64(0))
}
}
func TestCreatorAnalyticsService_GetDiscoverySources(t *testing.T) {
service, _, creatorID, _ := setupTestCreatorAnalyticsService(t)
ctx := context.Background()
startDate := time.Now().Add(-30 * 24 * time.Hour)
endDate := time.Now().Add(time.Hour)
sources, err := service.GetDiscoverySources(ctx, creatorID, startDate, endDate)
require.NoError(t, err)
// Should have at least one source ("direct" since all track_plays have empty source)
assert.NotEmpty(t, sources)
// Verify percentages sum to ~100
var totalPct float64
for _, s := range sources {
totalPct += s.Percentage
assert.NotEmpty(t, s.Source)
assert.Greater(t, s.Count, int64(0))
}
assert.InDelta(t, 100.0, totalPct, 1.0)
}
func TestCreatorAnalyticsService_GetAudienceProfile_MinimumListeners(t *testing.T) {
service, _, creatorID, _ := setupTestCreatorAnalyticsService(t)
ctx := context.Background()
startDate := time.Now().Add(-30 * 24 * time.Hour)
endDate := time.Now().Add(time.Hour)
profile, err := service.GetAudienceProfile(ctx, creatorID, startDate, endDate)
require.NoError(t, err)
require.NotNil(t, profile)
// We have 15 listeners which is >= 10, so we should get data
assert.GreaterOrEqual(t, profile.TotalListeners, int64(10), "should have >= 10 listeners")
}
func TestCreatorAnalyticsService_GetAudienceProfile_InsufficientListeners(t *testing.T) {
// Test with creator who has < 10 listeners
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.PlaybackAnalytics{})
require.NoError(t, err)
creator := &models.User{
Username: "lonely", Email: "lonely@test.com",
PasswordHash: "hash", Slug: "lonely", IsActive: true,
}
require.NoError(t, db.Create(creator).Error)
track := &models.Track{
UserID: creator.ID, Title: "Lonely Track", FilePath: "/lonely.mp3",
FileSize: 1024, Format: "MP3", Duration: 60,
IsPublic: true, Status: models.TrackStatusCompleted,
}
require.NoError(t, db.Create(track).Error)
// Only create 3 listeners (< 10 minimum)
for i := 0; i < 3; i++ {
listener := &models.User{
Username: "few" + string(rune('a'+i)),
Email: "few" + string(rune('a'+i)) + "@test.com",
PasswordHash: "hash",
Slug: "few" + string(rune('a'+i)),
IsActive: true,
}
require.NoError(t, db.Create(listener).Error)
pa := &models.PlaybackAnalytics{
TrackID: track.ID, UserID: listener.ID,
PlayTime: 30, CompletionRate: 50,
StartedAt: time.Now().Add(-time.Hour),
}
require.NoError(t, db.Create(pa).Error)
}
service := NewCreatorAnalyticsService(db, zap.NewNop())
ctx := context.Background()
profile, err := service.GetAudienceProfile(ctx, creator.ID,
time.Now().Add(-24*time.Hour), time.Now().Add(time.Hour))
require.NoError(t, err)
require.NotNil(t, profile)
// < 10 listeners: should NOT expose detailed data
assert.Less(t, profile.TotalListeners, int64(10))
assert.Empty(t, profile.ListenersByGenre, "should not expose genre data with < 10 listeners")
assert.Empty(t, profile.TopListeningTimes, "should not expose listening times with < 10 listeners")
}
func TestCreatorAnalyticsService_GetPerTrackStats(t *testing.T) {
service, _, creatorID, _ := setupTestCreatorAnalyticsService(t)
ctx := context.Background()
startDate := time.Now().Add(-30 * 24 * time.Hour)
endDate := time.Now().Add(time.Hour)
tracks, total, err := service.GetPerTrackStats(ctx, creatorID, startDate, endDate, 20, 0)
require.NoError(t, err)
assert.Greater(t, total, int64(0))
assert.NotEmpty(t, tracks)
for _, track := range tracks {
assert.NotEmpty(t, track.TrackID)
assert.NotEmpty(t, track.Title)
assert.GreaterOrEqual(t, track.TotalPlays, int64(0))
}
}
func TestCreatorAnalyticsService_GetPerTrackStats_PaginationLimit(t *testing.T) {
service, _, creatorID, _ := setupTestCreatorAnalyticsService(t)
ctx := context.Background()
startDate := time.Now().Add(-30 * 24 * time.Hour)
endDate := time.Now().Add(time.Hour)
// Test max limit enforcement
tracks, _, err := service.GetPerTrackStats(ctx, creatorID, startDate, endDate, 200, 0)
require.NoError(t, err)
assert.LessOrEqual(t, len(tracks), 100, "limit should be capped at 100")
}
func TestCreatorAnalyticsService_GetLiveStreamMetrics(t *testing.T) {
service, db, creatorID, _ := setupTestCreatorAnalyticsService(t)
ctx := context.Background()
// Create a live stream started 10 minutes ago
tenMinAgo := time.Now().Add(-10 * time.Minute)
stream := &models.LiveStream{
UserID: creatorID,
Title: "Test Stream",
IsLive: true,
ViewerCount: 42,
StartedAt: &tenMinAgo,
}
require.NoError(t, db.Create(stream).Error)
metrics, err := service.GetLiveStreamMetrics(ctx, creatorID, stream.ID)
require.NoError(t, err)
require.NotNil(t, metrics)
assert.Equal(t, 42, metrics.CurrentViewers)
assert.True(t, metrics.IsLive)
assert.Greater(t, metrics.DurationSeconds, int64(500), "stream started 10 min ago")
}
func TestCreatorAnalyticsService_GetLiveStreamMetrics_NotFound(t *testing.T) {
service, _, creatorID, _ := setupTestCreatorAnalyticsService(t)
ctx := context.Background()
_, err := service.GetLiveStreamMetrics(ctx, creatorID, uuid.New())
assert.Error(t, err)
assert.Contains(t, err.Error(), "not found")
}
func TestCreatorAnalyticsService_GetLiveStreamMetrics_WrongCreator(t *testing.T) {
service, db, creatorID, _ := setupTestCreatorAnalyticsService(t)
ctx := context.Background()
now := time.Now()
stream := &models.LiveStream{
UserID: creatorID, Title: "Stream", IsLive: true, StartedAt: &now,
}
require.NoError(t, db.Create(stream).Error)
// Try to access with a different creator ID
_, err := service.GetLiveStreamMetrics(ctx, uuid.New(), stream.ID)
assert.Error(t, err)
}
func TestCreatorAnalyticsService_MetricsNeverPublic(t *testing.T) {
// This test verifies the design principle: all creator analytics
// require a creator ID and only return data for that creator's tracks.
service, _, _, _ := setupTestCreatorAnalyticsService(t)
ctx := context.Background()
startDate := time.Now().Add(-30 * 24 * time.Hour)
endDate := time.Now().Add(time.Hour)
// All methods require a creator ID — there's no public endpoint
randomID := uuid.New()
dashboard, err := service.GetCreatorDashboard(ctx, randomID, startDate, endDate)
require.NoError(t, err)
assert.Equal(t, int64(0), dashboard.TotalPlays, "random user should see no data")
points, err := service.GetPlayEvolution(ctx, randomID, startDate, endDate, "day")
require.NoError(t, err)
assert.Empty(t, points, "random user should see no evolution data")
}