This repository has no description
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 ¬ifierTestEnv{
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}