package handlers import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/go-chi/chi/v5" "github.com/orchard9/rdev/internal/domain" ) // mockWebhookRepository implements port.WebhookRepository for testing. type mockWebhookRepository struct { webhooks []*domain.Webhook deliveries []*domain.WebhookDelivery err error } func (m *mockWebhookRepository) Create(ctx context.Context, webhook *domain.Webhook) error { if m.err != nil { return m.err } webhook.CreatedAt = time.Now() webhook.UpdatedAt = time.Now() m.webhooks = append(m.webhooks, webhook) return nil } func (m *mockWebhookRepository) GetByID(ctx context.Context, id domain.WebhookID) (*domain.Webhook, error) { if m.err != nil { return nil, m.err } for _, w := range m.webhooks { if w.ID == id { return w, nil } } return nil, domain.ErrWebhookNotFound } func (m *mockWebhookRepository) ListByProject(ctx context.Context, projectID string) ([]*domain.Webhook, error) { if m.err != nil { return nil, m.err } var result []*domain.Webhook for _, w := range m.webhooks { if w.ProjectID == projectID { result = append(result, w) } } return result, nil } func (m *mockWebhookRepository) ListEnabledByProjectAndEvent(ctx context.Context, projectID string, eventType domain.WebhookEventType) ([]*domain.Webhook, error) { if m.err != nil { return nil, m.err } var result []*domain.Webhook for _, w := range m.webhooks { if w.ProjectID == projectID && w.Enabled { for _, e := range w.Events { if e == eventType { result = append(result, w) break } } } } return result, nil } func (m *mockWebhookRepository) Update(ctx context.Context, webhook *domain.Webhook) error { if m.err != nil { return m.err } for i, w := range m.webhooks { if w.ID == webhook.ID { m.webhooks[i] = webhook return nil } } return domain.ErrWebhookNotFound } func (m *mockWebhookRepository) Delete(ctx context.Context, id domain.WebhookID) error { if m.err != nil { return m.err } for i, w := range m.webhooks { if w.ID == id { m.webhooks = append(m.webhooks[:i], m.webhooks[i+1:]...) return nil } } return domain.ErrWebhookNotFound } func (m *mockWebhookRepository) RecordDelivery(ctx context.Context, delivery *domain.WebhookDelivery) error { if m.err != nil { return m.err } m.deliveries = append(m.deliveries, delivery) return nil } func (m *mockWebhookRepository) GetDeliveries(ctx context.Context, webhookID domain.WebhookID, filters *domain.WebhookDeliveryFilters) ([]*domain.WebhookDelivery, error) { if m.err != nil { return nil, m.err } var result []*domain.WebhookDelivery for _, d := range m.deliveries { if d.WebhookID == webhookID { result = append(result, d) } } return result, nil } func (m *mockWebhookRepository) CleanupOldDeliveries(ctx context.Context, olderThanDays int) (int64, error) { return 0, m.err } func TestWebhookHandler_Create(t *testing.T) { projectRepo := newMockProjectRepo() projectRepo.Register(context.Background(), &domain.Project{ID: "proj-1", Name: "Test Project"}) tests := []struct { name string projectID string body CreateWebhookRequest wantStatus int }{ { name: "valid webhook", projectID: "proj-1", body: CreateWebhookRequest{ URL: "https://example.com/webhook", Events: []string{"command.started", "command.completed"}, }, wantStatus: http.StatusCreated, }, { name: "with custom secret", projectID: "proj-1", body: CreateWebhookRequest{ URL: "https://example.com/webhook", Events: []string{"command.started"}, Secret: "my-secret-key", }, wantStatus: http.StatusCreated, }, { name: "missing url", projectID: "proj-1", body: CreateWebhookRequest{ Events: []string{"command.started"}, }, wantStatus: http.StatusBadRequest, }, { name: "invalid url", projectID: "proj-1", body: CreateWebhookRequest{ URL: "not-a-valid-url", Events: []string{"command.started"}, }, wantStatus: http.StatusBadRequest, }, { name: "missing events", projectID: "proj-1", body: CreateWebhookRequest{ URL: "https://example.com/webhook", Events: []string{}, }, wantStatus: http.StatusBadRequest, }, { name: "invalid event type", projectID: "proj-1", body: CreateWebhookRequest{ URL: "https://example.com/webhook", Events: []string{"invalid.event"}, }, wantStatus: http.StatusBadRequest, }, { name: "project not found", projectID: "unknown", body: CreateWebhookRequest{ URL: "https://example.com/webhook", Events: []string{"command.started"}, }, wantStatus: http.StatusNotFound, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { webhookRepo := &mockWebhookRepository{} h := NewWebhookHandler(webhookRepo, projectRepo) r := chi.NewRouter() r.Post("/projects/{id}/webhooks/", h.Create) body, _ := json.Marshal(tt.body) req := httptest.NewRequest(http.MethodPost, "/projects/"+tt.projectID+"/webhooks/", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != tt.wantStatus { t.Errorf("Create() status = %d, want %d, body: %s", w.Code, tt.wantStatus, w.Body.String()) } if tt.wantStatus == http.StatusCreated { var resp struct { Data CreateWebhookResponse `json:"data"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if resp.Data.Secret == "" { t.Error("Secret should be returned on creation") } } }) } } func TestWebhookHandler_List(t *testing.T) { projectRepo := newMockProjectRepo() projectRepo.Register(context.Background(), &domain.Project{ID: "proj-1", Name: "Test Project"}) webhookRepo := &mockWebhookRepository{ webhooks: []*domain.Webhook{ { ID: "wh-1", ProjectID: "proj-1", URL: "https://example.com/webhook1", Events: []domain.WebhookEventType{domain.WebhookEventCommandStarted}, Enabled: true, CreatedAt: time.Now(), UpdatedAt: time.Now(), }, { ID: "wh-2", ProjectID: "proj-1", URL: "https://example.com/webhook2", Events: []domain.WebhookEventType{domain.WebhookEventCommandCompleted}, Enabled: false, CreatedAt: time.Now(), UpdatedAt: time.Now(), }, }, } tests := []struct { name string projectID string wantStatus int wantCount int }{ { name: "list webhooks", projectID: "proj-1", wantStatus: http.StatusOK, wantCount: 2, }, { name: "project not found", projectID: "unknown", wantStatus: http.StatusNotFound, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { h := NewWebhookHandler(webhookRepo, projectRepo) r := chi.NewRouter() r.Get("/projects/{id}/webhooks/", h.List) req := httptest.NewRequest(http.MethodGet, "/projects/"+tt.projectID+"/webhooks/", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != tt.wantStatus { t.Errorf("List() status = %d, want %d", w.Code, tt.wantStatus) } if tt.wantStatus == http.StatusOK { var resp struct { Data struct { Webhooks []*WebhookDTO `json:"webhooks"` Total int `json:"total"` } `json:"data"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if resp.Data.Total != tt.wantCount { t.Errorf("List() count = %d, want %d", resp.Data.Total, tt.wantCount) } } }) } } func TestWebhookHandler_Get(t *testing.T) { projectRepo := newMockProjectRepo() projectRepo.Register(context.Background(), &domain.Project{ID: "proj-1", Name: "Test Project"}) webhookRepo := &mockWebhookRepository{ webhooks: []*domain.Webhook{ { ID: "wh-123", ProjectID: "proj-1", URL: "https://example.com/webhook", Events: []domain.WebhookEventType{domain.WebhookEventCommandStarted}, Enabled: true, CreatedAt: time.Now(), UpdatedAt: time.Now(), }, }, } tests := []struct { name string projectID string webhookID string wantStatus int }{ { name: "existing webhook", projectID: "proj-1", webhookID: "wh-123", wantStatus: http.StatusOK, }, { name: "webhook not found", projectID: "proj-1", webhookID: "wh-unknown", wantStatus: http.StatusNotFound, }, { name: "project not found", projectID: "unknown", webhookID: "wh-123", wantStatus: http.StatusNotFound, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { h := NewWebhookHandler(webhookRepo, projectRepo) r := chi.NewRouter() r.Get("/projects/{id}/webhooks/{webhookId}", h.Get) req := httptest.NewRequest(http.MethodGet, "/projects/"+tt.projectID+"/webhooks/"+tt.webhookID, nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != tt.wantStatus { t.Errorf("Get() status = %d, want %d", w.Code, tt.wantStatus) } }) } } func TestWebhookHandler_Update(t *testing.T) { projectRepo := newMockProjectRepo() projectRepo.Register(context.Background(), &domain.Project{ID: "proj-1", Name: "Test Project"}) tests := []struct { name string projectID string webhookID string body UpdateWebhookRequest wantStatus int }{ { name: "update url", projectID: "proj-1", webhookID: "wh-123", body: UpdateWebhookRequest{ URL: "https://new-url.com/webhook", }, wantStatus: http.StatusOK, }, { name: "disable webhook", projectID: "proj-1", webhookID: "wh-123", body: UpdateWebhookRequest{ Enabled: boolPtr(false), }, wantStatus: http.StatusOK, }, { name: "invalid url", projectID: "proj-1", webhookID: "wh-123", body: UpdateWebhookRequest{ URL: "not-a-url", }, wantStatus: http.StatusBadRequest, }, { name: "webhook not found", projectID: "proj-1", webhookID: "wh-unknown", body: UpdateWebhookRequest{ URL: "https://example.com", }, wantStatus: http.StatusNotFound, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { webhookRepo := &mockWebhookRepository{ webhooks: []*domain.Webhook{ { ID: "wh-123", ProjectID: "proj-1", URL: "https://example.com/webhook", Events: []domain.WebhookEventType{domain.WebhookEventCommandStarted}, Enabled: true, CreatedAt: time.Now(), UpdatedAt: time.Now(), }, }, } h := NewWebhookHandler(webhookRepo, projectRepo) r := chi.NewRouter() r.Put("/projects/{id}/webhooks/{webhookId}", h.Update) body, _ := json.Marshal(tt.body) req := httptest.NewRequest(http.MethodPut, "/projects/"+tt.projectID+"/webhooks/"+tt.webhookID, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != tt.wantStatus { t.Errorf("Update() status = %d, want %d, body: %s", w.Code, tt.wantStatus, w.Body.String()) } }) } } func TestWebhookHandler_Delete(t *testing.T) { projectRepo := newMockProjectRepo() projectRepo.Register(context.Background(), &domain.Project{ID: "proj-1", Name: "Test Project"}) tests := []struct { name string projectID string webhookID string wantStatus int }{ { name: "delete existing webhook", projectID: "proj-1", webhookID: "wh-123", wantStatus: http.StatusOK, }, { name: "webhook not found", projectID: "proj-1", webhookID: "wh-unknown", wantStatus: http.StatusNotFound, }, { name: "project not found", projectID: "unknown", webhookID: "wh-123", wantStatus: http.StatusNotFound, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { webhookRepo := &mockWebhookRepository{ webhooks: []*domain.Webhook{ { ID: "wh-123", ProjectID: "proj-1", URL: "https://example.com/webhook", Events: []domain.WebhookEventType{domain.WebhookEventCommandStarted}, Enabled: true, CreatedAt: time.Now(), UpdatedAt: time.Now(), }, }, } h := NewWebhookHandler(webhookRepo, projectRepo) r := chi.NewRouter() r.Delete("/projects/{id}/webhooks/{webhookId}", h.Delete) req := httptest.NewRequest(http.MethodDelete, "/projects/"+tt.projectID+"/webhooks/"+tt.webhookID, nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != tt.wantStatus { t.Errorf("Delete() status = %d, want %d", w.Code, tt.wantStatus) } }) } } func TestWebhookHandler_GetDeliveries(t *testing.T) { projectRepo := newMockProjectRepo() projectRepo.Register(context.Background(), &domain.Project{ID: "proj-1", Name: "Test Project"}) webhookRepo := &mockWebhookRepository{ webhooks: []*domain.Webhook{ { ID: "wh-123", ProjectID: "proj-1", URL: "https://example.com/webhook", Events: []domain.WebhookEventType{domain.WebhookEventCommandStarted}, Enabled: true, CreatedAt: time.Now(), UpdatedAt: time.Now(), }, }, deliveries: []*domain.WebhookDelivery{ { ID: "del-1", WebhookID: "wh-123", EventType: domain.WebhookEventCommandStarted, Payload: `{"test": true}`, ResponseStatus: 200, Success: true, DeliveredAt: time.Now(), }, }, } tests := []struct { name string projectID string webhookID string query string wantStatus int }{ { name: "get deliveries", projectID: "proj-1", webhookID: "wh-123", query: "", wantStatus: http.StatusOK, }, { name: "with filters", projectID: "proj-1", webhookID: "wh-123", query: "?success=true&limit=10", wantStatus: http.StatusOK, }, { name: "webhook not found", projectID: "proj-1", webhookID: "wh-unknown", wantStatus: http.StatusNotFound, }, { name: "project not found", projectID: "unknown", webhookID: "wh-123", wantStatus: http.StatusNotFound, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { h := NewWebhookHandler(webhookRepo, projectRepo) r := chi.NewRouter() r.Get("/projects/{id}/webhooks/{webhookId}/deliveries", h.GetDeliveries) req := httptest.NewRequest(http.MethodGet, "/projects/"+tt.projectID+"/webhooks/"+tt.webhookID+"/deliveries"+tt.query, nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != tt.wantStatus { t.Errorf("GetDeliveries() status = %d, want %d", w.Code, tt.wantStatus) } }) } } func boolPtr(b bool) *bool { return &b }