Files
kandev/apps/backend/internal/automation/github_webhook_subscriber_test.go
T

428 lines
13 KiB
Go

package automation
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/github"
)
var errFlakySubscribe = errors.New("flaky subscribe failure")
func newTestSubscriber(t *testing.T, svc *Service) *GitHubWebhookSubscriber {
t.Helper()
log, err := logger.NewFromZap(zap.NewNop())
if err != nil {
t.Fatal(err)
}
sub := NewGitHubWebhookSubscriber(svc, svc.eventBus, log)
sub.Start(context.Background())
t.Cleanup(sub.Stop)
return sub
}
func createTestAutomation(t *testing.T, svc *Service, workspaceID string) *Automation {
t.Helper()
a, err := svc.CreateAutomation(context.Background(), &CreateAutomationRequest{
WorkspaceID: workspaceID,
Name: "test automation",
AgentProfileID: "agent-1",
ExecutorProfileID: "exec-1",
})
if err != nil {
t.Fatalf("CreateAutomation() error = %v", err)
}
return a
}
func addTestTrigger(t *testing.T, svc *Service, automationID string, triggerType TriggerType, cfg interface{}) *AutomationTrigger {
t.Helper()
raw, err := json.Marshal(cfg)
if err != nil {
t.Fatal(err)
}
trig, err := svc.AddTrigger(context.Background(), &AddTriggerRequest{
AutomationID: automationID,
Type: triggerType,
Config: raw,
Enabled: true,
})
if err != nil {
t.Fatalf("AddTrigger() error = %v", err)
}
return trig
}
func subscribeAutomationTriggered(t *testing.T, eventBus bus.EventBus) chan *AutomationTriggeredEvent {
t.Helper()
ch := make(chan *AutomationTriggeredEvent, 4)
if _, err := eventBus.Subscribe(events.AutomationTriggered, func(_ context.Context, e *bus.Event) error {
if evt, ok := e.Data.(*AutomationTriggeredEvent); ok {
ch <- evt
}
return nil
}); err != nil {
t.Fatal(err)
}
return ch
}
func TestGitHubWebhookSubscriberFiresCheckRunTrigger(t *testing.T) {
svc := newTestService(t)
newTestSubscriber(t, svc)
fired := subscribeAutomationTriggered(t, svc.eventBus)
a := createTestAutomation(t, svc, "ws-1")
trig := addTestTrigger(t, svc, a.ID, TriggerTypeGitHubCI, GitHubCITriggerConfig{
Repos: []github.RepoFilter{{Owner: "acme", Name: "repo"}},
Conclusions: []string{"failure"},
})
if err := svc.eventBus.Publish(context.Background(), events.GitHubCheckRunCompleted, bus.NewEvent(
events.GitHubCheckRunCompleted, "test", &github.GitHubCheckRunEventPayload{
WorkspaceIDs: []string{"ws-1"},
Owner: "acme",
Name: "repo",
Branch: "main",
SHA: "abc1234",
CheckName: "build",
Conclusion: "failure",
CheckRunID: 555,
HTMLURL: "https://github.com/acme/repo/runs/555",
},
)); err != nil {
t.Fatal(err)
}
select {
case evt := <-fired:
if evt.AutomationID != a.ID || evt.TriggerID != trig.ID || evt.TriggerType != TriggerTypeGitHubCI {
t.Fatalf("evt = %+v", evt)
}
if evt.DedupKey != "ci:acme/repo#555" {
t.Fatalf("dedup key = %q", evt.DedupKey)
}
// The {{ci.url}} placeholder resolves from data["html_url"]; assert the
// subscriber populated it so the default CI prompt doesn't render empty.
var data map[string]interface{}
if err := json.Unmarshal(evt.TriggerData, &data); err != nil {
t.Fatal(err)
}
if data["html_url"] != "https://github.com/acme/repo/runs/555" {
t.Fatalf("expected html_url in trigger data, got %+v", data)
}
default:
t.Fatal("expected AutomationTriggered to fire")
}
}
func TestGitHubWebhookSubscriberCheckRunNegativeCases(t *testing.T) {
base := github.GitHubCheckRunEventPayload{
WorkspaceIDs: []string{"ws-1"},
Owner: "acme",
Name: "repo",
Branch: "main",
SHA: "abc1234",
CheckName: "build",
Conclusion: "failure",
CheckRunID: 555,
}
tests := []struct {
name string
mutate func(p *github.GitHubCheckRunEventPayload)
}{
{"wrong workspace", func(p *github.GitHubCheckRunEventPayload) { p.WorkspaceIDs = []string{"ws-other"} }},
{"wrong repo", func(p *github.GitHubCheckRunEventPayload) { p.Name = "other-repo" }},
{"wrong conclusion", func(p *github.GitHubCheckRunEventPayload) { p.Conclusion = "success" }},
{"wrong check name", func(p *github.GitHubCheckRunEventPayload) {}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := newTestService(t)
newTestSubscriber(t, svc)
fired := subscribeAutomationTriggered(t, svc.eventBus)
a := createTestAutomation(t, svc, "ws-1")
cfg := GitHubCITriggerConfig{
Repos: []github.RepoFilter{{Owner: "acme", Name: "repo"}},
Conclusions: []string{"failure"},
}
if tt.name == "wrong check name" {
cfg.CheckNames = []string{"lint"}
}
addTestTrigger(t, svc, a.ID, TriggerTypeGitHubCI, cfg)
payload := base
tt.mutate(&payload)
if err := svc.eventBus.Publish(context.Background(), events.GitHubCheckRunCompleted, bus.NewEvent(
events.GitHubCheckRunCompleted, "test", &payload,
)); err != nil {
t.Fatal(err)
}
select {
case evt := <-fired:
t.Fatalf("expected no fire, got %+v", evt)
default:
}
})
}
}
func TestGitHubWebhookSubscriberPushTriggerBranchMatch(t *testing.T) {
svc := newTestService(t)
newTestSubscriber(t, svc)
fired := subscribeAutomationTriggered(t, svc.eventBus)
a := createTestAutomation(t, svc, "ws-1")
trig := addTestTrigger(t, svc, a.ID, TriggerTypeGitHubPush, GitHubPushTriggerConfig{
Repos: []github.RepoFilter{{Owner: "acme", Name: "repo"}},
Branches: []string{"main"},
})
publishPush := func(branch, sha string) {
if err := svc.eventBus.Publish(context.Background(), events.GitHubPushReceived, bus.NewEvent(
events.GitHubPushReceived, "test", &github.GitHubPushEventPayload{
WorkspaceIDs: []string{"ws-1"},
Owner: "acme",
Name: "repo",
Branch: branch,
SHA: sha,
PusherLogin: "alice",
HeadCommitMsg: "fix: bug",
},
)); err != nil {
t.Fatal(err)
}
}
// Non-matching branch: no fire.
publishPush("develop", "sha-1")
select {
case evt := <-fired:
t.Fatalf("expected no fire for non-matching branch, got %+v", evt)
default:
}
// Matching branch: fires.
publishPush("main", "sha-2")
var dedupKey string
select {
case evt := <-fired:
if evt.AutomationID != a.ID || evt.TriggerID != trig.ID {
t.Fatalf("evt = %+v", evt)
}
if evt.DedupKey != "push:acme/repo@main@sha-2" {
t.Fatalf("dedup key = %q", evt.DedupKey)
}
// {{push.message}} resolves from data["message"]; assert it's populated.
var data map[string]interface{}
if err := json.Unmarshal(evt.TriggerData, &data); err != nil {
t.Fatal(err)
}
if data["message"] != "fix: bug" {
t.Fatalf("expected message in trigger data, got %+v", data)
}
dedupKey = evt.DedupKey
default:
t.Fatal("expected AutomationTriggered to fire for matching branch")
}
// A real deployment records a run row for the fired trigger (the
// orchestrator does this on AutomationTriggered); do so here so the
// dedup check in FireTrigger has something to find.
if err := svc.RecordRun(context.Background(), &AutomationRun{
AutomationID: a.ID, TriggerID: trig.ID, TriggerType: TriggerTypeGitHubPush,
Status: RunStatusTaskCreated, DedupKey: dedupKey,
}); err != nil {
t.Fatal(err)
}
// Same SHA again: dedup key prevents double-fire.
publishPush("main", "sha-2")
select {
case evt := <-fired:
t.Fatalf("expected dedup to suppress second fire, got %+v", evt)
default:
}
}
// TestGitHubWebhookSubscriberPushDedupIsPerBranch pins that the same commit SHA
// pushed to two different (matching) branches fires once per branch, while a
// duplicate delivery on one branch stays suppressed.
func TestGitHubWebhookSubscriberPushDedupIsPerBranch(t *testing.T) {
svc := newTestService(t)
newTestSubscriber(t, svc)
fired := subscribeAutomationTriggered(t, svc.eventBus)
a := createTestAutomation(t, svc, "ws-1")
trig := addTestTrigger(t, svc, a.ID, TriggerTypeGitHubPush, GitHubPushTriggerConfig{
Repos: []github.RepoFilter{{Owner: "acme", Name: "repo"}},
Branches: []string{"main", "develop"},
})
publishPush := func(branch, sha string) {
if err := svc.eventBus.Publish(context.Background(), events.GitHubPushReceived, bus.NewEvent(
events.GitHubPushReceived, "test", &github.GitHubPushEventPayload{
WorkspaceIDs: []string{"ws-1"}, Owner: "acme", Name: "repo",
Branch: branch, SHA: sha,
},
)); err != nil {
t.Fatal(err)
}
}
recordFired := func() {
select {
case evt := <-fired:
// Record as a terminal (succeeded) run: it still carries the dedup
// key for same-key suppression, but doesn't count toward the
// concurrency cap, so a distinct-branch push can still fire.
if err := svc.RecordRun(context.Background(), &AutomationRun{
AutomationID: a.ID, TriggerID: trig.ID, TriggerType: TriggerTypeGitHubPush,
Status: RunStatusSucceeded, DedupKey: evt.DedupKey,
}); err != nil {
t.Fatal(err)
}
default:
t.Fatal("expected AutomationTriggered to fire")
}
}
// Same SHA on two branches: each fires (distinct dedup keys).
publishPush("main", "shaX")
recordFired()
publishPush("develop", "shaX")
recordFired()
// Duplicate delivery on the same branch is suppressed.
publishPush("main", "shaX")
select {
case evt := <-fired:
t.Fatalf("expected same-branch duplicate to be suppressed, got %+v", evt)
default:
}
}
// TestGitHubWebhookSubscriberCITriggerBranchFilter pins that a CI trigger scoped
// to a branch only fires for check runs on that branch.
func TestGitHubWebhookSubscriberCITriggerBranchFilter(t *testing.T) {
svc := newTestService(t)
newTestSubscriber(t, svc)
fired := subscribeAutomationTriggered(t, svc.eventBus)
a := createTestAutomation(t, svc, "ws-1")
addTestTrigger(t, svc, a.ID, TriggerTypeGitHubCI, GitHubCITriggerConfig{
Repos: []github.RepoFilter{{Owner: "acme", Name: "repo"}},
Conclusions: []string{"failure"},
Branches: []string{"main"},
})
publishCI := func(branch string, runID int64) {
if err := svc.eventBus.Publish(context.Background(), events.GitHubCheckRunCompleted, bus.NewEvent(
events.GitHubCheckRunCompleted, "test", &github.GitHubCheckRunEventPayload{
WorkspaceIDs: []string{"ws-1"}, Owner: "acme", Name: "repo",
Branch: branch, SHA: "abc", CheckName: "build", Conclusion: "failure", CheckRunID: runID,
},
)); err != nil {
t.Fatal(err)
}
}
// Wrong branch: no fire.
publishCI("develop", 1)
select {
case evt := <-fired:
t.Fatalf("expected no fire for non-matching CI branch, got %+v", evt)
default:
}
// Matching branch: fires.
publishCI("main", 2)
select {
case <-fired:
default:
t.Fatal("expected CI trigger to fire for matching branch")
}
}
func TestGitHubWebhookSubscriberStopDrainsCleanly(t *testing.T) {
svc := newTestService(t)
sub := newTestSubscriber(t, svc)
sub.Stop()
// Calling Stop twice (once here, once via t.Cleanup) must be safe.
}
// flakySubscribeBus is a minimal bus.EventBus whose Subscribe fails on the
// call whose 1-based index equals failCall (0 = never), used to exercise the
// subscriber's retryable-Start contract. It tracks the count of live
// subscriptions so a test can assert partial subscriptions are rolled back.
type flakySubscribeBus struct {
subscribeCalls int
failCall int
activeCount int
}
type flakySub struct{ bus *flakySubscribeBus }
func (s *flakySub) Unsubscribe() error { s.bus.activeCount--; return nil }
func (s *flakySub) IsValid() bool { return true }
func (b *flakySubscribeBus) Subscribe(string, bus.EventHandler) (bus.Subscription, error) {
b.subscribeCalls++
if b.subscribeCalls == b.failCall {
return nil, errFlakySubscribe
}
b.activeCount++
return &flakySub{bus: b}, nil
}
func (b *flakySubscribeBus) Publish(context.Context, string, *bus.Event) error { return nil }
func (b *flakySubscribeBus) QueueSubscribe(string, string, bus.EventHandler) (bus.Subscription, error) {
return nil, nil
}
func (b *flakySubscribeBus) Request(context.Context, string, *bus.Event, time.Duration) (*bus.Event, error) {
return nil, nil
}
func (b *flakySubscribeBus) Close() {}
func (b *flakySubscribeBus) IsConnected() bool { return true }
// TestGitHubWebhookSubscriberStartRetryableAfterFailure pins the retry
// contract: a failed subscription leaves the subscriber un-started with any
// partial subscription rolled back, and a later Start re-attempts and succeeds.
func TestGitHubWebhookSubscriberStartRetryableAfterFailure(t *testing.T) {
svc := newTestService(t)
fb := &flakySubscribeBus{failCall: 2} // first Subscribe ok, second fails
log, _ := logger.NewFromZap(zap.NewNop())
sub := NewGitHubWebhookSubscriber(svc, fb, log)
t.Cleanup(sub.Stop)
sub.Start(context.Background())
if sub.started {
t.Fatal("expected subscriber to remain un-started after a subscription failure")
}
if fb.activeCount != 0 {
t.Fatalf("expected the partial subscription to be rolled back, active=%d", fb.activeCount)
}
// Bus recovers; a later Start must re-attempt and succeed.
fb.failCall = 0
sub.Start(context.Background())
if !sub.started {
t.Fatal("expected subscriber to be started after a successful retry")
}
if fb.activeCount != 2 {
t.Fatalf("expected 2 live subscriptions after retry, active=%d", fb.activeCount)
}
}