package postgres import ( "context" "database/sql" "testing" "time" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/testutil" ) func cleanupTestWebhooks(t *testing.T, db *sql.DB) { t.Helper() // Clean deliveries first due to foreign key constraint _, err := db.Exec("DELETE FROM webhook_deliveries WHERE webhook_id IN (SELECT id FROM webhooks WHERE project_id LIKE 'test-%')") if err != nil { t.Logf("cleanup test webhook deliveries: %v", err) } _, err = db.Exec("DELETE FROM webhooks WHERE project_id LIKE 'test-%'") if err != nil { t.Logf("cleanup test webhooks: %v", err) } } func TestWebhookRepository_Create(t *testing.T) { db := testutil.TestDB(t) t.Cleanup(func() { cleanupTestWebhooks(t, db) }) repo := NewWebhookRepository(db) ctx := context.Background() t.Run("creates webhook successfully", func(t *testing.T) { webhook := &domain.Webhook{ ID: "wh-test-create-1", ProjectID: "test-proj-webhook-1", URL: "https://example.com/webhook", Secret: "test-secret-123", Events: []domain.WebhookEventType{ domain.WebhookEventCommandStarted, domain.WebhookEventCommandCompleted, }, Enabled: true, } err := repo.Create(ctx, webhook) if err != nil { t.Fatalf("Create() error = %v", err) } if webhook.CreatedAt.IsZero() { t.Error("CreatedAt should be set after create") } if webhook.UpdatedAt.IsZero() { t.Error("UpdatedAt should be set after create") } }) t.Run("creates webhook without secret", func(t *testing.T) { webhook := &domain.Webhook{ ID: "wh-test-create-nosecret", ProjectID: "test-proj-webhook-2", URL: "https://example.com/webhook2", Secret: "", Events: []domain.WebhookEventType{domain.WebhookEventCommandStarted}, Enabled: true, } err := repo.Create(ctx, webhook) if err != nil { t.Fatalf("Create() error = %v", err) } // Retrieve and verify secret is empty retrieved, _ := repo.GetByID(ctx, "wh-test-create-nosecret") if retrieved.Secret != "" { t.Errorf("Secret = %q, want empty", retrieved.Secret) } }) } func TestWebhookRepository_Update(t *testing.T) { db := testutil.TestDB(t) t.Cleanup(func() { cleanupTestWebhooks(t, db) }) repo := NewWebhookRepository(db) ctx := context.Background() t.Run("updates existing webhook", func(t *testing.T) { // Create webhook first webhook := &domain.Webhook{ ID: "wh-test-update-1", ProjectID: "test-proj-update", URL: "https://example.com/original", Secret: "original-secret", Events: []domain.WebhookEventType{domain.WebhookEventCommandStarted}, Enabled: true, } repo.Create(ctx, webhook) // Update it webhook.URL = "https://example.com/updated" webhook.Secret = "updated-secret" webhook.Enabled = false webhook.Events = []domain.WebhookEventType{ domain.WebhookEventCommandCompleted, domain.WebhookEventCommandFailed, } originalUpdatedAt := webhook.UpdatedAt time.Sleep(10 * time.Millisecond) // Ensure timestamp changes err := repo.Update(ctx, webhook) if err != nil { t.Fatalf("Update() error = %v", err) } if !webhook.UpdatedAt.After(originalUpdatedAt) { t.Error("UpdatedAt should be updated after Update()") } // Verify changes retrieved, _ := repo.GetByID(ctx, "wh-test-update-1") if retrieved.URL != "https://example.com/updated" { t.Errorf("URL = %q, want %q", retrieved.URL, "https://example.com/updated") } if retrieved.Secret != "updated-secret" { t.Errorf("Secret = %q, want %q", retrieved.Secret, "updated-secret") } if retrieved.Enabled { t.Error("Enabled should be false after update") } if len(retrieved.Events) != 2 { t.Errorf("Events length = %d, want 2", len(retrieved.Events)) } }) t.Run("returns error for non-existent webhook", func(t *testing.T) { webhook := &domain.Webhook{ ID: "wh-nonexistent", ProjectID: "test-proj", URL: "https://example.com", Events: []domain.WebhookEventType{domain.WebhookEventCommandStarted}, } err := repo.Update(ctx, webhook) if err != domain.ErrWebhookNotFound { t.Errorf("Update() error = %v, want %v", err, domain.ErrWebhookNotFound) } }) } func TestWebhookRepository_Delete(t *testing.T) { db := testutil.TestDB(t) t.Cleanup(func() { cleanupTestWebhooks(t, db) }) repo := NewWebhookRepository(db) ctx := context.Background() t.Run("deletes existing webhook", func(t *testing.T) { webhook := &domain.Webhook{ ID: "wh-test-delete-1", ProjectID: "test-proj-delete", URL: "https://example.com/delete", Events: []domain.WebhookEventType{domain.WebhookEventCommandStarted}, Enabled: true, } repo.Create(ctx, webhook) err := repo.Delete(ctx, "wh-test-delete-1") if err != nil { t.Fatalf("Delete() error = %v", err) } // Verify deleted _, err = repo.GetByID(ctx, "wh-test-delete-1") if err != domain.ErrWebhookNotFound { t.Errorf("GetByID() after delete error = %v, want %v", err, domain.ErrWebhookNotFound) } }) t.Run("returns error for non-existent webhook", func(t *testing.T) { err := repo.Delete(ctx, "wh-nonexistent") if err != domain.ErrWebhookNotFound { t.Errorf("Delete() error = %v, want %v", err, domain.ErrWebhookNotFound) } }) } func TestWebhookRepository_GetByID(t *testing.T) { db := testutil.TestDB(t) t.Cleanup(func() { cleanupTestWebhooks(t, db) }) repo := NewWebhookRepository(db) ctx := context.Background() t.Run("gets existing webhook", func(t *testing.T) { webhook := &domain.Webhook{ ID: "wh-test-getbyid-1", ProjectID: "test-proj-getbyid", URL: "https://example.com/getbyid", Secret: "get-secret", Events: []domain.WebhookEventType{ domain.WebhookEventCommandStarted, domain.WebhookEventCommandCompleted, }, Enabled: true, } repo.Create(ctx, webhook) retrieved, err := repo.GetByID(ctx, "wh-test-getbyid-1") if err != nil { t.Fatalf("GetByID() error = %v", err) } if retrieved.URL != "https://example.com/getbyid" { t.Errorf("URL = %q, want %q", retrieved.URL, "https://example.com/getbyid") } if retrieved.Secret != "get-secret" { t.Errorf("Secret = %q, want %q", retrieved.Secret, "get-secret") } if len(retrieved.Events) != 2 { t.Errorf("Events length = %d, want 2", len(retrieved.Events)) } }) t.Run("returns error for non-existent webhook", func(t *testing.T) { _, err := repo.GetByID(ctx, "wh-nonexistent") if err != domain.ErrWebhookNotFound { t.Errorf("GetByID() error = %v, want %v", err, domain.ErrWebhookNotFound) } }) } func TestWebhookRepository_ListByProject(t *testing.T) { db := testutil.TestDB(t) t.Cleanup(func() { cleanupTestWebhooks(t, db) }) repo := NewWebhookRepository(db) ctx := context.Background() projectID := "test-proj-list" // Create multiple webhooks for i := 0; i < 3; i++ { webhook := &domain.Webhook{ ID: domain.WebhookID("wh-test-list-" + string(rune('a'+i))), ProjectID: projectID, URL: "https://example.com/list" + string(rune('a'+i)), Events: []domain.WebhookEventType{domain.WebhookEventCommandStarted}, Enabled: true, } repo.Create(ctx, webhook) time.Sleep(10 * time.Millisecond) // Ensure different timestamps } // Create webhook in different project otherWebhook := &domain.Webhook{ ID: "wh-test-list-other", ProjectID: "test-proj-list-other", URL: "https://example.com/other", Events: []domain.WebhookEventType{domain.WebhookEventCommandStarted}, Enabled: true, } repo.Create(ctx, otherWebhook) t.Run("lists all webhooks for project", func(t *testing.T) { webhooks, err := repo.ListByProject(ctx, projectID) if err != nil { t.Fatalf("ListByProject() error = %v", err) } if len(webhooks) != 3 { t.Errorf("ListByProject() returned %d webhooks, want 3", len(webhooks)) } // Verify all belong to the project for _, wh := range webhooks { if wh.ProjectID != projectID { t.Errorf("Webhook has ProjectID = %q, want %q", wh.ProjectID, projectID) } } }) t.Run("returns empty slice for project with no webhooks", func(t *testing.T) { webhooks, err := repo.ListByProject(ctx, "test-proj-no-webhooks") if err != nil { t.Fatalf("ListByProject() error = %v", err) } if len(webhooks) != 0 { t.Errorf("ListByProject() returned %d webhooks, want 0", len(webhooks)) } }) } func TestWebhookRepository_ListEnabledByProjectAndEvent(t *testing.T) { db := testutil.TestDB(t) t.Cleanup(func() { cleanupTestWebhooks(t, db) }) repo := NewWebhookRepository(db) ctx := context.Background() projectID := "test-proj-enabled" // Create webhooks with different configurations enabledStarted := &domain.Webhook{ ID: "wh-enabled-started", ProjectID: projectID, URL: "https://example.com/enabled-started", Events: []domain.WebhookEventType{domain.WebhookEventCommandStarted}, Enabled: true, } repo.Create(ctx, enabledStarted) enabledCompleted := &domain.Webhook{ ID: "wh-enabled-completed", ProjectID: projectID, URL: "https://example.com/enabled-completed", Events: []domain.WebhookEventType{domain.WebhookEventCommandCompleted}, Enabled: true, } repo.Create(ctx, enabledCompleted) disabledStarted := &domain.Webhook{ ID: "wh-disabled-started", ProjectID: projectID, URL: "https://example.com/disabled-started", Events: []domain.WebhookEventType{domain.WebhookEventCommandStarted}, Enabled: false, } repo.Create(ctx, disabledStarted) t.Run("returns only enabled webhooks with matching event", func(t *testing.T) { webhooks, err := repo.ListEnabledByProjectAndEvent(ctx, projectID, domain.WebhookEventCommandStarted) if err != nil { t.Fatalf("ListEnabledByProjectAndEvent() error = %v", err) } if len(webhooks) != 1 { t.Errorf("ListEnabledByProjectAndEvent() returned %d webhooks, want 1", len(webhooks)) } if len(webhooks) > 0 && webhooks[0].ID != "wh-enabled-started" { t.Errorf("Webhook ID = %q, want %q", webhooks[0].ID, "wh-enabled-started") } }) t.Run("returns empty when no matching webhooks", func(t *testing.T) { webhooks, err := repo.ListEnabledByProjectAndEvent(ctx, projectID, domain.WebhookEventCommandFailed) if err != nil { t.Fatalf("ListEnabledByProjectAndEvent() error = %v", err) } if len(webhooks) != 0 { t.Errorf("ListEnabledByProjectAndEvent() returned %d webhooks, want 0", len(webhooks)) } }) } func TestWebhookRepository_RecordDelivery(t *testing.T) { db := testutil.TestDB(t) t.Cleanup(func() { cleanupTestWebhooks(t, db) }) repo := NewWebhookRepository(db) ctx := context.Background() // Create webhook first webhook := &domain.Webhook{ ID: "wh-test-delivery", ProjectID: "test-proj-delivery", URL: "https://example.com/delivery", Events: []domain.WebhookEventType{domain.WebhookEventCommandStarted}, Enabled: true, } repo.Create(ctx, webhook) t.Run("records successful delivery", func(t *testing.T) { delivery := &domain.WebhookDelivery{ ID: "del-test-success", WebhookID: "wh-test-delivery", EventType: domain.WebhookEventCommandStarted, Payload: `{"event":"command.started"}`, ResponseStatus: 200, ResponseBody: "OK", DeliveredAt: time.Now(), Success: true, RetryCount: 0, } err := repo.RecordDelivery(ctx, delivery) if err != nil { t.Fatalf("RecordDelivery() error = %v", err) } }) t.Run("records failed delivery", func(t *testing.T) { delivery := &domain.WebhookDelivery{ ID: "del-test-failure", WebhookID: "wh-test-delivery", EventType: domain.WebhookEventCommandStarted, Payload: `{"event":"command.started"}`, ResponseStatus: 500, ResponseBody: "Internal Server Error", DeliveredAt: time.Now(), Success: false, RetryCount: 3, ErrorMessage: "server returned 500", } err := repo.RecordDelivery(ctx, delivery) if err != nil { t.Fatalf("RecordDelivery() error = %v", err) } }) } func TestWebhookRepository_GetDeliveries(t *testing.T) { db := testutil.TestDB(t) t.Cleanup(func() { cleanupTestWebhooks(t, db) }) repo := NewWebhookRepository(db) ctx := context.Background() // Create webhook webhook := &domain.Webhook{ ID: "wh-test-get-deliveries", ProjectID: "test-proj-get-deliveries", URL: "https://example.com/deliveries", Events: []domain.WebhookEventType{domain.WebhookEventCommandStarted}, Enabled: true, } repo.Create(ctx, webhook) // Create deliveries for i := 0; i < 5; i++ { delivery := &domain.WebhookDelivery{ ID: domain.WebhookDeliveryID("del-get-" + string(rune('a'+i))), WebhookID: "wh-test-get-deliveries", EventType: domain.WebhookEventCommandStarted, Payload: `{"event":"command.started"}`, ResponseStatus: 200, DeliveredAt: time.Now(), Success: i%2 == 0, // Alternate success/failure } _ = repo.RecordDelivery(ctx, delivery) time.Sleep(10 * time.Millisecond) } t.Run("gets all deliveries", func(t *testing.T) { deliveries, err := repo.GetDeliveries(ctx, "wh-test-get-deliveries", nil) if err != nil { t.Fatalf("GetDeliveries() error = %v", err) } if len(deliveries) != 5 { t.Errorf("GetDeliveries() returned %d deliveries, want 5", len(deliveries)) } }) t.Run("filters by success", func(t *testing.T) { success := true deliveries, err := repo.GetDeliveries(ctx, "wh-test-get-deliveries", &domain.WebhookDeliveryFilters{ Success: &success, Limit: 100, }) if err != nil { t.Fatalf("GetDeliveries() error = %v", err) } for _, d := range deliveries { if !d.Success { t.Error("GetDeliveries() returned unsuccessful delivery when filtering by success=true") } } }) t.Run("applies limit", func(t *testing.T) { deliveries, err := repo.GetDeliveries(ctx, "wh-test-get-deliveries", &domain.WebhookDeliveryFilters{ Limit: 2, }) if err != nil { t.Fatalf("GetDeliveries() error = %v", err) } if len(deliveries) != 2 { t.Errorf("GetDeliveries() returned %d deliveries, want 2", len(deliveries)) } }) } func TestWebhookRepository_CleanupOldDeliveries(t *testing.T) { db := testutil.TestDB(t) t.Cleanup(func() { cleanupTestWebhooks(t, db) }) repo := NewWebhookRepository(db) ctx := context.Background() // Create webhook webhook := &domain.Webhook{ ID: "wh-test-cleanup", ProjectID: "test-proj-cleanup", URL: "https://example.com/cleanup", Events: []domain.WebhookEventType{domain.WebhookEventCommandStarted}, Enabled: true, } repo.Create(ctx, webhook) // Create deliveries for i := 0; i < 3; i++ { delivery := &domain.WebhookDelivery{ ID: domain.WebhookDeliveryID("del-cleanup-" + string(rune('a'+i))), WebhookID: "wh-test-cleanup", EventType: domain.WebhookEventCommandStarted, Payload: `{}`, ResponseStatus: 200, DeliveredAt: time.Now(), Success: true, } _ = repo.RecordDelivery(ctx, delivery) } t.Run("cleanup runs without error", func(t *testing.T) { deleted, err := repo.CleanupOldDeliveries(ctx, 30) if err != nil { t.Fatalf("CleanupOldDeliveries() error = %v", err) } // Newly created deliveries shouldn't be deleted if deleted != 0 { t.Logf("CleanupOldDeliveries() deleted %d deliveries (expected 0 for new deliveries)", deleted) } }) }