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" "gorm.io/driver/sqlite" "gorm.io/gorm" ) // 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) } func (m *MockAnalyticsService) GetDB() *gorm.DB { args := m.Called() return args.Get(0).(*gorm.DB) } // setupTestDB creates an in-memory SQLite DB with tracks table for ownership checks func setupTestDB(trackID, creatorID uuid.UUID) *gorm.DB { db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) db.Exec("CREATE TABLE tracks (id TEXT PRIMARY KEY, creator_id TEXT)") db.Exec("INSERT INTO tracks (id, creator_id) VALUES (?, ?)", trackID.String(), creatorID.String()) return db } // 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() creatorID := uuid.New() expectedStats := &types.TrackStats{ TotalPlays: 100, UniqueListeners: 50, AverageDuration: 120, CompletionRate: 0.8, } expectedPoints := []services.PlayTimePoint{ {Date: time.Now(), Count: 10}, } testDB := setupTestDB(trackID, creatorID) mockService.On("GetDB").Return(testDB) 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) req.Header.Set("X-User-ID", creatorID.String()) // SECURITY: Must be track creator w := httptest.NewRecorder() router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) mockService.AssertExpectations(t) } func TestHandler_GetTrackAnalyticsDashboard_IDOR_Blocked(t *testing.T) { mockService := new(MockAnalyticsService) router := setupTestRouter(mockService, nil) trackID := uuid.New() creatorID := uuid.New() attackerID := uuid.New() testDB := setupTestDB(trackID, creatorID) mockService.On("GetDB").Return(testDB) req, _ := http.NewRequest("GET", "/api/v1/analytics/tracks/"+trackID.String(), nil) req.Header.Set("X-User-ID", attackerID.String()) // Different user — should be blocked w := httptest.NewRecorder() router.ServeHTTP(w, req) assert.Equal(t, http.StatusForbidden, w.Code) // IDOR blocked }