#3: add embedded-Postgres E2E test validating F1 inbound SMTP against PLAN acceptance criteria
Boots a real Postgres (no Docker), applies the real migration and seed, starts the real go-smtp server wired as main.go does, and drives a live SMTP session. Asserts: 550 for unknown RCPT, durable EML + row with uid_next allocation, sha256 disk/db match, outbox event, blob dedup with ref_count. Documents the counters gap (PLAN 6.1) as an explicit skip. Phase: F1 validation Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6980a08516
commit
097fa373e1
3 files changed
+224
No files matched your search
@@ -0,0 +1,215 @@
|
||||
// Package e2e contains end-to-end validation of the F0/F1 scope declared in
|
||||
// PLAN.md section 16: a real Postgres (embedded, no Docker required), the real
|
||||
// migration, the real SMTP server wired exactly as cmd/lambdamail-protocols
|
||||
// does, and a real SMTP client driving the session.
|
||||
//
|
||||
// Run: go test ./internal/e2e/ -v (downloads Postgres binaries on first run).
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
||||
gosmtp "github.com/emersion/go-smtp"
|
||||
|
||||
"lambdamail/protocols/internal/application/usecase"
|
||||
"lambdamail/protocols/internal/infrastructure/diskstorage"
|
||||
"lambdamail/protocols/internal/infrastructure/postgres"
|
||||
smtppresentation "lambdamail/protocols/internal/presentation/smtp"
|
||||
)
|
||||
|
||||
const testMessage = "From: sender@remote.example\r\n" +
|
||||
"To: postmaster@example.test\r\n" +
|
||||
"Subject: e2e smoke\r\n" +
|
||||
"\r\n" +
|
||||
"F1 inbound validation body.\r\n"
|
||||
|
||||
func TestInboundSMTPEndToEnd(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("e2e test skipped in -short mode")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
// --- 1. Real Postgres via embedded binaries ---------------------------
|
||||
runtimeDir := t.TempDir()
|
||||
pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().
|
||||
Port(54329).
|
||||
RuntimePath(runtimeDir).
|
||||
StartTimeout(120 * time.Second))
|
||||
if err := pg.Start(); err != nil {
|
||||
t.Fatalf("start embedded postgres: %v", err)
|
||||
}
|
||||
defer pg.Stop()
|
||||
|
||||
dbURL := "postgres://postgres:postgres@localhost:54329/postgres?sslmode=disable"
|
||||
pool, err := postgres.NewPool(ctx, dbURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
// --- 2. Real migration + dev seed ------------------------------------
|
||||
root := repoRoot(t)
|
||||
for _, f := range []string{
|
||||
filepath.Join(root, "migrations", "0001_init_schema.up.sql"),
|
||||
filepath.Join(root, "scripts", "dev-seed.sql"),
|
||||
} {
|
||||
sql, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", f, err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, string(sql)); err != nil {
|
||||
t.Fatalf("apply %s: %v", f, err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3. Real server, wired exactly like main.go -----------------------
|
||||
spool := t.TempDir()
|
||||
uc := usecase.NewProcessInboundEmailUseCase(
|
||||
postgres.NewMailboxRepository(pool),
|
||||
diskstorage.NewLocalDiskBlobStorage(pool, spool),
|
||||
postgres.NewInboundMessageRepository(pool),
|
||||
)
|
||||
server := gosmtp.NewServer(smtppresentation.NewBackend(uc))
|
||||
server.Domain = "mail.example.test"
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
go server.Serve(ln)
|
||||
defer server.Close()
|
||||
addr := ln.Addr().String()
|
||||
|
||||
// --- 4. Unknown recipient must be rejected in-session (550) -----------
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
if err := c.Mail("sender@remote.example"); err != nil {
|
||||
t.Fatalf("MAIL FROM: %v", err)
|
||||
}
|
||||
err = c.Rcpt("nobody@example.test")
|
||||
if err == nil {
|
||||
t.Fatal("RCPT for unknown user succeeded; PLAN section 6.1 requires 550 5.1.1")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "550") {
|
||||
t.Errorf("unknown RCPT error = %q, want SMTP 550", err)
|
||||
}
|
||||
c.Close()
|
||||
|
||||
// --- 5. Valid delivery ------------------------------------------------
|
||||
deliver := func() {
|
||||
t.Helper()
|
||||
if err := smtp.SendMail(addr, nil, "sender@remote.example",
|
||||
[]string{"postmaster@example.test"}, []byte(testMessage)); err != nil {
|
||||
t.Fatalf("SendMail: %v", err)
|
||||
}
|
||||
}
|
||||
deliver()
|
||||
|
||||
// --- 6. Durability assertions (PLAN sections 6.1 and 9) ---------------
|
||||
var uid int64
|
||||
var blobPath string
|
||||
var sizeBytes int64
|
||||
err = pool.QueryRow(ctx, `
|
||||
SELECT m.uid, b.storage_path, b.size_bytes
|
||||
FROM email_messages m JOIN message_blobs b ON b.id = m.blob_id
|
||||
WHERE m.sender_address = 'sender@remote.example'
|
||||
ORDER BY m.uid LIMIT 1
|
||||
`).Scan(&uid, &blobPath, &sizeBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("query persisted message: %v", err)
|
||||
}
|
||||
if uid != 1 {
|
||||
t.Errorf("first uid = %d, want 1 (folders.uid_next allocation)", uid)
|
||||
}
|
||||
raw, err := os.ReadFile(blobPath)
|
||||
if err != nil {
|
||||
t.Fatalf("EML file missing on disk: %v", err)
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
var dbSum string
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT content_sha256 FROM message_blobs LIMIT 1`).Scan(&dbSum); err != nil {
|
||||
t.Fatalf("blob row: %v", err)
|
||||
}
|
||||
if hex.EncodeToString(sum[:]) != dbSum {
|
||||
t.Errorf("on-disk sha256 %x != db %s", sum, dbSum)
|
||||
}
|
||||
|
||||
var outboxCount int
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM domain_events_outbox WHERE event_type = 'EmailReceived'`).Scan(&outboxCount); err != nil {
|
||||
t.Fatalf("outbox: %v", err)
|
||||
}
|
||||
if outboxCount != 1 {
|
||||
t.Errorf("outbox events = %d, want 1", outboxCount)
|
||||
}
|
||||
|
||||
// --- 7. Dedup: identical message again => 1 blob, ref_count 2, uid 2 --
|
||||
deliver()
|
||||
var blobs, refs int
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT count(*), max(ref_count) FROM message_blobs`).Scan(&blobs, &refs); err != nil {
|
||||
t.Fatalf("dedup query: %v", err)
|
||||
}
|
||||
if blobs != 1 || refs != 2 {
|
||||
t.Errorf("blobs=%d refs=%d, want 1 blob with ref_count 2 (PLAN section 9 dedup)", blobs, refs)
|
||||
}
|
||||
var maxUID int64
|
||||
if err := pool.QueryRow(ctx, `SELECT max(uid) FROM email_messages`).Scan(&maxUID); err != nil {
|
||||
t.Fatalf("uid query: %v", err)
|
||||
}
|
||||
if maxUID != 2 {
|
||||
t.Errorf("max uid = %d, want 2 (sequential per folder)", maxUID)
|
||||
}
|
||||
|
||||
// --- 8. PLAN gaps this test documents deliberately --------------------
|
||||
// The following FAIL today and are expected to: they mark the F1 scope
|
||||
// that is not implemented yet. Remove t.Skip as each lands.
|
||||
t.Run("counters_updated", func(t *testing.T) {
|
||||
var unread, total int
|
||||
var used int64
|
||||
err := pool.QueryRow(ctx, `
|
||||
SELECT f.unread_count, f.total_count, mb.used_bytes
|
||||
FROM folders f JOIN mailboxes mb ON mb.id = f.mailbox_id
|
||||
WHERE f.special_use = 'inbox' AND mb.email_address = 'postmaster@example.test'
|
||||
`).Scan(&unread, &total, &used)
|
||||
if err != nil {
|
||||
t.Fatalf("counters query: %v", err)
|
||||
}
|
||||
if unread != 2 || total != 2 || used != sizeBytes*2 {
|
||||
t.Skipf("KNOWN GAP (PLAN 6.1 '++counters'): unread=%d total=%d used=%d — repository does not update folder counters or mailboxes.used_bytes yet", unread, total, used)
|
||||
}
|
||||
})
|
||||
|
||||
_ = fmt.Sprintf // keep fmt imported if assertions above change
|
||||
}
|
||||
|
||||
// repoRoot walks up from the test file location to the repository root.
|
||||
func repoRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 8; i++ {
|
||||
if _, err := os.Stat(filepath.Join(dir, "PLAN.md")); err == nil {
|
||||
return dir
|
||||
}
|
||||
dir = filepath.Dir(dir)
|
||||
}
|
||||
t.Fatal("could not locate repository root (PLAN.md)")
|
||||
return ""
|
||||
}
|
||||
Reference in new issue
Block a user