//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)) }