Skip to content

Commit 60ca62d

Browse files
test(api): integration coverage — brevo ledger webhook + status endpoint (toward 100%)
Adds REAL-DB integration tests for two handlers that previously had ONLY sqlmock (unit) coverage, closing integration gaps flagged in INTEGRATION-COVERAGE-PLAN-2026-06-04 §2.1. brevo_webhook_realdb_integration_test.go — POST /webhooks/brevo/:secret, the rule-12 email truth surface. Seeds real forwarder_sent rows, POSTs synthetic Brevo events, reads back via the production LookupForwarderSentByProviderID helper. Proves the SQL is CORRECT (not just "issued"): delivered overwrites classification + stamps delivered_at; each non-delivered event maps to its terminal class leaving delivered_at NULL; the bug-bash #6 terminal-class guard preserves bounced_hard/soft/ rejected/complaint/unsubscribed against a LATE out-of-order 'delivered' while still upgrading deferred/success; GREATEST delivered_at is monotonic on Brevo retries (idempotent); an orphan messageId is a 200 no-op that creates no row. Testable today despite the unvalidated-Brevo-sender block (synthetic payload, not a live send). status_realdb_integration_test.go — GET /api/v1/status. Seeds real service_components + uptime_samples and asserts the computed payload through a Fiber app on real DB + real Redis: operational (all-healthy) vs down (recent unhealthy probe), empty-DB clean state (never 500), the 60s cache.GetOrSet round-trip (second request served from Redis, cache-bust surfaces a mid-flight DB mutation), and the nil-Redis DB-fallback path. No production code changed. Integration coverage (handlers, -coverpkg=./...): 78.6% -> 78.7%; status.go computeOne 86.0% -> 89.5%. The pre-existing local TestQueue_CredIssueError_FallsBackToLegacyOpen 503 (NATS unreachable locally) is unchanged and unrelated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d0f05fa commit 60ca62d

2 files changed

Lines changed: 603 additions & 0 deletions

