Skip to content

Commit b188e08

Browse files
committed
feat: implement bounce crawler logic with unit tests and add structured logging test suite
1 parent 12b75d6 commit b188e08

9 files changed

Lines changed: 636 additions & 26 deletions

File tree

.scannerwork/report-task.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@ projectKey=dispatch
22
serverUrl=http://10.27.27.202:9000
33
serverVersion=26.4.0.121862
44
dashboardUrl=http://10.27.27.202:9000/dashboard?id=dispatch
5-
ceTaskId=68fb8656-1ed5-4f18-83e6-c1bb725748fe
6-
ceTaskUrl=http://10.27.27.202:9000/api/ce/task?id=68fb8656-1ed5-4f18-83e6-c1bb725748fe
5+
ceTaskId=dd9293ab-92fb-4d30-9020-34e36ede6c6c
6+
ceTaskUrl=http://10.27.27.202:9000/api/ce/task?id=dd9293ab-92fb-4d30-9020-34e36ede6c6c

internal/admin/auth_test.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import (
99
"github.com/golang-jwt/jwt/v5"
1010
)
1111

12-
const authTestSecret = "test-secret-key"
12+
const (
13+
authTestSecret = "test-secret-key"
14+
bearerPrefix = "Bearer "
15+
)
1316

1417
func signedToken(t *testing.T, secret string, expiry time.Time) string {
1518
t.Helper()
@@ -31,10 +34,10 @@ func TestAuthMiddleware(t *testing.T) {
3134
authHeader string
3235
wantStatus int
3336
}{
34-
{"valid token", "Bearer " + validToken, http.StatusOK},
37+
{"valid token", bearerPrefix + validToken, http.StatusOK},
3538
{"no token", "", http.StatusUnauthorized},
36-
{"wrong secret", "Bearer " + wrongSecretToken, http.StatusUnauthorized},
37-
{"expired token", "Bearer " + expiredToken, http.StatusUnauthorized},
39+
{"wrong secret", bearerPrefix + wrongSecretToken, http.StatusUnauthorized},
40+
{"expired token", bearerPrefix + expiredToken, http.StatusUnauthorized},
3841
{"malformed token", "Bearer notajwt", http.StatusUnauthorized},
3942
{"missing bearer prefix", validToken, http.StatusUnauthorized},
4043
}

internal/bounce/crawler.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ type graphClient interface {
2323
MarkAsRead(ctx context.Context, mailbox, messageID string) error
2424
}
2525

26+
type jsPublisher interface {
27+
Publish(subj string, data []byte, opts ...nats.PubOpt) (*nats.PubAck, error)
28+
}
29+
2630
// NDRMessage represents a non-delivery report from MS Graph.
2731
type NDRMessage struct {
2832
ID string
@@ -33,11 +37,11 @@ type NDRMessage struct {
3337
// Crawler reads NDR messages from a bounce mailbox and writes BounceRecords to NATS.
3438
type Crawler struct {
3539
graph graphClient
36-
js nats.JetStreamContext
40+
js jsPublisher
3741
mailbox string
3842
}
3943

40-
func NewCrawler(graph graphClient, js nats.JetStreamContext, mailbox string) *Crawler {
44+
func NewCrawler(graph graphClient, js jsPublisher, mailbox string) *Crawler {
4145
return &Crawler{graph: graph, js: js, mailbox: mailbox}
4246
}
4347

internal/bounce/crawler_test.go

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package bounce
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"testing"
8+
9+
"github.com/nats-io/nats.go"
10+
11+
"dispatch/internal/domain"
12+
)
13+
14+
const (
15+
testCrawlerMailbox = "bounce@example.com"
16+
errUnexpected = "unexpected error: %v"
17+
)
18+
19+
// --- stubs ---
20+
21+
type stubGraph struct {
22+
msgs []NDRMessage
23+
err error
24+
}
25+
26+
func (s *stubGraph) GetUnreadMessages(_ context.Context, _ string) ([]NDRMessage, error) {
27+
return s.msgs, s.err
28+
}
29+
func (s *stubGraph) MarkAsRead(_ context.Context, _, _ string) error { return s.err }
30+
31+
type captureJS struct {
32+
published [][]byte
33+
err error
34+
}
35+
36+
func (c *captureJS) Publish(_ string, data []byte, _ ...nats.PubOpt) (*nats.PubAck, error) {
37+
if c.err != nil {
38+
return nil, c.err
39+
}
40+
c.published = append(c.published, data)
41+
return &nats.PubAck{}, nil
42+
}
43+
44+
// --- extractTraceID ---
45+
46+
func TestExtractTraceID_Found(t *testing.T) {
47+
body := "Some NDR text\nX-Dispatch-TraceId: 550e8400-e29b-41d4-a716-446655440000\nMore text"
48+
got := extractTraceID(body)
49+
if got != "550e8400-e29b-41d4-a716-446655440000" {
50+
t.Errorf("want trace ID, got %q", got)
51+
}
52+
}
53+
54+
func TestExtractTraceID_NotFound(t *testing.T) {
55+
got := extractTraceID("no trace id here")
56+
if got != "" {
57+
t.Errorf("want empty, got %q", got)
58+
}
59+
}
60+
61+
func TestExtractTraceID_Empty(t *testing.T) {
62+
got := extractTraceID("")
63+
if got != "" {
64+
t.Errorf("want empty string, got %q", got)
65+
}
66+
}
67+
68+
// --- Crawler.Run ---
69+
70+
func TestRun_NoMessages(t *testing.T) {
71+
crawler := NewCrawler(&stubGraph{msgs: []NDRMessage{}}, &captureJS{}, testCrawlerMailbox)
72+
if err := crawler.Run(context.Background()); err != nil {
73+
t.Fatalf(errUnexpected, err)
74+
}
75+
}
76+
77+
func TestRun_GraphError(t *testing.T) {
78+
crawler := NewCrawler(&stubGraph{err: errors.New("graph down")}, &captureJS{}, testCrawlerMailbox)
79+
if err := crawler.Run(context.Background()); err == nil {
80+
t.Fatal("expected error when graph fails")
81+
}
82+
}
83+
84+
func TestRun_PublishesBouncRecord(t *testing.T) {
85+
msgs := []NDRMessage{
86+
{ID: "m1", Subject: "Undeliverable: Hi", Body: "X-Dispatch-TraceId: aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"},
87+
}
88+
js := &captureJS{}
89+
crawler := NewCrawler(&stubGraph{msgs: msgs}, js, testCrawlerMailbox)
90+
91+
if err := crawler.Run(context.Background()); err != nil {
92+
t.Fatalf(errUnexpected, err)
93+
}
94+
if len(js.published) != 1 {
95+
t.Fatalf("want 1 published message, got %d", len(js.published))
96+
}
97+
98+
var rec domain.BounceRecord
99+
if err := json.Unmarshal(js.published[0], &rec); err != nil {
100+
t.Fatalf("unmarshal bounce record: %v", err)
101+
}
102+
if rec.OriginalTraceID != "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" {
103+
t.Errorf("OriginalTraceID: want trace id, got %q", rec.OriginalTraceID)
104+
}
105+
if rec.BounceReason != "Undeliverable: Hi" {
106+
t.Errorf("BounceReason: want subject, got %q", rec.BounceReason)
107+
}
108+
}
109+
110+
func TestRun_NoTraceID_StillPublishes(t *testing.T) {
111+
msgs := []NDRMessage{
112+
{ID: "m2", Subject: "NDR", Body: "no trace id in body"},
113+
}
114+
js := &captureJS{}
115+
crawler := NewCrawler(&stubGraph{msgs: msgs}, js, testCrawlerMailbox)
116+
117+
if err := crawler.Run(context.Background()); err != nil {
118+
t.Fatalf(errUnexpected, err)
119+
}
120+
if len(js.published) != 1 {
121+
t.Fatalf("want 1 published message, got %d", len(js.published))
122+
}
123+
124+
var rec domain.BounceRecord
125+
_ = json.Unmarshal(js.published[0], &rec)
126+
if rec.OriginalTraceID != "" {
127+
t.Errorf("OriginalTraceID: want empty, got %q", rec.OriginalTraceID)
128+
}
129+
}
130+
131+
func TestRun_PublishError_ContinuesToNextMessage(t *testing.T) {
132+
msgs := []NDRMessage{
133+
{ID: "m1", Subject: "NDR1", Body: ""},
134+
{ID: "m2", Subject: "NDR2", Body: ""},
135+
}
136+
js := &captureJS{err: errors.New("NATS down")}
137+
crawler := NewCrawler(&stubGraph{msgs: msgs}, js, testCrawlerMailbox)
138+
139+
// Run must not return an error — publish errors are logged but do not abort the loop
140+
if err := crawler.Run(context.Background()); err != nil {
141+
t.Fatalf(errUnexpected, err)
142+
}
143+
}
144+
145+
func TestRun_MultipleMessages(t *testing.T) {
146+
msgs := []NDRMessage{
147+
{ID: "m1", Subject: "NDR1", Body: "X-Dispatch-TraceId: 11111111-1111-1111-1111-111111111111"},
148+
{ID: "m2", Subject: "NDR2", Body: "X-Dispatch-TraceId: 22222222-2222-2222-2222-222222222222"},
149+
}
150+
js := &captureJS{}
151+
crawler := NewCrawler(&stubGraph{msgs: msgs}, js, testCrawlerMailbox)
152+
153+
if err := crawler.Run(context.Background()); err != nil {
154+
t.Fatalf(errUnexpected, err)
155+
}
156+
if len(js.published) != 2 {
157+
t.Errorf("want 2 published messages, got %d", len(js.published))
158+
}
159+
}

internal/config/config_test.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
const (
99
testNatsURL = "nats://localhost:4222"
1010
testSenderEmail = "sender@example.com"
11+
testAdminSecret = "test-secret"
1112
unexpectedErr = "unexpected error: %v"
1213
)
1314

@@ -18,7 +19,7 @@ func setRequiredEnv(t *testing.T) {
1819
t.Setenv("MS_GRAPH_CLIENT_ID", "client")
1920
t.Setenv("MS_GRAPH_CLIENT_SECRET", "secret")
2021
t.Setenv("MS_GRAPH_SENDER_EMAIL", testSenderEmail)
21-
t.Setenv("DISPATCH_ADMIN_AUTH_SECRET", "test-secret")
22+
t.Setenv("DISPATCH_ADMIN_AUTH_SECRET", testAdminSecret)
2223
}
2324

2425
func TestLoad_Success(t *testing.T) {
@@ -86,7 +87,7 @@ func TestLoad_MissingNatsURL(t *testing.T) {
8687

8788
func TestLoad_MissingGraphCredentials(t *testing.T) {
8889
t.Setenv("NATS_URL", testNatsURL)
89-
t.Setenv("DISPATCH_ADMIN_AUTH_SECRET", "test-secret")
90+
t.Setenv("DISPATCH_ADMIN_AUTH_SECRET", testAdminSecret)
9091
// MS_GRAPH_MOCK_TOKEN is not set → credentials are required
9192

9293
cases := []struct {
@@ -119,7 +120,7 @@ func TestLoad_MissingGraphCredentials(t *testing.T) {
119120
func TestLoad_MockTokenSkipsCredentialCheck(t *testing.T) {
120121
t.Setenv("NATS_URL", testNatsURL)
121122
t.Setenv("MS_GRAPH_MOCK_TOKEN", "dev-token")
122-
t.Setenv("DISPATCH_ADMIN_AUTH_SECRET", "test-secret")
123+
t.Setenv("DISPATCH_ADMIN_AUTH_SECRET", testAdminSecret)
123124
// deliberately leave Graph credentials unset
124125

125126
cfg, err := Load()

internal/loggy/loggy.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,9 @@ func (l *Loggy) RecordApiStart(apiName string) {
183183
// ExternalApiSuccess logs a successful external API call at INFO level,
184184
// computing latency from the prior RecordApiStart.
185185
func (l *Loggy) ExternalApiSuccess(apiName string, httpStatus int) {
186+
if l == nil {
187+
return
188+
}
186189
var durationMs int64
187190
if v, ok := l.apiStart.LoadAndDelete(apiName); ok {
188191
durationMs = time.Since(v.(time.Time)).Milliseconds()
@@ -195,6 +198,9 @@ func (l *Loggy) ExternalApiSuccess(apiName string, httpStatus int) {
195198

196199
// ExternalApiFailure logs a failed external API call (5xx / network) at ERROR level.
197200
func (l *Loggy) ExternalApiFailure(apiName string, httpStatus int, err error) {
201+
if l == nil {
202+
return
203+
}
198204
var durationMs int64
199205
if v, ok := l.apiStart.LoadAndDelete(apiName); ok {
200206
durationMs = time.Since(v.(time.Time)).Milliseconds()
@@ -207,6 +213,9 @@ func (l *Loggy) ExternalApiFailure(apiName string, httpStatus int, err error) {
207213

208214
// ApiClientError logs a 4xx client error against an external API at WARN level.
209215
func (l *Loggy) ApiClientError(apiName string, httpStatus int, reason string) {
216+
if l == nil {
217+
return
218+
}
210219
var durationMs int64
211220
if v, ok := l.apiStart.LoadAndDelete(apiName); ok {
212221
durationMs = time.Since(v.(time.Time)).Milliseconds()

0 commit comments

Comments
 (0)