From 99989fc9e982beb8e04d66d8074924d7632d1b95 Mon Sep 17 00:00:00 2001 From: Gabriel Radureau Date: Tue, 5 May 2026 10:51:14 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(bdd):=20pkg/bdd/mailpit/=20HTT?= =?UTF-8?q?P=20client=20+=20integration=20tests=20(ADR-0030=20Phase=20A.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: 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 --- adr/0030-bdd-email-parallel-strategy.md | 4 +- documentation/EMAIL.md | 12 +- pkg/bdd/mailpit/client.go | 180 +++++++++++++++++++++ pkg/bdd/mailpit/client_integration_test.go | 133 +++++++++++++++ 4 files changed, 322 insertions(+), 7 deletions(-) create mode 100644 pkg/bdd/mailpit/client.go create mode 100644 pkg/bdd/mailpit/client_integration_test.go diff --git a/adr/0030-bdd-email-parallel-strategy.md b/adr/0030-bdd-email-parallel-strategy.md index dcea6ad..a803c6c 100644 --- a/adr/0030-bdd-email-parallel-strategy.md +++ b/adr/0030-bdd-email-parallel-strategy.md @@ -36,7 +36,7 @@ Each BDD scenario generates a unique email address for its test user, derived fr The application code accepts any email format. The BDD scenario asserts on Mailpit's HTTP API filtering by the `to` address. Two parallel scenarios with different addresses can NEVER see each other's emails. -**Cleanup** : at the start of each scenario, the BDD framework calls `DELETE /api/v1/messages?query=to:` on Mailpit to purge any leftover messages from prior runs. +**Cleanup** : at the start of each scenario, the BDD framework calls `DELETE /api/v1/search?query=to:` on Mailpit to purge any leftover messages from prior runs. ### Option 2: One Mailpit instance per Go test package @@ -127,7 +127,7 @@ func (s *EmailSteps) theEmailContainsAMagicLinkToken() (string, error) ### Race-free deletion -Mailpit's `DELETE /api/v1/messages?query=to:` is atomic per recipient. Two concurrent scenarios with different addresses cannot interfere. +Mailpit's `DELETE /api/v1/search?query=to:` is atomic per recipient. Two concurrent scenarios with different addresses cannot interfere. ### Sample scenario (auth-magic-link.feature) diff --git a/documentation/EMAIL.md b/documentation/EMAIL.md index 207ed75..1459010 100644 --- a/documentation/EMAIL.md +++ b/documentation/EMAIL.md @@ -43,17 +43,19 @@ http://localhost:8025 — list of all captured messages, search, raw view, HTML ### HTTP API (for automation) ```bash -# Latest 10 messages +# Latest 10 messages (no filter — /api/v1/messages is for pagination) curl -s 'http://localhost:8025/api/v1/messages?limit=10' | jq -# Messages for a specific recipient (used by BDD tests, cf. ADR-0030) -curl -s 'http://localhost:8025/api/v1/messages?query=to:test-user@bdd.local' | jq +# Messages for a specific recipient — use /api/v1/search, NOT /messages +# (the latter's `query` param is for pagination only, not filtering ; +# verified empirically 2026-05-05) +curl -s 'http://localhost:8025/api/v1/search?query=to:test-user@bdd.local' | jq # Get a specific message by ID (full content, headers, attachments) curl -s 'http://localhost:8025/api/v1/message/' | jq -# Purge messages for a recipient (used in test cleanup) -curl -X DELETE 'http://localhost:8025/api/v1/messages?query=to:test-user@bdd.local' +# Purge messages for a recipient (used in test cleanup) — also via /search +curl -X DELETE 'http://localhost:8025/api/v1/search?query=to:test-user@bdd.local' ``` Full API: https://mailpit.axllent.org/docs/api-v1/ diff --git a/pkg/bdd/mailpit/client.go b/pkg/bdd/mailpit/client.go new file mode 100644 index 0000000..faa3059 --- /dev/null +++ b/pkg/bdd/mailpit/client.go @@ -0,0 +1,180 @@ +// Package mailpit is a thin client for the local Mailpit HTTP API, +// used by BDD scenarios to assert on emails sent during a test. +// +// Per ADR-0030 (BDD email parallel strategy), each scenario uses a +// unique recipient address so parallel scenarios cannot interfere. +// The client exposes per-recipient query + delete + await operations. +// +// Production code MUST NOT depend on this package. It lives under +// pkg/bdd/ specifically to signal "test-only". +package mailpit + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +// DefaultBaseURL is the local Mailpit HTTP API root used by the +// docker-compose service (cf. ADR-0029). +const DefaultBaseURL = "http://localhost:8025" + +// Client is a Mailpit HTTP API client. Safe for concurrent use. +type Client struct { + BaseURL string + HTTP *http.Client +} + +// NewClient returns a Client pointing at the local Mailpit. The HTTP +// client has a 5-second per-call timeout to fail fast in test setups +// where Mailpit is down. +func NewClient() *Client { + return &Client{ + BaseURL: DefaultBaseURL, + HTTP: &http.Client{Timeout: 5 * time.Second}, + } +} + +// Message is the metadata + body view returned by the Mailpit detail +// endpoint. Fields are a subset of what Mailpit returns — only what +// BDD scenarios need to assert on. +type Message struct { + ID string `json:"ID"` + From Address `json:"From"` + To []Address `json:"To"` + Subject string `json:"Subject"` + Text string `json:"Text"` + HTML string `json:"HTML"` + Date time.Time `json:"Date"` + Headers map[string]interface{} `json:"-"` // populated only via the Headers() helper +} + +// Address is a Mailpit-formatted email address. +type Address struct { + Name string `json:"Name"` + Address string `json:"Address"` +} + +// listResponse is the shape of GET /api/v1/messages. +type listResponse struct { + Messages []messageSummary `json:"messages"` + Total int `json:"total"` +} + +type messageSummary struct { + ID string `json:"ID"` + Subject string `json:"Subject"` + Created time.Time `json:"Created"` +} + +// MessagesTo returns the list of message IDs currently in Mailpit +// addressed to the given recipient. Empty slice + nil error means +// "no messages yet". +func (c *Client) MessagesTo(ctx context.Context, to string) ([]string, error) { + // Mailpit's /api/v1/search supports the to: filter ; the more + // obvious-looking /api/v1/messages does NOT (the `query` param there + // is for pagination, not filtering — verified empirically 2026-05-05). + u := fmt.Sprintf("%s/api/v1/search?query=%s", + strings.TrimRight(c.BaseURL, "/"), + url.QueryEscape("to:"+to)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("mailpit list: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("mailpit list: HTTP %d", resp.StatusCode) + } + var list listResponse + if err := json.NewDecoder(resp.Body).Decode(&list); err != nil { + return nil, fmt.Errorf("mailpit list decode: %w", err) + } + ids := make([]string, 0, len(list.Messages)) + for _, m := range list.Messages { + ids = append(ids, m.ID) + } + return ids, nil +} + +// Get fetches the full content of the message with the given ID. +func (c *Client) Get(ctx context.Context, id string) (*Message, error) { + u := fmt.Sprintf("%s/api/v1/message/%s", + strings.TrimRight(c.BaseURL, "/"), + url.PathEscape(id)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("mailpit get: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("mailpit get %s: HTTP %d", id, resp.StatusCode) + } + var m Message + if err := json.NewDecoder(resp.Body).Decode(&m); err != nil { + return nil, fmt.Errorf("mailpit get decode: %w", err) + } + return &m, nil +} + +// AwaitMessageTo polls Mailpit for a message addressed to the given +// recipient. Returns the most recent matching message ; errors out if +// the timeout elapses with no match. Polls every 50ms — Mailpit is +// fast enough that this is rarely the limiting factor. +// +// Use this in BDD steps "Then I should receive an email ...". +func (c *Client) AwaitMessageTo(ctx context.Context, to string, timeout time.Duration) (*Message, error) { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + ids, err := c.MessagesTo(ctx, to) + if err == nil && len(ids) > 0 { + // Most recent first per Mailpit's default sort + return c.Get(ctx, ids[0]) + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(50 * time.Millisecond): + } + } + return nil, fmt.Errorf("mailpit: no message for %s within %s", to, timeout) +} + +// PurgeMessagesTo deletes every message addressed to the given recipient. +// Idempotent: calling against an empty inbox is fine. +// +// Use this at the start of a BDD scenario to clear leftovers from +// prior runs of the same scenario (rare given the random suffix per +// scenario, but defensive). +func (c *Client) PurgeMessagesTo(ctx context.Context, to string) error { + // Mailpit's /api/v1/search supports the to: filter ; the more + // obvious-looking /api/v1/messages does NOT (the `query` param there + // is for pagination, not filtering — verified empirically 2026-05-05). + u := fmt.Sprintf("%s/api/v1/search?query=%s", + strings.TrimRight(c.BaseURL, "/"), + url.QueryEscape("to:"+to)) + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u, nil) + if err != nil { + return err + } + resp, err := c.HTTP.Do(req) + if err != nil { + return fmt.Errorf("mailpit delete: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("mailpit delete: HTTP %d", resp.StatusCode) + } + return nil +} diff --git a/pkg/bdd/mailpit/client_integration_test.go b/pkg/bdd/mailpit/client_integration_test.go new file mode 100644 index 0000000..779dfda --- /dev/null +++ b/pkg/bdd/mailpit/client_integration_test.go @@ -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)) +} -- 2.49.1