//go:build integration // +build integration package contract import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "os" "testing" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "golang.org/x/crypto/bcrypt" "go.uber.org/zap" "gorm.io/driver/sqlite" "gorm.io/gorm" "veza-backend-api/internal/api" "veza-backend-api/internal/config" "veza-backend-api/internal/core/marketplace" "veza-backend-api/internal/database" "veza-backend-api/internal/metrics" "veza-backend-api/internal/middleware" "veza-backend-api/internal/models" "veza-backend-api/internal/services" "github.com/stretchr/testify/require" ) // testAuthMiddleware reads X-User-ID header for integration tests (marketplace routes) type testAuthMiddleware struct{} func (t *testAuthMiddleware) RequireAuth() gin.HandlerFunc { return func(c *gin.Context) { if userIDStr := c.GetHeader("X-User-ID"); userIDStr != "" { if userID, err := uuid.Parse(userIDStr); err == nil { c.Set("user_id", userID) c.Next() return } } c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) } } func (t *testAuthMiddleware) RequireContentCreatorRole() gin.HandlerFunc { return t.RequireAuth() } func (t *testAuthMiddleware) RequireOwnershipOrAdmin(_ string, _ middleware.ResourceOwnerResolver) gin.HandlerFunc { return t.RequireAuth() } // migrateContractDB runs AutoMigrate for all models needed by contract tests. func migrateContractDB(t *testing.T, db *gorm.DB, extra ...interface{}) { t.Helper() allModels := []interface{}{ &models.User{}, &models.RefreshToken{}, &models.StorageQuota{}, &models.Track{}, &models.Session{}, &models.Notification{}, &models.Room{}, &models.RoomMember{}, &models.Message{}, &models.Playlist{}, &models.PlaylistTrack{}, &models.UserPresence{}, &models.Role{}, &models.UserRole{}, &models.Permission{}, &models.RolePermission{}, } allModels = append(allModels, extra...) require.NoError(t, db.AutoMigrate(allModels...)) // Add download_count to tracks if missing (analytics handler expects it) _ = db.Exec("ALTER TABLE tracks ADD COLUMN download_count INTEGER DEFAULT 0").Error } func setupContractRouter(t *testing.T, db *gorm.DB, marketSvc *marketplace.Service) *gin.Engine { t.Helper() os.Setenv("ENABLE_CLAMAV", "false") os.Setenv("CLAMAV_REQUIRED", "false") gin.SetMode(gin.TestMode) router := gin.New() sqlDB, err := db.DB() require.NoError(t, err) vezaDB := &database.Database{DB: sqlDB, GormDB: db, Logger: zap.NewNop()} cfg := &config.Config{ HyperswitchWebhookSecret: "test-secret", JWTSecret: "test-jwt-secret-key-minimum-32-characters-long", JWTIssuer: "veza-api", JWTAudience: "veza-app", Logger: zap.NewNop(), RedisClient: nil, ErrorMetrics: metrics.NewErrorMetrics(), UploadDir: "uploads/test", Env: "development", Database: vezaDB, CORSOrigins: []string{"*"}, HandlerTimeout: 30 * time.Second, RateLimitLimit: 100, AuthRateLimitLoginAttempts: 100, AuthRateLimitLoginWindow: 15, MarketplaceServiceOverride: marketSvc, } require.NoError(t, cfg.InitServicesForTest()) require.NoError(t, cfg.InitMiddlewaresForTest()) cfg.AuthMiddlewareOverride = &testAuthMiddleware{} apiRouter := api.NewAPIRouter(vezaDB, cfg) require.NoError(t, apiRouter.Setup(router)) return router } func openContractDB(t *testing.T) *gorm.DB { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) sqlDB, err := db.DB() require.NoError(t, err) sqlDB.SetMaxOpenConns(1) // SQLite :memory: - each new connection gets empty DB return db } func TestContract_Login(t *testing.T) { db := openContractDB(t) migrateContractDB(t, db) // No user - login fails with 401 router := setupContractRouter(t, db, nil) body, _ := json.Marshal(map[string]string{"email": "nobody@test.com", "password": "wrong"}) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) require.Equal(t, http.StatusUnauthorized, w.Code) resp := ValidateAPIResponseEnvelope(t, w.Body.Bytes(), false) require.NotNil(t, resp["error"]) } func TestContract_Register(t *testing.T) { db := openContractDB(t) migrateContractDB(t, db) router := setupContractRouter(t, db, nil) body, _ := json.Marshal(map[string]interface{}{ "email": "contract-" + uuid.New().String() + "@test.com", "username": "contractuser", "password": "ValidPassword123!", "password_confirmation": "ValidPassword123!", }) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) require.True(t, w.Code == http.StatusCreated || w.Code == http.StatusBadRequest, "register: %s", w.Body.String()) resp := ValidateAPIResponseEnvelope(t, w.Body.Bytes(), w.Code == http.StatusCreated) if w.Code == http.StatusCreated { data, ok := resp["data"].(map[string]interface{}) require.True(t, ok) RequireDataKeys(t, data, "user", "token") } } func TestContract_GetTracks(t *testing.T) { db := openContractDB(t) migrateContractDB(t, db) router := setupContractRouter(t, db, nil) req := httptest.NewRequest(http.MethodGet, "/api/v1/tracks", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) resp := ValidateAPIResponseEnvelope(t, w.Body.Bytes(), true) data, ok := resp["data"].(map[string]interface{}) require.True(t, ok) // Tracks may be in "tracks" or "items" depending on handler require.True(t, data["tracks"] != nil || data["items"] != nil || data["data"] != nil, "data should have tracks/items: %v", data) } func TestContract_GetUser(t *testing.T) { db := openContractDB(t) migrateContractDB(t, db) userID := uuid.New() hash, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost) require.NoError(t, db.Create(&models.User{ ID: userID, Email: "me@test.com", Username: "meuser", PasswordHash: string(hash), IsVerified: true, }).Error) router := setupContractRouter(t, db, nil) // Login to get token loginBody, _ := json.Marshal(map[string]string{"email": "me@test.com", "password": "password123"}) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) var loginResp map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &loginResp)) data := loginResp["data"].(map[string]interface{}) tokenData := data["token"].(map[string]interface{}) accessToken := tokenData["access_token"].(string) // GET /auth/me req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) req.Header.Set("Authorization", "Bearer "+accessToken) w = httptest.NewRecorder() router.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) resp := ValidateAPIResponseEnvelope(t, w.Body.Bytes(), true) userData, ok := resp["data"].(map[string]interface{}) require.True(t, ok) RequireDataKeys(t, userData, "id", "email", "username") } func TestContract_CreateTrack(t *testing.T) { db := openContractDB(t) migrateContractDB(t, db) userID := uuid.New() hash, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost) require.NoError(t, db.Create(&models.User{ ID: userID, Email: "creator@test.com", Username: "creator", PasswordHash: string(hash), IsVerified: true, Role: "content_creator", }).Error) // RequireContentCreatorRole checks user_roles; create creator role and assign creatorRole := models.Role{ID: uuid.New(), Name: "creator", DisplayName: "Creator", IsSystem: true} require.NoError(t, db.Create(&creatorRole).Error) require.NoError(t, db.Create(&models.UserRole{ UserID: userID, RoleID: creatorRole.ID, RoleName: "creator", IsActive: true, }).Error) router := setupContractRouter(t, db, nil) // Login loginBody, _ := json.Marshal(map[string]string{"email": "creator@test.com", "password": "password123"}) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) var loginResp map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &loginResp)) tokenData := loginResp["data"].(map[string]interface{})["token"].(map[string]interface{}) accessToken := tokenData["access_token"].(string) // POST /tracks without file -> expect 400 (validation) req = httptest.NewRequest(http.MethodPost, "/api/v1/tracks", nil) req.Header.Set("Authorization", "Bearer "+accessToken) w = httptest.NewRecorder() router.ServeHTTP(w, req) require.True(t, w.Code == http.StatusBadRequest || w.Code == http.StatusUnsupportedMediaType) ValidateAPIResponseEnvelope(t, w.Body.Bytes(), false) } func TestContract_Search(t *testing.T) { db := openContractDB(t) migrateContractDB(t, db) router := setupContractRouter(t, db, nil) req := httptest.NewRequest(http.MethodGet, "/api/v1/search?q=test", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) // Search uses PostgreSQL-specific SQL (ILIKE); may return 500 on SQLite require.True(t, w.Code == http.StatusOK || w.Code == http.StatusInternalServerError, "expected 200 or 500, got %d: %s", w.Code, w.Body.String()) if w.Code == http.StatusOK { resp := ValidateAPIResponseEnvelope(t, w.Body.Bytes(), true) require.Contains(t, resp, "data") } // On 500, Search returns raw gin.H{"error": "..."} - no standard envelope } func TestContract_CreateOrder(t *testing.T) { db := openContractDB(t) migrateContractDB(t, db, &marketplace.Product{}, &marketplace.Order{}, &marketplace.OrderItem{}, &marketplace.License{}, &marketplace.SellerTransfer{}, ) userID := uuid.New() trackID := uuid.New() hash, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost) require.NoError(t, db.Create(&models.User{ID: userID, Email: "buyer@test.com", Username: "buyer", PasswordHash: string(hash), IsVerified: true}).Error) require.NoError(t, db.Create(&models.Track{ID: trackID, UserID: userID, FilePath: "/test.mp3"}).Error) product := &marketplace.Product{ ID: uuid.New(), SellerID: userID, Title: "Test", Price: 9.99, ProductType: "track", TrackID: &trackID, Status: marketplace.ProductStatusActive, } require.NoError(t, db.Create(product).Error) storageService := services.NewTrackStorageService("uploads/test", false, zap.NewNop()) marketSvc := marketplace.NewService(db, zap.NewNop(), storageService, marketplace.WithPaymentProvider(&mockPaymentProvider{paymentID: "pay_1", clientSecret: "sec"}), marketplace.WithHyperswitchConfig(true, "/"), ) router := setupContractRouter(t, db, marketSvc) // Login loginBody, _ := json.Marshal(map[string]string{"email": "buyer@test.com", "password": "password123"}) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) var loginResp map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &loginResp)) tokenData := loginResp["data"].(map[string]interface{})["token"].(map[string]interface{}) accessToken := tokenData["access_token"].(string) // Create order via marketplace (uses AuthMiddlewareOverride -> X-User-ID) orderBody, _ := json.Marshal(map[string]interface{}{ "items": []map[string]string{{"product_id": product.ID.String()}}, }) req = httptest.NewRequest(http.MethodPost, "/api/v1/marketplace/orders", bytes.NewReader(orderBody)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("X-User-ID", userID.String()) w = httptest.NewRecorder() router.ServeHTTP(w, req) require.True(t, w.Code == http.StatusCreated || w.Code == http.StatusOK || w.Code >= 400) resp := ValidateAPIResponseEnvelope(t, w.Body.Bytes(), w.Code < 400) if w.Code < 400 { data, _ := resp["data"].(map[string]interface{}) if data != nil { require.True(t, data["order"] != nil || data["payment_id"] != nil || data["id"] != nil) } } } type mockPaymentProvider struct { paymentID string clientSecret string } func (m *mockPaymentProvider) CreatePayment(_ context.Context, _ int64, _, _, _ string, _ map[string]string) (string, string, error) { return m.paymentID, m.clientSecret, nil } func (m *mockPaymentProvider) GetPayment(_ context.Context, _ string) (string, error) { return "succeeded", nil } func TestContract_GetNotifications(t *testing.T) { db := openContractDB(t) migrateContractDB(t, db) userID := uuid.New() hash, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost) require.NoError(t, db.Create(&models.User{ ID: userID, Email: "notif@test.com", Username: "notifuser", PasswordHash: string(hash), IsVerified: true, }).Error) router := setupContractRouter(t, db, nil) loginBody, _ := json.Marshal(map[string]string{"email": "notif@test.com", "password": "password123"}) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) var loginResp map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &loginResp)) tokenData := loginResp["data"].(map[string]interface{})["token"].(map[string]interface{}) accessToken := tokenData["access_token"].(string) req = httptest.NewRequest(http.MethodGet, "/api/v1/notifications", nil) req.Header.Set("Authorization", "Bearer "+accessToken) w = httptest.NewRecorder() router.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) resp := ValidateAPIResponseEnvelope(t, w.Body.Bytes(), true) require.Contains(t, resp, "data") } func TestContract_GetConversations(t *testing.T) { db := openContractDB(t) migrateContractDB(t, db) userID := uuid.New() hash, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost) require.NoError(t, db.Create(&models.User{ ID: userID, Email: "conv@test.com", Username: "convuser", PasswordHash: string(hash), IsVerified: true, }).Error) router := setupContractRouter(t, db, nil) loginBody, _ := json.Marshal(map[string]string{"email": "conv@test.com", "password": "password123"}) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) var loginResp map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &loginResp)) tokenData := loginResp["data"].(map[string]interface{})["token"].(map[string]interface{}) accessToken := tokenData["access_token"].(string) req = httptest.NewRequest(http.MethodGet, "/api/v1/conversations", nil) req.Header.Set("Authorization", "Bearer "+accessToken) w = httptest.NewRecorder() router.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) resp := ValidateAPIResponseEnvelope(t, w.Body.Bytes(), true) require.Contains(t, resp, "data") } func TestContract_GetAnalytics(t *testing.T) { db := openContractDB(t) migrateContractDB(t, db) userID := uuid.New() hash, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost) require.NoError(t, db.Create(&models.User{ ID: userID, Email: "analytics@test.com", Username: "analyticsuser", PasswordHash: string(hash), IsVerified: true, }).Error) router := setupContractRouter(t, db, nil) loginBody, _ := json.Marshal(map[string]string{"email": "analytics@test.com", "password": "password123"}) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) var loginResp map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &loginResp)) tokenData := loginResp["data"].(map[string]interface{})["token"].(map[string]interface{}) accessToken := tokenData["access_token"].(string) req = httptest.NewRequest(http.MethodGet, "/api/v1/analytics", nil) req.Header.Set("Authorization", "Bearer "+accessToken) w = httptest.NewRecorder() router.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) resp := ValidateAPIResponseEnvelope(t, w.Body.Bytes(), true) require.Contains(t, resp, "data") }