This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / appview / notify / webhook / notifier_test.go
9.5 kB 333 lines
1package webhook 2 3import ( 4 "context" 5 "net/http" 6 "net/http/httptest" 7 "path/filepath" 8 "testing" 9 "time" 10 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 "tangled.org/core/appview/db" 13 "tangled.org/core/appview/models" 14) 15 16func TestPullStateEvent(t *testing.T) { 17 tests := []struct { 18 name string 19 state models.PullState 20 wantEvent models.WebhookEvent 21 wantAction string 22 wantOk bool 23 }{ 24 {"merged", models.PullMerged, models.WebhookEventPullRequestMerged, "merged", true}, 25 {"closed", models.PullClosed, models.WebhookEventPullRequestClosed, "closed", true}, 26 {"reopened", models.PullOpen, models.WebhookEventPullRequestReopened, "reopened", true}, 27 {"abandoned", models.PullAbandoned, "", "", false}, 28 } 29 30 for _, tt := range tests { 31 t.Run(tt.name, func(t *testing.T) { 32 event, action, ok := pullStateEvent(tt.state) 33 if event != tt.wantEvent || action != tt.wantAction || ok != tt.wantOk { 34 t.Errorf("pullStateEvent(%v) = (%q, %q, %v), want (%q, %q, %v)", 35 tt.state, event, action, ok, tt.wantEvent, tt.wantAction, tt.wantOk) 36 } 37 }) 38 } 39} 40 41func TestBuildPullRequestPayload(t *testing.T) { 42 const baseUrl = "https://tangled.org" 43 44 targetDid := syntax.DID("did:plc:target") 45 forkDid := syntax.DID("did:plc:fork") 46 47 repo := &models.Repo{ 48 Did: "did:plc:target", 49 Name: "some-repo", 50 Knot: "knot.example.com", 51 Rkey: "some-repo", 52 Created: time.Date(2025, 9, 15, 8, 57, 23, 0, time.UTC), 53 } 54 55 basePull := func() models.Pull { 56 return models.Pull{ 57 PullId: 4, 58 RepoDid: targetDid, 59 OwnerDid: "did:plc:author", 60 Title: "add dark mode", 61 Body: "implements dark mode", 62 TargetBranch: "main", 63 State: models.PullOpen, 64 Created: time.Date(2025, 9, 16, 10, 0, 0, 0, time.UTC), 65 Submissions: []*models.PullSubmission{ 66 {RoundNumber: 0, SourceRev: "aaaa000"}, 67 {RoundNumber: 1, SourceRev: "bbbb111"}, 68 }, 69 } 70 } 71 72 t.Run("patch based", func(t *testing.T) { 73 pull := basePull() 74 pull.PullSource = nil 75 76 payload := buildPullRequestPayload("created", repo, &pull, "did:plc:author", baseUrl) 77 78 if payload.Action != "created" { 79 t.Errorf("action = %q, want %q", payload.Action, "created") 80 } 81 pr := payload.PullRequest 82 if pr.Number != 4 { 83 t.Errorf("number = %d, want 4", pr.Number) 84 } 85 if pr.State != "open" { 86 t.Errorf("state = %q, want %q", pr.State, "open") 87 } 88 if pr.Source != nil { 89 t.Errorf("source = %+v, want nil for patch-based pull", pr.Source) 90 } 91 if pr.RoundNumber != 1 { 92 t.Errorf("round_number = %d, want 1", pr.RoundNumber) 93 } 94 wantHtmlUrl := "https://tangled.org/did:plc:target/some-repo/pulls/4" 95 if pr.HtmlUrl != wantHtmlUrl { 96 t.Errorf("html_url = %q, want %q", pr.HtmlUrl, wantHtmlUrl) 97 } 98 wantPatchUrl := wantHtmlUrl + "/round/1.patch" 99 if pr.PatchUrl != wantPatchUrl { 100 t.Errorf("patch_url = %q, want %q", pr.PatchUrl, wantPatchUrl) 101 } 102 if pr.Owner.Did != "did:plc:author" { 103 t.Errorf("owner.did = %q, want %q", pr.Owner.Did, "did:plc:author") 104 } 105 if payload.Sender.Did != "did:plc:author" { 106 t.Errorf("sender.did = %q, want %q", payload.Sender.Did, "did:plc:author") 107 } 108 if payload.Repository.FullName != "did:plc:target/some-repo" { 109 t.Errorf("repository.full_name = %q, want %q", payload.Repository.FullName, "did:plc:target/some-repo") 110 } 111 }) 112 113 t.Run("branch based", func(t *testing.T) { 114 pull := basePull() 115 pull.PullSource = &models.PullSource{ 116 Branch: "dark-mode", 117 RepoDid: &targetDid, 118 } 119 120 payload := buildPullRequestPayload("merged", repo, &pull, "did:plc:merger", baseUrl) 121 122 pr := payload.PullRequest 123 if pr.Source == nil { 124 t.Fatal("source = nil, want non-nil for branch-based pull") 125 } 126 if pr.Source.Branch != "dark-mode" { 127 t.Errorf("source.branch = %q, want %q", pr.Source.Branch, "dark-mode") 128 } 129 if pr.Source.Repo != "" { 130 t.Errorf("source.repo = %q, want empty for branch-based pull", pr.Source.Repo) 131 } 132 if pr.Source.Sha != "bbbb111" { 133 t.Errorf("source.sha = %q, want %q", pr.Source.Sha, "bbbb111") 134 } 135 if payload.Sender.Did != "did:plc:merger" { 136 t.Errorf("sender.did = %q, want %q", payload.Sender.Did, "did:plc:merger") 137 } 138 }) 139 140 t.Run("fork based", func(t *testing.T) { 141 pull := basePull() 142 pull.PullSource = &models.PullSource{ 143 Branch: "dark-mode", 144 RepoDid: &forkDid, 145 } 146 147 payload := buildPullRequestPayload("created", repo, &pull, "did:plc:author", baseUrl) 148 149 pr := payload.PullRequest 150 if pr.Source == nil { 151 t.Fatal("source = nil, want non-nil for fork-based pull") 152 } 153 if pr.Source.Repo != "did:plc:fork" { 154 t.Errorf("source.repo = %q, want %q", pr.Source.Repo, "did:plc:fork") 155 } 156 }) 157 158 t.Run("no submissions", func(t *testing.T) { 159 pull := basePull() 160 pull.Submissions = nil 161 162 payload := buildPullRequestPayload("created", repo, &pull, "did:plc:author", baseUrl) 163 164 pr := payload.PullRequest 165 if pr.RoundNumber != 0 { 166 t.Errorf("round_number = %d, want 0", pr.RoundNumber) 167 } 168 if pr.PatchUrl != "" { 169 t.Errorf("patch_url = %q, want empty when there are no submissions", pr.PatchUrl) 170 } 171 }) 172} 173 174type notifierTestEnv struct { 175 notifier *Notifier 176 webhook *models.Webhook 177 db *db.DB 178 received chan string 179} 180 181// newNotifierTestEnv sets up a real sqlite db with a repo and a webhook 182// subscribed to the given events, delivering to a local test server 183func newNotifierTestEnv(t *testing.T, events []string) *notifierTestEnv { 184 t.Helper() 185 186 d, err := db.Make(context.Background(), filepath.Join(t.TempDir(), "test.db")) 187 if err != nil { 188 t.Fatalf("Make: %v", err) 189 } 190 t.Cleanup(func() { d.Close() }) 191 192 tx, err := d.Begin() 193 if err != nil { 194 t.Fatalf("Begin: %v", err) 195 } 196 if err := db.AddRepo(tx, &models.Repo{ 197 Did: "did:plc:owner", 198 Name: "some-repo", 199 Knot: "knot.example.com", 200 Rkey: "some-repo", 201 RepoDid: "did:plc:repo1", 202 }); err != nil { 203 t.Fatalf("AddRepo: %v", err) 204 } 205 if err := tx.Commit(); err != nil { 206 t.Fatalf("Commit: %v", err) 207 } 208 209 received := make(chan string, 1) 210 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 211 received <- r.Header.Get("X-Tangled-Event") 212 w.WriteHeader(http.StatusOK) 213 })) 214 t.Cleanup(srv.Close) 215 216 webhook := &models.Webhook{ 217 RepoDid: syntax.DID("did:plc:repo1"), 218 Url: srv.URL, 219 Active: true, 220 Events: events, 221 } 222 if err := db.AddWebhook(d, webhook); err != nil { 223 t.Fatalf("AddWebhook: %v", err) 224 } 225 226 return &notifierTestEnv{ 227 notifier: NewNotifier(d, "https://tangled.org", true), 228 webhook: webhook, 229 db: d, 230 received: received, 231 } 232} 233 234func (env *notifierTestEnv) awaitDelivery(t *testing.T, wantEvent string) { 235 t.Helper() 236 237 select { 238 case event := <-env.received: 239 if event != wantEvent { 240 t.Errorf("X-Tangled-Event = %q, want %q", event, wantEvent) 241 } 242 case <-time.After(10 * time.Second): 243 t.Fatalf("webhook %s not delivered", wantEvent) 244 } 245 246 // wait for the delivery record so the sender goroutine finishes 247 // before the db is closed 248 deadline := time.Now().Add(10 * time.Second) 249 for { 250 deliveries, err := db.GetWebhookDeliveries(env.db, env.webhook.Id, 10) 251 if err == nil && len(deliveries) > 0 { 252 if !deliveries[0].Success { 253 t.Errorf("delivery recorded as failed, want success") 254 } 255 return 256 } 257 if time.Now().After(deadline) { 258 t.Fatal("delivery record not written") 259 } 260 time.Sleep(10 * time.Millisecond) 261 } 262} 263 264func testPull(state models.PullState) *models.Pull { 265 return &models.Pull{ 266 PullId: 1, 267 RepoDid: syntax.DID("did:plc:repo1"), 268 OwnerDid: "did:plc:author", 269 Title: "hello", 270 TargetBranch: "main", 271 State: state, 272 Created: time.Now(), 273 } 274} 275 276// Pull request events fire from http handlers, whose request context is 277// canceled as soon as the handler returns. Deliveries run in background 278// goroutines and must not be cut short by that cancellation. 279func TestPullRequestEventDeliversAfterContextCancel(t *testing.T) { 280 env := newNotifierTestEnv(t, []string{string(models.WebhookEventPullRequestCreated)}) 281 282 ctx, cancel := context.WithCancel(context.Background()) 283 cancel() 284 285 env.notifier.NewPull(ctx, testPull(models.PullOpen)) 286 env.awaitDelivery(t, "pull_request:created") 287} 288 289func TestNotifierDeliversPullRequestEvents(t *testing.T) { 290 allEvents := []string{ 291 string(models.WebhookEventPullRequestCreated), 292 string(models.WebhookEventPullRequestResubmitted), 293 string(models.WebhookEventPullRequestMerged), 294 string(models.WebhookEventPullRequestClosed), 295 string(models.WebhookEventPullRequestReopened), 296 } 297 actor := syntax.DID("did:plc:actor") 298 299 tests := []struct { 300 name string 301 notify func(*Notifier, context.Context) 302 wantEvent string 303 }{ 304 { 305 "resubmitted", 306 func(n *Notifier, ctx context.Context) { n.ResubmitPull(ctx, testPull(models.PullOpen)) }, 307 "pull_request:resubmitted", 308 }, 309 { 310 "merged", 311 func(n *Notifier, ctx context.Context) { n.NewPullState(ctx, actor, testPull(models.PullMerged)) }, 312 "pull_request:merged", 313 }, 314 { 315 "closed", 316 func(n *Notifier, ctx context.Context) { n.NewPullState(ctx, actor, testPull(models.PullClosed)) }, 317 "pull_request:closed", 318 }, 319 { 320 "reopened", 321 func(n *Notifier, ctx context.Context) { n.NewPullState(ctx, actor, testPull(models.PullOpen)) }, 322 "pull_request:reopened", 323 }, 324 } 325 326 for _, tt := range tests { 327 t.Run(tt.name, func(t *testing.T) { 328 env := newNotifierTestEnv(t, allEvents) 329 tt.notify(env.notifier, context.Background()) 330 env.awaitDelivery(t, tt.wantEvent) 331 }) 332 } 333}