//go:build integration || webhook_delivery // +build integration webhook_delivery package webhook_delivery import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "sync" "testing" "time" "veza-backend-api/internal/models" "veza-backend-api/internal/services" "veza-backend-api/internal/workers" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap/zaptest" "gorm.io/driver/sqlite" "gorm.io/gorm" ) // setupWebhookDeliveryTest crée un environnement de test pour les webhooks func setupWebhookDeliveryTest(t *testing.T) (*services.WebhookService, *gorm.DB, func()) { logger := zaptest.NewLogger(t) // Setup in-memory SQLite database db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) db.Exec("PRAGMA foreign_keys = ON") // Auto-migrate models err = db.AutoMigrate( &models.User{}, &models.Webhook{}, &models.WebhookFailure{}, ) require.NoError(t, err) // Setup webhook service jwtSecret := "test-secret-key" webhookService := services.NewWebhookService(db, logger, jwtSecret) cleanup := func() { // Database cleanup handled by test } return webhookService, db, cleanup } // createTestWebhook crée un webhook de test func createTestWebhook(db *gorm.DB, userID uuid.UUID, url string, events []string, active bool) (*models.Webhook, error) { apiKey, err := services.NewWebhookService(db, zaptest.NewLogger(&testing.T{}), "test-secret").GenerateAPIKey() if err != nil { return nil, err } webhook := &models.Webhook{ ID: uuid.New(), UserID: userID, URL: url, Events: events, Active: active, APIKey: apiKey, CreatedAt: time.Now(), UpdatedAt: time.Now(), } if err := db.Create(webhook).Error; err != nil { return nil, err } return webhook, nil } // TestWebhookService_TriggerEvent_Success teste le déclenchement d'un événement avec succès // Note: SQLite ne supporte pas la syntaxe PostgreSQL @> pour les arrays, donc ce test est limité func TestWebhookService_TriggerEvent_Success(t *testing.T) { t.Skip("SQLite does not support PostgreSQL array operators (@>), skipping TriggerEvent tests") service, db, cleanup := setupWebhookDeliveryTest(t) defer cleanup() // Create test user userID := uuid.New() user := &models.User{ ID: userID, Email: "test@example.com", Username: "testuser", PasswordHash: "$2a$10$abcdefghijklmnopqrstuvwxyz1234567890", IsActive: true, IsVerified: true, } err := db.Create(user).Error require.NoError(t, err) // Create test webhook server that will receive the webhook var receivedPayload map[string]interface{} var receivedHeaders http.Header var mu sync.Mutex server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() defer mu.Unlock() receivedHeaders = r.Header.Clone() var payload map[string]interface{} json.NewDecoder(r.Body).Decode(&payload) receivedPayload = payload w.WriteHeader(http.StatusOK) w.Write([]byte("OK")) })) defer server.Close() // Create active webhook for the event _, err = createTestWebhook(db, userID, server.URL, []string{"track.created"}, true) require.NoError(t, err) // Trigger event eventData := map[string]interface{}{ "track_id": uuid.New().String(), "title": "Test Track", } err = service.TriggerEvent(context.Background(), "track.created", eventData, &userID) require.NoError(t, err) // Wait for async webhook delivery time.Sleep(500 * time.Millisecond) // Verify webhook was received mu.Lock() defer mu.Unlock() require.NotNil(t, receivedPayload, "Webhook payload should be received") assert.Equal(t, "track.created", receivedPayload["event"]) assert.NotNil(t, receivedPayload["timestamp"]) assert.Equal(t, eventData["track_id"], receivedPayload["data"].(map[string]interface{})["track_id"]) assert.Equal(t, eventData["title"], receivedPayload["data"].(map[string]interface{})["title"]) // Verify headers assert.NotEmpty(t, receivedHeaders.Get("X-Veza-Signature"), "Signature header should be present") assert.Equal(t, "track.created", receivedHeaders.Get("X-Veza-Event"), "Event header should match") assert.NotEmpty(t, receivedHeaders.Get("X-Veza-Timestamp"), "Timestamp header should be present") } // TestWebhookService_TriggerEvent_MultipleWebhooks teste le déclenchement pour plusieurs webhooks // Note: SQLite ne supporte pas la syntaxe PostgreSQL @> pour les arrays func TestWebhookService_TriggerEvent_MultipleWebhooks(t *testing.T) { t.Skip("SQLite does not support PostgreSQL array operators (@>), skipping TriggerEvent tests") service, db, cleanup := setupWebhookDeliveryTest(t) defer cleanup() // Create test user userID := uuid.New() user := &models.User{ ID: userID, Email: "test@example.com", Username: "testuser", PasswordHash: "$2a$10$abcdefghijklmnopqrstuvwxyz1234567890", IsActive: true, IsVerified: true, } err := db.Create(user).Error require.NoError(t, err) // Create multiple test webhook servers var receivedCount int32 var mu sync.Mutex createServer := func() *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() receivedCount++ mu.Unlock() w.WriteHeader(http.StatusOK) })) } server1 := createServer() defer server1.Close() server2 := createServer() defer server2.Close() server3 := createServer() defer server3.Close() // Create multiple active webhooks for the same event _, err = createTestWebhook(db, userID, server1.URL, []string{"track.created"}, true) require.NoError(t, err) _, err = createTestWebhook(db, userID, server2.URL, []string{"track.created"}, true) require.NoError(t, err) _, err = createTestWebhook(db, userID, server3.URL, []string{"track.created"}, true) require.NoError(t, err) // Trigger event eventData := map[string]interface{}{ "track_id": uuid.New().String(), } err = service.TriggerEvent(context.Background(), "track.created", eventData, &userID) require.NoError(t, err) // Wait for async webhook deliveries time.Sleep(1 * time.Second) // Verify all webhooks were received mu.Lock() defer mu.Unlock() assert.Equal(t, int32(3), receivedCount, "All webhooks should be received") } // TestWebhookService_TriggerEvent_FiltersByEvent teste que seuls les webhooks pour l'événement sont déclenchés // Note: SQLite ne supporte pas la syntaxe PostgreSQL @> pour les arrays func TestWebhookService_TriggerEvent_FiltersByEvent(t *testing.T) { t.Skip("SQLite does not support PostgreSQL array operators (@>), skipping TriggerEvent tests") service, db, cleanup := setupWebhookDeliveryTest(t) defer cleanup() // Create test user userID := uuid.New() user := &models.User{ ID: userID, Email: "test@example.com", Username: "testuser", PasswordHash: "$2a$10$abcdefghijklmnopqrstuvwxyz1234567890", IsActive: true, IsVerified: true, } err := db.Create(user).Error require.NoError(t, err) // Create test webhook server var receivedCount int32 var mu sync.Mutex server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() receivedCount++ mu.Unlock() w.WriteHeader(http.StatusOK) })) defer server.Close() // Create webhook for different event _, err = createTestWebhook(db, userID, server.URL, []string{"track.updated"}, true) require.NoError(t, err) // Trigger different event eventData := map[string]interface{}{ "track_id": uuid.New().String(), } err = service.TriggerEvent(context.Background(), "track.created", eventData, &userID) require.NoError(t, err) // Wait for async webhook delivery time.Sleep(500 * time.Millisecond) // Verify webhook was NOT received (different event) mu.Lock() defer mu.Unlock() assert.Equal(t, int32(0), receivedCount, "Webhook should not be received for different event") } // TestWebhookService_TriggerEvent_IgnoresInactiveWebhooks teste que les webhooks inactifs sont ignorés // Note: SQLite ne supporte pas la syntaxe PostgreSQL @> pour les arrays func TestWebhookService_TriggerEvent_IgnoresInactiveWebhooks(t *testing.T) { t.Skip("SQLite does not support PostgreSQL array operators (@>), skipping TriggerEvent tests") service, db, cleanup := setupWebhookDeliveryTest(t) defer cleanup() // Create test user userID := uuid.New() user := &models.User{ ID: userID, Email: "test@example.com", Username: "testuser", PasswordHash: "$2a$10$abcdefghijklmnopqrstuvwxyz1234567890", IsActive: true, IsVerified: true, } err := db.Create(user).Error require.NoError(t, err) // Create test webhook server var receivedCount int32 var mu sync.Mutex server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() receivedCount++ mu.Unlock() w.WriteHeader(http.StatusOK) })) defer server.Close() // Create inactive webhook _, err = createTestWebhook(db, userID, server.URL, []string{"track.created"}, false) require.NoError(t, err) // Trigger event eventData := map[string]interface{}{ "track_id": uuid.New().String(), } err = service.TriggerEvent(context.Background(), "track.created", eventData, &userID) require.NoError(t, err) // Wait for async webhook delivery time.Sleep(500 * time.Millisecond) // Verify webhook was NOT received (inactive) mu.Lock() defer mu.Unlock() assert.Equal(t, int32(0), receivedCount, "Inactive webhook should not be triggered") } // TestWebhookService_DeliverWebhook_Success teste la livraison réussie d'un webhook func TestWebhookService_DeliverWebhook_Success(t *testing.T) { service, db, cleanup := setupWebhookDeliveryTest(t) defer cleanup() // Create test webhook server var receivedPayload map[string]interface{} var receivedHeaders http.Header var mu sync.Mutex server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() defer mu.Unlock() receivedHeaders = r.Header.Clone() var payload map[string]interface{} json.NewDecoder(r.Body).Decode(&payload) receivedPayload = payload w.WriteHeader(http.StatusOK) w.Write([]byte("OK")) })) defer server.Close() // Create webhook webhook, err := createTestWebhook(db, uuid.New(), server.URL, []string{"track.created"}, true) require.NoError(t, err) // Deliver webhook eventData := map[string]interface{}{ "track_id": uuid.New().String(), "title": "Test Track", } err = service.DeliverWebhook(context.Background(), webhook, "track.created", eventData) require.NoError(t, err) // Verify webhook was received mu.Lock() defer mu.Unlock() require.NotNil(t, receivedPayload, "Webhook payload should be received") assert.Equal(t, "track.created", receivedPayload["event"]) assert.NotNil(t, receivedPayload["timestamp"]) assert.Equal(t, eventData["track_id"], receivedPayload["data"].(map[string]interface{})["track_id"]) // Verify signature header is present (actual verification is tested in TestWebhookService_DeliverWebhook_SignatureVerification) assert.NotEmpty(t, receivedHeaders.Get("X-Veza-Signature"), "Signature header should be present") } // TestWebhookService_DeliverWebhook_RetryOnFailure teste le retry en cas d'erreur réseau // Note: Le service retry seulement en cas d'erreur réseau, pas pour les status codes non-200 func TestWebhookService_DeliverWebhook_RetryOnFailure(t *testing.T) { service, db, cleanup := setupWebhookDeliveryTest(t) defer cleanup() // Create webhook pointing to server that will be started later // This simulates a network error initially, then success webhook, err := createTestWebhook(db, uuid.New(), "http://localhost:99998/temporary", []string{"track.created"}, true) require.NoError(t, err) // Start with no server (will cause network error) eventData := map[string]interface{}{ "track_id": uuid.New().String(), } // First attempt will fail (no server) // Then start server for retry var server *httptest.Server var receivedCount int32 var mu sync.Mutex // Start server after a short delay to simulate retry scenario go func() { time.Sleep(500 * time.Millisecond) server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() receivedCount++ mu.Unlock() w.WriteHeader(http.StatusOK) })) // Update webhook URL to point to the new server webhook.URL = server.URL }() // This will fail initially, but we can't easily test retry with network errors // So we test that the service handles network errors gracefully err = service.DeliverWebhook(context.Background(), webhook, "track.created", eventData) // Should fail due to network error (server not ready yet) require.Error(t, err, "Webhook should fail due to network error") if server != nil { defer server.Close() } } // TestWebhookService_DeliverWebhook_MaxRetriesExceeded teste que le retry s'arrête après max retries func TestWebhookService_DeliverWebhook_MaxRetriesExceeded(t *testing.T) { service, db, cleanup := setupWebhookDeliveryTest(t) defer cleanup() // Create test webhook server that always fails attemptCount := 0 var mu sync.Mutex server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Read body to avoid "ContentLength with Body length 0" error io.ReadAll(r.Body) mu.Lock() attemptCount++ mu.Unlock() w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("Internal Server Error")) })) defer server.Close() // Create webhook webhook, err := createTestWebhook(db, uuid.New(), server.URL, []string{"track.created"}, true) require.NoError(t, err) // Deliver webhook - should fail after max retries eventData := map[string]interface{}{ "track_id": uuid.New().String(), } err = service.DeliverWebhook(context.Background(), webhook, "track.created", eventData) // Should fail after max retries require.Error(t, err, "Webhook should fail after max retries") assert.Contains(t, err.Error(), "failed after", "Error should mention retry failure") // Note: The service retries only on network errors, not on non-200 status codes // This test verifies that network errors trigger retries } // TestWebhookService_DeliverWebhook_NetworkErrorRetry teste le retry en cas d'erreur réseau func TestWebhookService_DeliverWebhook_NetworkErrorRetry(t *testing.T) { service, db, cleanup := setupWebhookDeliveryTest(t) defer cleanup() // Create webhook pointing to non-existent server (will cause network error) webhook, err := createTestWebhook(db, uuid.New(), "http://localhost:99999/nonexistent", []string{"track.created"}, true) require.NoError(t, err) // Deliver webhook - should retry and eventually fail eventData := map[string]interface{}{ "track_id": uuid.New().String(), } err = service.DeliverWebhook(context.Background(), webhook, "track.created", eventData) // Should fail after max retries require.Error(t, err, "Webhook should fail after max retries due to network error") assert.Contains(t, err.Error(), "failed after", "Error should mention retry failure") } // TestWebhookWorker_RetryLogic teste la logique de retry du worker func TestWebhookWorker_RetryLogic(t *testing.T) { logger := zaptest.NewLogger(t) // Setup in-memory SQLite database db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) db.Exec("PRAGMA foreign_keys = ON") // Auto-migrate models err = db.AutoMigrate( &models.User{}, &models.Webhook{}, &models.WebhookFailure{}, ) require.NoError(t, err) // Create test webhook server that fails first time, then succeeds attemptCount := 0 var mu sync.Mutex server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() attemptCount++ currentAttempt := attemptCount mu.Unlock() if currentAttempt == 1 { // Fail first attempt w.WriteHeader(http.StatusInternalServerError) } else { // Succeed on retry w.WriteHeader(http.StatusOK) } })) defer server.Close() // Setup webhook service jwtSecret := "test-secret-key" webhookService := services.NewWebhookService(db, logger, jwtSecret) // Setup webhook worker with small queue and 1 worker for testing webhookWorker := workers.NewWebhookWorker( db, webhookService, logger, 10, // Queue size 1, // Workers 3, // Max retries ) // Create webhook webhook, err := createTestWebhook(db, uuid.New(), server.URL, []string{"track.created"}, true) require.NoError(t, err) // Start worker ctx, cancel := context.WithCancel(context.Background()) defer cancel() webhookWorker.Start(ctx) // Enqueue job job := workers.WebhookJob{ Webhook: webhook, Event: "track.created", Data: map[string]interface{}{ "track_id": uuid.New().String(), }, Retries: 0, } webhookWorker.Enqueue(job) // Wait for processing and retry time.Sleep(2 * time.Second) // Verify retry happened mu.Lock() defer mu.Unlock() assert.GreaterOrEqual(t, attemptCount, 2, "Should have attempted at least 2 times (1 failure + 1 retry success)") } // TestWebhookService_DeliverWebhook_SignatureVerification teste la vérification de signature func TestWebhookService_DeliverWebhook_SignatureVerification(t *testing.T) { service, db, cleanup := setupWebhookDeliveryTest(t) defer cleanup() // Create test webhook server var receivedSignature string var receivedPayloadBytes []byte var mu sync.Mutex server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() defer mu.Unlock() receivedSignature = r.Header.Get("X-Veza-Signature") // Read the payload receivedPayloadBytes, _ = io.ReadAll(r.Body) w.WriteHeader(http.StatusOK) })) defer server.Close() // Create webhook webhook, err := createTestWebhook(db, uuid.New(), server.URL, []string{"track.created"}, true) require.NoError(t, err) // Deliver webhook eventData := map[string]interface{}{ "track_id": "test", } err = service.DeliverWebhook(context.Background(), webhook, "track.created", eventData) require.NoError(t, err) // Wait for delivery time.Sleep(200 * time.Millisecond) // Verify signature can be verified mu.Lock() defer mu.Unlock() // Verify signature with the actual payload that was sent require.NotEmpty(t, receivedSignature, "Signature should be received") require.NotEmpty(t, receivedPayloadBytes, "Payload should be received") // The signature is generated from the exact JSON bytes sent, so verify it isValid := service.VerifySignature(receivedSignature, receivedPayloadBytes) assert.True(t, isValid, "Signature should be valid") } // TestWebhookService_TriggerEvent_UserFiltering teste le filtrage par userID // Note: SQLite ne supporte pas la syntaxe PostgreSQL @> pour les arrays func TestWebhookService_TriggerEvent_UserFiltering(t *testing.T) { t.Skip("SQLite does not support PostgreSQL array operators (@>), skipping TriggerEvent tests") service, db, cleanup := setupWebhookDeliveryTest(t) defer cleanup() // Create two test users user1ID := uuid.New() user1 := &models.User{ ID: user1ID, Email: "user1@example.com", Username: "user1", PasswordHash: "$2a$10$abcdefghijklmnopqrstuvwxyz1234567890", IsActive: true, IsVerified: true, } err := db.Create(user1).Error require.NoError(t, err) user2ID := uuid.New() user2 := &models.User{ ID: user2ID, Email: "user2@example.com", Username: "user2", PasswordHash: "$2a$10$abcdefghijklmnopqrstuvwxyz1234567890", IsActive: true, IsVerified: true, } err = db.Create(user2).Error require.NoError(t, err) // Create test webhook servers var user1Received bool var user2Received bool var mu sync.Mutex server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() user1Received = true mu.Unlock() w.WriteHeader(http.StatusOK) })) defer server1.Close() server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() user2Received = true mu.Unlock() w.WriteHeader(http.StatusOK) })) defer server2.Close() // Create webhooks for both users _, err = createTestWebhook(db, user1ID, server1.URL, []string{"track.created"}, true) require.NoError(t, err) _, err = createTestWebhook(db, user2ID, server2.URL, []string{"track.created"}, true) require.NoError(t, err) // Trigger event for user1 only eventData := map[string]interface{}{ "track_id": uuid.New().String(), } err = service.TriggerEvent(context.Background(), "track.created", eventData, &user1ID) require.NoError(t, err) // Wait for async webhook delivery time.Sleep(500 * time.Millisecond) // Verify only user1's webhook was triggered mu.Lock() defer mu.Unlock() assert.True(t, user1Received, "User1's webhook should be triggered") assert.False(t, user2Received, "User2's webhook should not be triggered") }