✨ 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:
@@ -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.
|
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:<scenario-address>` 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:<scenario-address>` on Mailpit to purge any leftover messages from prior runs.
|
||||||
|
|
||||||
### Option 2: One Mailpit instance per Go test package
|
### Option 2: One Mailpit instance per Go test package
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ func (s *EmailSteps) theEmailContainsAMagicLinkToken() (string, error)
|
|||||||
|
|
||||||
### Race-free deletion
|
### Race-free deletion
|
||||||
|
|
||||||
Mailpit's `DELETE /api/v1/messages?query=to:<addr>` is atomic per recipient. Two concurrent scenarios with different addresses cannot interfere.
|
Mailpit's `DELETE /api/v1/search?query=to:<addr>` is atomic per recipient. Two concurrent scenarios with different addresses cannot interfere.
|
||||||
|
|
||||||
### Sample scenario (auth-magic-link.feature)
|
### Sample scenario (auth-magic-link.feature)
|
||||||
|
|
||||||
|
|||||||
@@ -43,17 +43,19 @@ http://localhost:8025 — list of all captured messages, search, raw view, HTML
|
|||||||
### HTTP API (for automation)
|
### HTTP API (for automation)
|
||||||
|
|
||||||
```bash
|
```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
|
curl -s 'http://localhost:8025/api/v1/messages?limit=10' | jq
|
||||||
|
|
||||||
# Messages for a specific recipient (used by BDD tests, cf. ADR-0030)
|
# Messages for a specific recipient — use /api/v1/search, NOT /messages
|
||||||
curl -s 'http://localhost:8025/api/v1/messages?query=to:test-user@bdd.local' | jq
|
# (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)
|
# Get a specific message by ID (full content, headers, attachments)
|
||||||
curl -s 'http://localhost:8025/api/v1/message/<id>' | jq
|
curl -s 'http://localhost:8025/api/v1/message/<id>' | jq
|
||||||
|
|
||||||
# Purge messages for a recipient (used in test cleanup)
|
# Purge messages for a recipient (used in test cleanup) — also via /search
|
||||||
curl -X DELETE 'http://localhost:8025/api/v1/messages?query=to:test-user@bdd.local'
|
curl -X DELETE 'http://localhost:8025/api/v1/search?query=to:test-user@bdd.local'
|
||||||
```
|
```
|
||||||
|
|
||||||
Full API: https://mailpit.axllent.org/docs/api-v1/
|
Full API: https://mailpit.axllent.org/docs/api-v1/
|
||||||
|
|||||||
180
pkg/bdd/mailpit/client.go
Normal file
180
pkg/bdd/mailpit/client.go
Normal file
@@ -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:<addr> 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:<addr> 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
|
||||||
|
}
|
||||||
133
pkg/bdd/mailpit/client_integration_test.go
Normal file
133
pkg/bdd/mailpit/client_integration_test.go
Normal 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))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user