|
| 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