package smtp import ( "bytes" "context" "errors" "go/ast" "go/parser" "go/token" "os" "path/filepath" "strings" "testing" "testing/fstest" "code.nochebuena.dev/einherjar/contracts/lifecycle" "code.nochebuena.dev/einherjar/contracts/logging" "code.nochebuena.dev/einherjar/contracts/observability" "code.nochebuena.dev/einherjar/core/xerrors" ) // Compile-time interface checks (CT-5 / I-8). var _ lifecycle.Component = (Component)(nil) var _ observability.Checkable = (Component)(nil) var _ Sender = (Component)(nil) // --- CT-6: at most one exported TypeSpec per non-test, non-doc file --- func TestAtMostOneExportedTypePerFile(t *testing.T) { fset := token.NewFileSet() pkgs, err := parser.ParseDir(fset, ".", func(fi os.FileInfo) bool { name := fi.Name() return !strings.HasSuffix(name, "_test.go") && name != "doc.go" }, 0) if err != nil { t.Fatalf("parse: %v", err) } for _, pkg := range pkgs { for path, file := range pkg.Files { base := filepath.Base(path) count := 0 for _, decl := range file.Decls { gd, ok := decl.(*ast.GenDecl) if !ok { continue } for _, spec := range gd.Specs { ts, ok := spec.(*ast.TypeSpec) if ok && ts.Name.IsExported() { count++ } } } if count > 1 { t.Errorf("%s: %d exported TypeSpecs (max 1)", base, count) } } } } // --- Config defaults (S-4) --- func TestDefaultConfig_OptionalFields(t *testing.T) { cfg := DefaultConfig() if cfg.Port == 0 { t.Error("Port must have a default") } } // --- New: noop when host empty --- func TestNew_ReturnsNoopWhenHostEmpty(t *testing.T) { c := New(newLogger(), Config{}) if c == nil { t.Fatal("New returned nil") } // noop: Name and Priority are still defined if c.Name() != "smtp" { t.Errorf("Name() = %q, want smtp", c.Name()) } } func TestNew_ReturnsClientWhenHostSet(t *testing.T) { c := New(newLogger(), Config{Host: "mail.example.com", Port: 587}) if c == nil { t.Fatal("New returned nil") } if _, ok := c.(*smtpClient); !ok { t.Errorf("expected *smtpClient, got %T", c) } } // --- noopClient --- func TestNoopClient_Send_ReturnsNil(t *testing.T) { c := New(newLogger(), Config{}) err := c.Send(context.Background(), Message{Subject: "test", To: []string{"a@b.com"}}) if err != nil { t.Errorf("noop Send must return nil, got %v", err) } } func TestNoopClient_HealthCheck_ReturnsNil(t *testing.T) { c := New(newLogger(), Config{}) if err := c.HealthCheck(context.Background()); err != nil { t.Errorf("noop HealthCheck must return nil, got %v", err) } } func TestNoopClient_Priority_IsDegraded(t *testing.T) { c := New(newLogger(), Config{}) if c.Priority() != observability.LevelDegraded { t.Error("Priority() must be LevelDegraded") } } func TestNoopClient_Lifecycle_ReturnsNil(t *testing.T) { c := New(newLogger(), Config{}) if err := c.OnInit(); err != nil { t.Errorf("OnInit: %v", err) } if err := c.OnStart(); err != nil { t.Errorf("OnStart: %v", err) } if err := c.OnStop(); err != nil { t.Errorf("OnStop: %v", err) } } // --- smtpClient metadata --- func TestSmtpClient_Priority_IsDegraded(t *testing.T) { c := New(newLogger(), Config{Host: "mail.example.com", Port: 587}) if c.Priority() != observability.LevelDegraded { t.Error("Priority() must be LevelDegraded") } } func TestSmtpClient_Name(t *testing.T) { c := New(newLogger(), Config{Host: "mail.example.com", Port: 587}) if c.Name() != "smtp" { t.Errorf("Name() = %q, want smtp", c.Name()) } } // --- buildRawMessage --- func TestBuildRawMessage_NoAttachments_PlainText(t *testing.T) { msg := Message{ To: []string{"bob@example.com"}, Subject: "Hello", Body: "plain body", ContentType: "text/plain", } raw, err := buildRawMessage("alice@example.com", msg) if err != nil { t.Fatalf("buildRawMessage: %v", err) } s := string(raw) for _, want := range []string{"From: alice@example.com", "To: bob@example.com", "Subject: Hello", "plain body"} { if !strings.Contains(s, want) { t.Errorf("raw message missing %q", want) } } } func TestBuildRawMessage_NoAttachments_HTML(t *testing.T) { msg := Message{ To: []string{"bob@example.com"}, Subject: "Receipt", Body: "Hello", ContentType: "text/html", } raw, err := buildRawMessage("alice@example.com", msg) if err != nil { t.Fatalf("buildRawMessage: %v", err) } if !strings.Contains(string(raw), "text/html") { t.Error("raw message missing text/html content type") } } func TestBuildRawMessage_WithCC_ReplyTo(t *testing.T) { msg := Message{ To: []string{"bob@example.com"}, CC: []string{"carol@example.com"}, ReplyTo: "noreply@example.com", Subject: "FYI", Body: "body", } raw, err := buildRawMessage("alice@example.com", msg) if err != nil { t.Fatalf("buildRawMessage: %v", err) } s := string(raw) if !strings.Contains(s, "Cc: carol@example.com") { t.Error("missing Cc header") } if !strings.Contains(s, "Reply-To: noreply@example.com") { t.Error("missing Reply-To header") } } func TestBuildRawMessage_WithAttachment(t *testing.T) { msg := Message{ To: []string{"bob@example.com"}, Subject: "Report", Body: "

