feat(bdd): pkg/bdd/mailpit/ HTTP client + integration tests (ADR-0030 Phase A.2)

Adds the Mailpit HTTP API client used by BDD scenarios to assert on
emails sent during a test. Implements the per-recipient query/await/
purge pattern from ADR-0030.

Build-tag-gated integration tests run against the live Mailpit at
localhost:8025 (started via docker compose up -d mailpit).

Three operations on the client:
- MessagesTo(ctx, to)        — list message IDs for a recipient
- Get(ctx, id)               — fetch full message content (text, html,
                               headers, subject, etc.)
- AwaitMessageTo(ctx, to,    — poll until a message arrives or timeout
   timeout)                    (50ms polls, fail-fast on ctx cancel)
- PurgeMessagesTo(ctx, to)   — delete all messages for a recipient

Tests in client_integration_test.go (build tag `integration`):
- RoundTrip: SMTP submit → list → get → assert subject/text — proves
  the BDD-helper contract end-to-end via real SMTP
- AwaitTimeoutWhenNoMessage: bounded wait when no email arrives
- PurgeIsolation: per-recipient delete does NOT affect other recipients

Mailpit API quirk discovered + documented (Q-NNN candidate):
- /api/v1/messages?query=... is for PAGINATION, not filtering — the
  `query` param there is silently ignored for filtering
- /api/v1/search?query=to:<addr> is the correct endpoint for filtering
  AND the matching DELETE
- ADR-0030 + EMAIL.md updated with the correct endpoint

Run integration tests:
  docker compose up -d mailpit
  go test -tags integration -race ./pkg/bdd/mailpit/...

Out of scope for this PR (Phase A.3+):
- pkg/bdd/steps/email_steps.go BDD step definitions
- magic_link_tokens table + repository
- magic-link/request and magic-link/consume HTTP handlers
- BDD scenarios for the magic-link flow
This commit is contained in:
2026-05-05 10:51:14 +02:00
parent ef32e750ed
commit 99989fc9e9
4 changed files with 322 additions and 7 deletions

View File

@@ -0,0 +1,133 @@
//go:build integration
// Integration tests for the Mailpit client. Run with:
//
// go test -tags integration ./pkg/bdd/mailpit/...
//
// Requires a running Mailpit reachable at http://localhost:8025
// (the docker-compose service from ADR-0029).
package mailpit
import (
"context"
"crypto/rand"
"encoding/hex"
"net/smtp"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// uniqueRecipient returns an address unique to this test run, using the
// per-scenario-recipient pattern from ADR-0030. Two parallel test runs
// generate different suffixes so they never see each other's messages.
func uniqueRecipient(t *testing.T) string {
t.Helper()
var raw [4]byte
_, err := rand.Read(raw[:])
require.NoError(t, err)
return "integ-" + t.Name() + "-" + hex.EncodeToString(raw[:]) + "@bdd.local"
}
// sendViaSMTP submits a small email through Mailpit's SMTP port.
// Real-wire-format path : same as the application code will use.
func sendViaSMTP(t *testing.T, to, subject, body string) {
t.Helper()
from := "integ-test@bdd.local"
msg := []byte(
"From: " + from + "\r\n" +
"To: " + to + "\r\n" +
"Subject: " + subject + "\r\n" +
"\r\n" +
body + "\r\n",
)
err := smtp.SendMail("localhost:1025", nil, from, []string{to}, msg)
require.NoError(t, err, "SMTP send to local Mailpit")
}
// TestIntegration_RoundTrip validates the full path : SMTP submit →
// Mailpit captures → client lists → client gets full body. This is
// the smoke test for the BDD-helper contract.
func TestIntegration_RoundTrip(t *testing.T) {
c := NewClient()
to := uniqueRecipient(t)
// Defensive cleanup before the test (in case the recipient was reused)
require.NoError(t, c.PurgeMessagesTo(context.Background(), to))
subject := "Integration roundtrip"
body := "Token: integ-token-" + strings.ReplaceAll(to, "@", "-at-")
sendViaSMTP(t, to, subject, body)
msg, err := c.AwaitMessageTo(context.Background(), to, 3*time.Second)
require.NoError(t, err)
require.NotNil(t, msg)
assert.Equal(t, subject, msg.Subject)
assert.Contains(t, msg.Text, "Token: integ-token-")
if assert.Len(t, msg.To, 1) {
assert.Equal(t, to, msg.To[0].Address)
}
// Cleanup so subsequent runs of this same test name don't accumulate
require.NoError(t, c.PurgeMessagesTo(context.Background(), to))
}
// TestIntegration_AwaitTimeoutWhenNoMessage confirms AwaitMessageTo
// returns an error within the timeout when no message arrives.
func TestIntegration_AwaitTimeoutWhenNoMessage(t *testing.T) {
c := NewClient()
to := uniqueRecipient(t) // never sent to → must time out
start := time.Now()
_, err := c.AwaitMessageTo(context.Background(), to, 200*time.Millisecond)
elapsed := time.Since(start)
require.Error(t, err)
assert.Contains(t, err.Error(), "no message")
assert.GreaterOrEqual(t, elapsed, 150*time.Millisecond, "should poll until close to timeout")
assert.Less(t, elapsed, 1*time.Second, "should not exceed timeout substantially")
}
// TestIntegration_PurgeIsolation proves the per-recipient query/delete
// model from ADR-0030 : two unique recipients can have their own
// messages without one's purge affecting the other.
func TestIntegration_PurgeIsolation(t *testing.T) {
c := NewClient()
// Build two distinct, well-formed addresses (separate local-parts,
// same domain). Avoid mutating uniqueRecipient's output post-@.
var rawA, rawB [4]byte
_, _ = rand.Read(rawA[:])
_, _ = rand.Read(rawB[:])
toA := "iso-a-" + hex.EncodeToString(rawA[:]) + "@bdd.local"
toB := "iso-b-" + hex.EncodeToString(rawB[:]) + "@bdd.local"
sendViaSMTP(t, toA, "for A", "body A")
sendViaSMTP(t, toB, "for B", "body B")
// Both messages should exist
idsA, err := c.MessagesTo(context.Background(), toA)
require.NoError(t, err)
assert.GreaterOrEqual(t, len(idsA), 1, "A should have its message")
idsB, err := c.MessagesTo(context.Background(), toB)
require.NoError(t, err)
assert.GreaterOrEqual(t, len(idsB), 1, "B should have its message")
// Purge A only
require.NoError(t, c.PurgeMessagesTo(context.Background(), toA))
// A is empty, B is untouched
idsA, err = c.MessagesTo(context.Background(), toA)
require.NoError(t, err)
assert.Empty(t, idsA, "A should be empty after purge")
idsB, err = c.MessagesTo(context.Background(), toB)
require.NoError(t, err)
assert.GreaterOrEqual(t, len(idsB), 1, "B should still have its message")
// Cleanup B
require.NoError(t, c.PurgeMessagesTo(context.Background(), toB))
}