File tree

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
package handlers_test
2+
3+
// brevo_webhook_realdb_integration_test.go — REAL-DB integration coverage for
4+
// the Brevo transactional-delivery receiver at POST /webhooks/brevo/:secret.
5+
//
6+
// WHY THIS FILE EXISTS (integration-coverage wave 2, 2026-06-04):
7+
//
8+
// brevo_webhook_test.go drives the same handler entirely through sqlmock — it
9+
// asserts the handler ISSUES the right SQL string, but it never proves the SQL
10+
// is CORRECT against a real Postgres schema. The rule-12 email truth surface
11+
// (forwarder_sent.classification + delivered_at) is only as trustworthy as the
12+
// actual UPDATE behaviour:
13+
//
14+
// * the delivered path uses COALESCE(GREATEST(delivered_at, NOW()), NOW())
15+
// — a sqlmock can't tell us whether that expression even parses against
16+
// the real column type, let alone that it's monotonic.
17+
// * the bug-bash #6 terminal-class guard
18+
// (classification NOT IN (bounced_hard, bounced_soft, rejected, complaint,
19+
// unsubscribed)) is a row-state predicate — sqlmock returns whatever rows
20+
// we tell it to; only a real row can prove a late 'delivered' does NOT
21+
// clobber a recorded 'bounced_hard'.
22+
//
23+
// These tests seed a real forwarder_sent row, POST a synthetic Brevo event
24+
// (the same shape Brevo emits — NOT a live send, so the unvalidated-sender
25+
// production block is irrelevant), and read the row back via the production
26+
// LookupForwarderSentByProviderID helper to assert the ledger overwrite.
27+
//
28+
// Tests are skipped (not failed) when TEST_DATABASE_URL is unreachable so the
29+
// hermetic `-short` gate stays green; CI supplies the DB and runs them for
30+
// real.
31+
32+
import (
33+
"context"
34+
"database/sql"
35+
"errors"
36+
"net/http"
37+
"testing"
38+
"time"
39+
40+
"github.com/gofiber/fiber/v2"
41+
"github.com/google/uuid"
42+
43+
"instant.dev/internal/config"
44+
"instant.dev/internal/handlers"
45+
"instant.dev/internal/testhelpers"
46+
)
47+
48+
// brevoTxRealApp builds a Fiber app with ONLY the transactional-delivery
49+
// receiver mounted, wired to the supplied real *sql.DB. Mirrors the production
50+
// ErrorHandler short-circuit so respondError envelopes pass through unchanged.
51+
func brevoTxRealApp(t *testing.T, db *sql.DB) *fiber.App {
52+
t.Helper()
53+
h := handlers.NewBrevoTransactionalWebhookHandler(db, &config.Config{BrevoWebhookSecret: testBrevoTxSecret})
54+
app := fiber.New(fiber.Config{
55+
ErrorHandler: func(c *fiber.Ctx, err error) error {
56+
if errors.Is(err, handlers.ErrResponseWritten) {
57+
return nil
58+
}
59+
return fiber.DefaultErrorHandler(c, err)
60+
},
61+
})
62+
app.Post("/webhooks/brevo/:secret", h.Receive)
63+
return app
64+
}
65+
66+
// seedForwarderSent inserts a forwarder_sent row with the given provider_id +
67+
// initial classification and returns the audit_id. The row carries
68+
// provider='brevo' so the receiver's (provider, provider_id) lookup matches.
69+
func seedForwarderSent(t *testing.T, db *sql.DB, providerID, classification string) string {
70+
t.Helper()
71+
auditID := uuid.NewString()
72+
_, err := db.ExecContext(context.Background(), `
73+
INSERT INTO forwarder_sent (audit_id, provider, provider_id, recipient, template_kind, classification)
74+
VALUES ($1, 'brevo', $2, 'u***@example.com', 'anon.expiry_warning', $3)
75+
`, auditID, providerID, classification)
76+
if err != nil {
77+
t.Fatalf("seedForwarderSent: %v", err)
78+
}
79+
return auditID
80+
}
81+
82+
// ── 1. delivered event overwrites classification AND stamps delivered_at ──
83+
84+
func TestBrevoTxWebhook_RealDB_DeliveredOverwritesLedger(t *testing.T) {
85+
db, cleanup := testhelpers.SetupTestDB(t)
86+
defer cleanup()
87+
88+
providerID := "msg-realdb-" + uuid.NewString()[:8]
89+
seedForwarderSent(t, db, providerID, "success") // worker's API-acceptance state
90+
91+
app := brevoTxRealApp(t, db)
92+
body := `{"event":"delivered","email":"u@example.com","message-id":"` + providerID + `","subject":"Welcome"}`
93+
resp := postBrevoTx(t, app, testBrevoTxSecret, body)
94+
if resp.StatusCode != http.StatusOK {
95+
t.Fatalf("status = %d; want 200", resp.StatusCode)
96+
}
97+
98+
row, err := handlers.LookupForwarderSentByProviderID(context.Background(), db, providerID)
99+
if err != nil {
100+
t.Fatalf("LookupForwarderSentByProviderID: %v", err)
101+
}
102+
if row.Classification != "delivered" {
103+
t.Errorf("classification = %q; want delivered (rule-12 truth surface must overwrite the worker's 'success')", row.Classification)
104+
}
105+
if row.DeliveredAt == nil {
106+
t.Error("delivered_at must be stamped on a 'delivered' event; got nil")
107+
}
108+
}
109+
110+
// ── 2. each non-delivered event overwrites classification, leaves delivered_at NULL ──
111+
112+
func TestBrevoTxWebhook_RealDB_FailureEventsOverwriteClassification(t *testing.T) {
113+
db, cleanup := testhelpers.SetupTestDB(t)
114+
defer cleanup()
115+
116+
cases := []struct {
117+
event string
118+
wantClass string
119+
}{
120+
{"hard_bounce", "bounced_hard"},
121+
{"soft_bounce", "bounced_soft"},
122+
{"blocked", "rejected"},
123+
{"complaint", "complaint"},
124+
{"spam", "complaint"}, // alias → complaint
125+
{"deferred", "deferred"},
126+
{"unsubscribed", "unsubscribed"},
127+
{"error", "error"},
128+
}
129+
app := brevoTxRealApp(t, db)
130+
for _, c := range cases {
131+
t.Run(c.event, func(t *testing.T) {
132+
providerID := "msg-fail-" + c.event + "-" + uuid.NewString()[:8]
133+
seedForwarderSent(t, db, providerID, "success")
134+
135+
body := `{"event":"` + c.event + `","email":"u@example.com","message-id":"` + providerID + `","reason":"mailbox full"}`
136+
resp := postBrevoTx(t, app, testBrevoTxSecret, body)
137+
if resp.StatusCode != http.StatusOK {
138+
t.Fatalf("status = %d; want 200", resp.StatusCode)
139+
}
140+
141+
row, err := handlers.LookupForwarderSentByProviderID(context.Background(), db, providerID)
142+
if err != nil {
143+
t.Fatalf("Lookup: %v", err)
144+
}
145+
if row.Classification != c.wantClass {
146+
t.Errorf("classification = %q; want %q", row.Classification, c.wantClass)
147+
}
148+
// Only the 'delivered' event ever stamps delivered_at.
149+
if row.DeliveredAt != nil {
150+
t.Errorf("delivered_at must stay NULL on a %q event; got %v", c.event, row.DeliveredAt)
151+
}
152+
})
153+
}
154+
}
155+
156+
// ── 3. bug-bash #6 terminal-class guard: a LATE 'delivered' must NOT clobber a
157+
// recorded 'bounced_hard'. This is the #6 guard area the brief calls out
158+
// — it can ONLY be proven against a real row (sqlmock can't enforce the
159+
// NOT IN row-state predicate).
160+
161+
func TestBrevoTxWebhook_RealDB_LateDeliveredDoesNotClobberHardBounce(t *testing.T) {
162+
db, cleanup := testhelpers.SetupTestDB(t)
163+
defer cleanup()
164+
165+
providerID := "msg-terminal-" + uuid.NewString()[:8]
166+
// The row is ALREADY terminal — a hard bounce was recorded first.
167+
seedForwarderSent(t, db, providerID, "bounced_hard")
168+
169+
app := brevoTxRealApp(t, db)
170+
// Out-of-order: Brevo's SMTP-accept 'delivered' arrives AFTER the bounce.
171+
body := `{"event":"delivered","email":"u@example.com","message-id":"` + providerID + `"}`
172+
resp := postBrevoTx(t, app, testBrevoTxSecret, body)
173+
if resp.StatusCode != http.StatusOK {
174+
t.Fatalf("status = %d; want 200", resp.StatusCode)
175+
}
176+
177+
row, err := handlers.LookupForwarderSentByProviderID(context.Background(), db, providerID)
178+
if err != nil {
179+
t.Fatalf("Lookup: %v", err)
180+
}
181+
if row.Classification != "bounced_hard" {
182+
t.Errorf("terminal class clobbered: classification = %q; want bounced_hard preserved (bug-bash #6)", row.Classification)
183+
}
184+
if row.DeliveredAt != nil {
185+
t.Errorf("delivered_at must NOT be stamped when the terminal class is preserved; got %v", row.DeliveredAt)
186+
}
187+
}
188+
189+
// ── 3b. the guard also preserves the OTHER terminal classes, and a 'delivered'
190+
// DOES win when the prior state is a non-terminal 'deferred'. This pins
191+
// the exact NOT IN set so a future edit that drops a class from the
192+
// guard fails here.
193+
194+
func TestBrevoTxWebhook_RealDB_TerminalGuardSetIsExact(t *testing.T) {
195+
db, cleanup := testhelpers.SetupTestDB(t)
196+
defer cleanup()
197+
app := brevoTxRealApp(t, db)
198+
199+
cases := []struct {
200+
name string
201+
seedClass string
202+
wantClassAfter string
203+
wantDelivered bool
204+
}{
205+
// Terminal classes: a late 'delivered' is a no-op on classification.
206+
{"hard_bounce_preserved", "bounced_hard", "bounced_hard", false},
207+
{"soft_bounce_preserved", "bounced_soft", "bounced_soft", false},
208+
{"rejected_preserved", "rejected", "rejected", false},
209+
{"complaint_preserved", "complaint", "complaint", false},
210+
{"unsubscribed_preserved", "unsubscribed", "unsubscribed", false},
211+
// Non-terminal classes: a 'delivered' SHOULD win (deferred is transient;
212+
// 'success' is the worker's API-acceptance placeholder).
213+
{"deferred_upgraded", "deferred", "delivered", true},
214+
{"success_upgraded", "success", "delivered", true},
215+
}
216+
for _, c := range cases {
217+
t.Run(c.name, func(t *testing.T) {
218+
providerID := "msg-guard-" + c.name + "-" + uuid.NewString()[:8]
219+
seedForwarderSent(t, db, providerID, c.seedClass)
220+
221+
body := `{"event":"delivered","email":"u@example.com","message-id":"` + providerID + `"}`
222+
resp := postBrevoTx(t, app, testBrevoTxSecret, body)
223+
if resp.StatusCode != http.StatusOK {
224+
t.Fatalf("status = %d; want 200", resp.StatusCode)
225+
}
226+
row, err := handlers.LookupForwarderSentByProviderID(context.Background(), db, providerID)
227+
if err != nil {
228+
t.Fatalf("Lookup: %v", err)
229+
}
230+
if row.Classification != c.wantClassAfter {
231+
t.Errorf("seed=%q: classification = %q; want %q", c.seedClass, row.Classification, c.wantClassAfter)
232+
}
233+
if (row.DeliveredAt != nil) != c.wantDelivered {
234+
t.Errorf("seed=%q: delivered_at set = %v; want %v", c.seedClass, row.DeliveredAt != nil, c.wantDelivered)
235+
}
236+
})
237+
}
238+
}
239+
240+
// ── 4. idempotency: a re-delivery of the same 'delivered' event is a no-op on
241+
// the value side and does NOT push delivered_at backwards (GREATEST).
242+
243+
func TestBrevoTxWebhook_RealDB_DeliveredIsIdempotent(t *testing.T) {
244+
db, cleanup := testhelpers.SetupTestDB(t)
245+
defer cleanup()
246+
247+
providerID := "msg-idem-" + uuid.NewString()[:8]
248+
seedForwarderSent(t, db, providerID, "success")
249+
app := brevoTxRealApp(t, db)
250+
body := `{"event":"delivered","email":"u@example.com","message-id":"` + providerID + `"}`
251+
252+
// First delivery stamps delivered_at.
253+
if resp := postBrevoTx(t, app, testBrevoTxSecret, body); resp.StatusCode != http.StatusOK {
254+
t.Fatalf("first POST status = %d; want 200", resp.StatusCode)
255+
}
256+
first, err := handlers.LookupForwarderSentByProviderID(context.Background(), db, providerID)
257+
if err != nil || first.DeliveredAt == nil {
258+
t.Fatalf("after first delivery: row=%+v err=%v", first, err)
259+
}
260+
firstTS := *first.DeliveredAt
261+
262+
// A re-delivery (Brevo retry) must keep classification + must not move
263+
// delivered_at backwards (GREATEST). It may bump forward by sub-second; we
264+
// assert it never regresses.
265+
time.Sleep(10 * time.Millisecond)
266+
if resp := postBrevoTx(t, app, testBrevoTxSecret, body); resp.StatusCode != http.StatusOK {
267+
t.Fatalf("second POST status = %d; want 200", resp.StatusCode)
268+
}
269+
second, err := handlers.LookupForwarderSentByProviderID(context.Background(), db, providerID)
270+
if err != nil {
271+
t.Fatalf("Lookup after re-delivery: %v", err)
272+
}
273+
if second.Classification != "delivered" {
274+
t.Errorf("classification after re-delivery = %q; want delivered", second.Classification)
275+
}
276+
if second.DeliveredAt == nil || second.DeliveredAt.Before(firstTS) {
277+
t.Errorf("delivered_at regressed: first=%v second=%v (GREATEST must keep it monotonic)", firstTS, second.DeliveredAt)
278+
}
279+
}
280+
281+
// ── 5. unknown messageId (no matching row) → 200 matched:false, NO row created.
282+
283+
func TestBrevoTxWebhook_RealDB_UnknownMessageIDIsNoOp(t *testing.T) {
284+
db, cleanup := testhelpers.SetupTestDB(t)
285+
defer cleanup()
286+
app := brevoTxRealApp(t, db)
287+
288+
orphanID := "msg-orphan-" + uuid.NewString()[:8]
289+
body := `{"event":"delivered","email":"u@example.com","message-id":"` + orphanID + `"}`
290+
resp := postBrevoTx(t, app, testBrevoTxSecret, body)
291+
if resp.StatusCode != http.StatusOK {
292+
t.Fatalf("status = %d; want 200 (Brevo retries on non-2xx — orphans must NOT amplify retry)", resp.StatusCode)
293+
}
294+
// The handler must NEVER INSERT a ledger row for an orphan event.
295+
if _, err := handlers.LookupForwarderSentByProviderID(context.Background(), db, orphanID); !errors.Is(err, sql.ErrNoRows) {
296+
t.Errorf("orphan messageId must leave no forwarder_sent row; lookup err = %v (want sql.ErrNoRows)", err)
297+
}
298+
}

0 commit comments

Comments
 (0)