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") }