2026-02-15 15:18:13 +00:00
|
|
|
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"
|
2026-03-13 23:44:46 +00:00
|
|
|
"gorm.io/driver/sqlite"
|
|
|
|
|
"gorm.io/gorm"
|
2026-02-15 15:18:13 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 23:44:46 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 15:18:13 +00:00
|
|
|
// 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()
|
2026-03-13 23:44:46 +00:00
|
|
|
creatorID := uuid.New()
|
2026-02-15 15:18:13 +00:00
|
|
|
expectedStats := &types.TrackStats{
|
|
|
|
|
TotalPlays: 100,
|
|
|
|
|
UniqueListeners: 50,
|
|
|
|
|
AverageDuration: 120,
|
|
|
|
|
CompletionRate: 0.8,
|
|
|
|
|
}
|
|
|
|
|
expectedPoints := []services.PlayTimePoint{
|
|
|
|
|
{Date: time.Now(), Count: 10},
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 23:44:46 +00:00
|
|
|
testDB := setupTestDB(trackID, creatorID)
|
|
|
|
|
mockService.On("GetDB").Return(testDB)
|
2026-02-15 15:18:13 +00:00
|
|
|
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)
|
2026-03-13 23:44:46 +00:00
|
|
|
req.Header.Set("X-User-ID", creatorID.String()) // SECURITY: Must be track creator
|
2026-02-15 15:18:13 +00:00
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
router.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
|
|
|
mockService.AssertExpectations(t)
|
|
|
|
|
}
|
2026-03-13 23:44:46 +00:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|