//go:build integration package integration_test import ( "context" "encoding/json" "io" "net/http" "strconv" "strings" "testing" "time" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" "github.com/alchemistkay/guestguard/internal/notification" ) // startMailpit launches a Mailpit container and returns (smtpHost, smtpPort, // httpBaseURL). The HTTP API is what we query to assert delivery. func startMailpit(t *testing.T, ctx context.Context) (string, int, string) { t.Helper() req := testcontainers.ContainerRequest{ Image: "axllent/mailpit:latest", ExposedPorts: []string{"1025/tcp", "8025/tcp"}, WaitingFor: wait.ForListeningPort("8025/tcp").WithStartupTimeout(45 * time.Second), } c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: req, Started: true, }) must(t, err, "start mailpit container") t.Cleanup(func() { _ = c.Terminate(context.Background()) }) host, err := c.Host(ctx) must(t, err, "mailpit host") smtpMP, err := c.MappedPort(ctx, "1025/tcp") must(t, err, "mailpit smtp port") httpMP, err := c.MappedPort(ctx, "8025/tcp") must(t, err, "mailpit http port") port, _ := strconv.Atoi(smtpMP.Port()) return host, port, "http://" + host + ":" + httpMP.Port() } // TestSMTPSenderAgainstMailpit sends a real verification email via the // SMTP adapter and asserts the Mailpit HTTP API saw it land in the inbox. // This is the closest thing to "did a real email arrive" we can run in CI. func TestSMTPSenderAgainstMailpit(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in -short mode") } ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) t.Cleanup(cancel) smtpHost, smtpPort, httpBase := startMailpit(t, ctx) tpls, err := notification.NewTemplates() must(t, err, "templates") sender, err := notification.NewSMTPEmailSender(notification.SMTPConfig{ Host: smtpHost, Port: smtpPort, FromEmail: "noreply@guestguard.local", FromName: "GuestGuard (dev)", TLS: "none", // mailpit accepts plain SMTP }, tpls) must(t, err, "smtp sender") must(t, sender.SendVerification(ctx, "kay@example.test", "Kay", "http://localhost:3000/verify-email?token=demo"), "send") // Mailpit exposes a /api/v1/messages list endpoint. Poll briefly since // SMTP delivery is async. var found bool deadline := time.Now().Add(10 * time.Second) for time.Now().Before(deadline) { resp, err := http.Get(httpBase + "/api/v1/messages") must(t, err, "mailpit list") body, _ := io.ReadAll(resp.Body) resp.Body.Close() var list struct { Messages []struct { Subject string `json:"Subject"` To []struct { Address string `json:"Address"` } `json:"To"` ID string `json:"ID"` } `json:"messages"` } if err := json.Unmarshal(body, &list); err != nil { t.Fatalf("decode mailpit list: %v body=%s", err, body) } for _, m := range list.Messages { if m.Subject == "Verify your GuestGuard email" { for _, to := range m.To { if to.Address == "kay@example.test" { found = true // Fetch the full message and confirm the verification // link survived through MIME encoding. full, err := http.Get(httpBase + "/api/v1/message/" + m.ID) must(t, err, "fetch message") b, _ := io.ReadAll(full.Body) full.Body.Close() if !strings.Contains(string(b), "verify-email?token=demo") { t.Errorf("verification link missing from body: %s", b) } break } } } } if found { break } time.Sleep(200 * time.Millisecond) } if !found { t.Fatalf("did not see verification email in mailpit within 10s") } }