See attached

", ContentType: "text/html", Attachments: []Attachment{ { Name: "report.pdf", ContentType: "application/pdf", Data: bytes.NewReader([]byte("PDF content")), }, }, } raw, err := buildRawMessage("alice@example.com", msg) if err != nil { t.Fatalf("buildRawMessage: %v", err) } s := string(raw) if !strings.Contains(s, "multipart/mixed") { t.Error("missing multipart/mixed content type") } if !strings.Contains(s, "report.pdf") { t.Error("missing attachment filename") } if !strings.Contains(s, "application/pdf") { t.Error("missing attachment content type") } } func TestBuildRawMessage_BCC_NotInHeaders(t *testing.T) { msg := Message{ To: []string{"bob@example.com"}, BCC: []string{"hidden@example.com"}, Subject: "Secret", Body: "body", } raw, err := buildRawMessage("alice@example.com", msg) if err != nil { t.Fatalf("buildRawMessage: %v", err) } if strings.Contains(string(raw), "hidden@example.com") { t.Error("BCC recipient must not appear in message headers") } } // --- Template --- func TestTemplate_Render(t *testing.T) { fsys := fstest.MapFS{ "email.html": &fstest.MapFile{ Data: []byte(`Hello, {{.Name}}!`), }, } tmpl, err := ParseFS(fsys, "email.html") if err != nil { t.Fatalf("ParseFS: %v", err) } result, err := tmpl.Render("email.html", map[string]string{"Name": "Alice"}) if err != nil { t.Fatalf("Render: %v", err) } if !strings.Contains(result, "Alice") { t.Errorf("want Alice in rendered output, got %q", result) } } func TestTemplate_Render_UnknownTemplate(t *testing.T) { fsys := fstest.MapFS{ "email.html": &fstest.MapFile{Data: []byte(`Hello!`)}, } tmpl, _ := ParseFS(fsys, "email.html") _, err := tmpl.Render("does-not-exist.html", nil) if err == nil { t.Fatal("expected error for unknown template name") } var xe *xerrors.Err if !errors.As(err, &xe) { t.Errorf("expected *xerrors.Err, got %T", err) } } func TestTemplate_ParseFS_Error(t *testing.T) { fsys := fstest.MapFS{} _, err := ParseFS(fsys, "nonexistent.html") if err == nil { t.Fatal("expected error for missing template file") } } // --- helpers --- type stubLogger struct{ warned bool } func newLogger() *stubLogger { return &stubLogger{} } func (s *stubLogger) Debug(msg string, args ...any) {} func (s *stubLogger) Info(msg string, args ...any) {} func (s *stubLogger) Warn(msg string, args ...any) { s.warned = true } func (s *stubLogger) Error(msg string, err error, args ...any) {} func (s *stubLogger) With(args ...any) logging.Logger { return s } func (s *stubLogger) WithContext(ctx context.Context) logging.Logger { return s }