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.
344 lines
11 KiB
Go
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")
|
|
}
|