Skip to content

Commit 00e2c8e

Browse files
committed
Refactor webhook sender to add manager/preparer interfaces
- Split `sender.go` into orchestrator logic and Slack-specific implementation (`slack.go`) - Introduce `Manager` and `Preparer` interfaces to decouple specific platform logic
1 parent c769fdf commit 00e2c8e

3 files changed

Lines changed: 288 additions & 81 deletions

File tree

workers/webhook/pkg/webhook/sender.go

Lines changed: 12 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,10 @@
1515
package webhook
1616

1717
import (
18-
"bytes"
1918
"context"
20-
"encoding/json"
21-
"errors"
2219
"fmt"
2320
"log/slog"
2421
"net/http"
25-
"net/url"
2622
"time"
2723

2824
"github.com/GoogleChrome/webstatus.dev/lib/workertypes"
@@ -52,97 +48,36 @@ func NewSender(httpClient HTTPClient, stateManager ChannelStateManager, frontend
5248
}
5349
}
5450

55-
type SlackPayload struct {
56-
Text string `json:"text"`
51+
type Manager interface {
52+
Send(ctx context.Context) error
5753
}
5854

59-
type webhookPreparer interface {
60-
Prepare(ctx context.Context, job workertypes.IncomingWebhookDeliveryJob) (*http.Request, error)
61-
}
62-
63-
type slackPreparer struct {
64-
frontendBaseURL string
65-
}
66-
67-
func (s *slackPreparer) Prepare(
68-
ctx context.Context, job workertypes.IncomingWebhookDeliveryJob) (*http.Request, error) {
69-
parsedURL, err := url.Parse(job.WebhookURL)
70-
if err != nil || parsedURL.Scheme != "https" || parsedURL.Host != "hooks.slack.com" {
71-
// Record permanent failure due to invalid URL
72-
return nil, fmt.Errorf("invalid webhook URL: %s", job.WebhookURL)
73-
}
74-
75-
var summary workertypes.EventSummary
76-
if err := json.Unmarshal(job.SummaryRaw, &summary); err != nil {
77-
return nil, fmt.Errorf("failed to unmarshal summary: %w", err)
78-
}
79-
80-
resultsURL := fmt.Sprintf("%s/features?q=%s", s.frontendBaseURL, url.QueryEscape(job.Metadata.Query))
81-
82-
payload := SlackPayload{
83-
Text: fmt.Sprintf("WebStatus.dev Notification: %s\nQuery: %s\nView Results: %s",
84-
summary.Text, job.Metadata.Query, resultsURL),
85-
}
86-
87-
payloadBytes, err := json.Marshal(payload)
88-
if err != nil {
89-
return nil, fmt.Errorf("failed to marshal slack payload: %w", err)
90-
}
91-
92-
req, err := http.NewRequestWithContext(ctx, http.MethodPost, job.WebhookURL, bytes.NewBuffer(payloadBytes))
93-
if err != nil {
94-
return nil, fmt.Errorf("failed to create request: %w", err)
95-
}
96-
req.Header.Set("Content-Type", "application/json")
97-
98-
return req, nil
55+
type Preparer interface {
56+
Prepare(job workertypes.IncomingWebhookDeliveryJob) (Manager, error)
9957
}
10058

10159
func (s *Sender) SendWebhook(ctx context.Context, job workertypes.IncomingWebhookDeliveryJob) error {
10260
slog.InfoContext(ctx, "sending webhook", "channelID", job.ChannelID, "url", job.WebhookURL)
10361

104-
var preparer webhookPreparer
62+
var preparer Preparer
10563
switch job.WebhookType {
10664
case workertypes.WebhookTypeSlack:
107-
preparer = &slackPreparer{frontendBaseURL: s.frontendBaseURL}
65+
preparer = &slackPreparer{
66+
frontendBaseURL: s.frontendBaseURL,
67+
httpClient: s.httpClient,
68+
stateManager: s.stateManager,
69+
}
10870
default:
10971
err := fmt.Errorf("unsupported webhook type: %v", job.WebhookType)
11072
_ = s.stateManager.RecordFailure(ctx, job.ChannelID, err, time.Now(), true, job.WebhookEventID)
11173

11274
return err
11375
}
11476

115-
req, err := preparer.Prepare(ctx, job)
77+
mgr, err := preparer.Prepare(job)
11678
if err != nil {
117-
// Preparation failures (like invalid payload or URL format) are typically permanent
118-
_ = s.stateManager.RecordFailure(ctx, job.ChannelID, err, time.Now(), true, job.WebhookEventID)
119-
12079
return fmt.Errorf("failed to prepare webhook request: %w", err)
12180
}
12281

123-
resp, err := s.httpClient.Do(req)
124-
if err != nil {
125-
// Transient error?
126-
_ = s.stateManager.RecordFailure(ctx, job.ChannelID, err, time.Now(), false, job.WebhookEventID)
127-
128-
return fmt.Errorf("failed to send webhook: %w", err)
129-
}
130-
defer resp.Body.Close()
131-
132-
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
133-
// Success
134-
_ = s.stateManager.RecordSuccess(ctx, job.ChannelID, time.Now(), job.WebhookEventID)
135-
136-
return nil
137-
}
138-
139-
// Failure
140-
errorMsg := fmt.Sprintf("webhook returned status code %d", resp.StatusCode)
141-
webhookErr := errors.New(errorMsg)
142-
isPermanent := resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone ||
143-
resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden
144-
145-
_ = s.stateManager.RecordFailure(ctx, job.ChannelID, webhookErr, time.Now(), isPermanent, job.WebhookEventID)
146-
147-
return fmt.Errorf("webhook failed: %s", errorMsg)
82+
return mgr.Send(ctx)
14883
}

workers/webhook/pkg/webhook/sender_test.go

Lines changed: 165 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ package webhook
1717
import (
1818
"context"
1919
"encoding/json"
20+
"fmt"
2021
"io"
2122
"net/http"
2223
"strings"
@@ -160,10 +161,6 @@ func TestSender_SendWebhook_Success(t *testing.T) {
160161
t.Fatalf("SendWebhook failed: %v", err)
161162
}
162163

163-
// Verify the payload text contains the link
164-
// The mock doFunc already checked basic text, but let's verify exact format if needed.
165-
// (Actual check is inside the mock doFunc redefined above if we want to be strict)
166-
167164
if len(mockState.successCalls) != 1 {
168165
t.Errorf("expected 1 success call, got %d", len(mockState.successCalls))
169166
}
@@ -252,3 +249,167 @@ func TestSender_SendWebhook_HTTPFailure(t *testing.T) {
252249
t.Error("expected permanent failure for 404")
253250
}
254251
}
252+
253+
func TestSender_SendWebhook_UnsupportedType(t *testing.T) {
254+
mockState := &mockChannelStateManager{
255+
successCalls: nil,
256+
failureCalls: nil,
257+
}
258+
sender := NewSender(nil, mockState, "https://webstatus.dev")
259+
260+
job := workertypes.IncomingWebhookDeliveryJob{
261+
WebhookDeliveryJob: workertypes.WebhookDeliveryJob{
262+
ChannelID: "chan-1",
263+
WebhookURL: "https://example.com/webhook",
264+
WebhookType: "unknown",
265+
SummaryRaw: nil,
266+
SubscriptionID: "sub-1",
267+
Triggers: nil,
268+
Metadata: workertypes.DeliveryMetadata{
269+
EventID: "evt-1",
270+
SearchID: "search-1",
271+
SearchName: "",
272+
Query: "group:css",
273+
Frequency: workertypes.FrequencyImmediate,
274+
GeneratedAt: time.Time{},
275+
},
276+
},
277+
WebhookEventID: "evt-1",
278+
}
279+
280+
err := sender.SendWebhook(context.Background(), job)
281+
if err == nil {
282+
t.Fatal("expected error, got nil")
283+
}
284+
if !strings.Contains(err.Error(), "unsupported webhook type") {
285+
t.Errorf("unexpected error message: %v", err)
286+
}
287+
288+
if len(mockState.failureCalls) != 1 {
289+
t.Errorf("expected 1 failure call, got %d", len(mockState.failureCalls))
290+
}
291+
if !mockState.failureCalls[0].isPermanent {
292+
t.Error("expected permanent failure for unsupported type")
293+
}
294+
}
295+
296+
func TestSender_SendWebhook_InvalidSlackURL(t *testing.T) {
297+
mockState := &mockChannelStateManager{
298+
successCalls: nil,
299+
failureCalls: nil,
300+
}
301+
sender := NewSender(nil, mockState, "https://webstatus.dev")
302+
303+
job := workertypes.IncomingWebhookDeliveryJob{
304+
WebhookDeliveryJob: workertypes.WebhookDeliveryJob{
305+
ChannelID: "chan-1",
306+
WebhookURL: "https://not-slack.com/hook",
307+
WebhookType: workertypes.WebhookTypeSlack,
308+
SummaryRaw: nil,
309+
SubscriptionID: "sub-1",
310+
Triggers: nil,
311+
Metadata: workertypes.DeliveryMetadata{
312+
EventID: "evt-1",
313+
SearchID: "search-1",
314+
SearchName: "",
315+
Query: "group:css",
316+
Frequency: workertypes.FrequencyImmediate,
317+
GeneratedAt: time.Time{},
318+
},
319+
},
320+
WebhookEventID: "evt-1",
321+
}
322+
323+
err := sender.SendWebhook(context.Background(), job)
324+
if err == nil {
325+
t.Fatal("expected error, got nil")
326+
}
327+
328+
if len(mockState.failureCalls) != 1 {
329+
t.Errorf("expected 1 failure call, got %d", len(mockState.failureCalls))
330+
}
331+
if !mockState.failureCalls[0].isPermanent {
332+
t.Error("expected permanent failure for invalid URL")
333+
}
334+
}
335+
336+
func TestSender_SendWebhook_NetworkError(t *testing.T) {
337+
mockHTTP := &mockHTTPClient{
338+
doFunc: func(_ *http.Request) (*http.Response, error) {
339+
return nil, fmt.Errorf("network error")
340+
},
341+
}
342+
mockState := &mockChannelStateManager{
343+
successCalls: nil,
344+
failureCalls: nil,
345+
}
346+
sender := NewSender(mockHTTP, mockState, "https://webstatus.dev")
347+
348+
job := workertypes.IncomingWebhookDeliveryJob{
349+
WebhookDeliveryJob: workertypes.WebhookDeliveryJob{
350+
ChannelID: "chan-1",
351+
WebhookURL: "https://hooks.slack.com/services/123",
352+
WebhookType: workertypes.WebhookTypeSlack,
353+
SummaryRaw: []byte(`{"text":"test"}`),
354+
SubscriptionID: "sub-1",
355+
Triggers: nil,
356+
Metadata: workertypes.DeliveryMetadata{
357+
EventID: "evt-1",
358+
SearchID: "search-1",
359+
SearchName: "",
360+
Query: "group:css",
361+
Frequency: workertypes.FrequencyImmediate,
362+
GeneratedAt: time.Time{},
363+
},
364+
},
365+
WebhookEventID: "evt-1",
366+
}
367+
368+
err := sender.SendWebhook(context.Background(), job)
369+
if err == nil {
370+
t.Fatal("expected error, got nil")
371+
}
372+
373+
if len(mockState.failureCalls) != 1 {
374+
t.Errorf("expected 1 failure call, got %d", len(mockState.failureCalls))
375+
}
376+
if mockState.failureCalls[0].isPermanent {
377+
t.Error("expected transient failure for network error")
378+
}
379+
}
380+
381+
func TestSender_SendWebhook_InvalidSummary(t *testing.T) {
382+
mockState := &mockChannelStateManager{
383+
successCalls: nil,
384+
failureCalls: nil,
385+
}
386+
sender := NewSender(nil, mockState, "https://webstatus.dev")
387+
388+
job := workertypes.IncomingWebhookDeliveryJob{
389+
WebhookDeliveryJob: workertypes.WebhookDeliveryJob{
390+
ChannelID: "chan-1",
391+
WebhookURL: "https://hooks.slack.com/services/123",
392+
WebhookType: workertypes.WebhookTypeSlack,
393+
SummaryRaw: []byte(`invalid json`),
394+
SubscriptionID: "sub-1",
395+
Triggers: nil,
396+
Metadata: workertypes.DeliveryMetadata{
397+
EventID: "evt-1",
398+
SearchID: "search-1",
399+
SearchName: "",
400+
Query: "group:css",
401+
Frequency: workertypes.FrequencyImmediate,
402+
GeneratedAt: time.Time{},
403+
},
404+
},
405+
WebhookEventID: "evt-1",
406+
}
407+
408+
err := sender.SendWebhook(context.Background(), job)
409+
if err == nil {
410+
t.Fatal("expected error, got nil")
411+
}
412+
if !strings.Contains(err.Error(), "failed to unmarshal summary") {
413+
t.Errorf("unexpected error message: %v", err)
414+
}
415+
}

0 commit comments

Comments
 (0)