package notify import ( "context" "errors" "net/smtp" "strings" "sync" "testing" "github.com/google/uuid" "github.com/stretchr/testify/require" ) // fakePrefsRepo 是 PrefsRepository 的内存实现,供 notifier 测试断言偏好过滤。 type fakePrefsRepo struct { mu sync.Mutex m map[uuid.UUID]Prefs err error } func newFakePrefs() *fakePrefsRepo { return &fakePrefsRepo{m: map[uuid.UUID]Prefs{}} } func (f *fakePrefsRepo) set(p Prefs) { f.mu.Lock() defer f.mu.Unlock() f.m[p.UserID] = p } func (f *fakePrefsRepo) Get(_ context.Context, userID uuid.UUID) (Prefs, error) { if f.err != nil { return Prefs{}, f.err } f.mu.Lock() defer f.mu.Unlock() if p, ok := f.m[userID]; ok { return p, nil } return DefaultPrefs(userID), nil } func (f *fakePrefsRepo) Upsert(_ context.Context, p Prefs) error { if f.err != nil { return f.err } f.set(p) return nil } // captureTransport 记录最后一次 SMTP 发送,便于断言收件人 / 报文形态。 type captureTransport struct { mu sync.Mutex calls int addr string from string to []string msg []byte auth smtp.Auth } func (c *captureTransport) Send(addr string, auth smtp.Auth, from string, to []string, msg []byte) error { c.mu.Lock() defer c.mu.Unlock() c.calls++ c.addr = addr c.from = from c.to = to c.msg = msg c.auth = auth return nil } // staticLookup 总是返回固定收件邮箱。 type staticLookup struct { email string err error } func (s staticLookup) EmailForUser(context.Context, uuid.UUID) (string, error) { return s.email, s.err } func emailCfg() EmailConfig { return EmailConfig{Enabled: true, SMTPHost: "smtp.test", Port: 587, From: "noreply@test"} } func TestEmailNotifier_SendsWhenEnabledAndSeverityMet(t *testing.T) { uid := uuid.New() prefs := newFakePrefs() prefs.set(Prefs{UserID: uid, EmailEnabled: true, MinSeverity: SeverityWarning}) tr := &captureTransport{} n := NewEmailNotifier(emailCfg(), prefs, staticLookup{email: "user@test"}, tr, newTestLogger()) err := n.Send(context.Background(), Message{ UserID: uid, Topic: "acp.permission_pending", Severity: SeverityWarning, Title: "Approval needed", Body: "session X wants to run rm", Link: "/approvals", }) require.NoError(t, err) require.Equal(t, 1, tr.calls) require.Equal(t, "smtp.test:587", tr.addr) require.Equal(t, "noreply@test", tr.from) require.Equal(t, []string{"user@test"}, tr.to) require.Nil(t, tr.auth, "无 username 时不应携带 auth") body := string(tr.msg) require.Contains(t, body, "To: user@test") require.Contains(t, body, "Subject: [WARNING] Approval needed") require.Contains(t, body, "session X wants to run rm") require.Contains(t, body, "/approvals") } func TestEmailNotifier_DisabledChannelSkipped(t *testing.T) { uid := uuid.New() prefs := newFakePrefs() prefs.set(Prefs{UserID: uid, EmailEnabled: false, MinSeverity: SeverityInfo}) tr := &captureTransport{} n := NewEmailNotifier(emailCfg(), prefs, staticLookup{email: "user@test"}, tr, newTestLogger()) require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError})) require.Equal(t, 0, tr.calls, "email_enabled=false 应跳过发送") } func TestEmailNotifier_BelowMinSeveritySkipped(t *testing.T) { uid := uuid.New() prefs := newFakePrefs() prefs.set(Prefs{UserID: uid, EmailEnabled: true, MinSeverity: SeverityError}) tr := &captureTransport{} n := NewEmailNotifier(emailCfg(), prefs, staticLookup{email: "user@test"}, tr, newTestLogger()) require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityWarning})) require.Equal(t, 0, tr.calls, "低于 min_severity 应跳过") require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError})) require.Equal(t, 1, tr.calls, "达到 min_severity 应发送") } func TestEmailNotifier_GloballyDisabledSkipped(t *testing.T) { uid := uuid.New() prefs := newFakePrefs() prefs.set(Prefs{UserID: uid, EmailEnabled: true, MinSeverity: SeverityInfo}) tr := &captureTransport{} cfg := emailCfg() cfg.Enabled = false n := NewEmailNotifier(cfg, prefs, staticLookup{email: "user@test"}, tr, newTestLogger()) require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError})) require.Equal(t, 0, tr.calls) } func TestEmailNotifier_NoRecipientSkipped(t *testing.T) { uid := uuid.New() prefs := newFakePrefs() prefs.set(Prefs{UserID: uid, EmailEnabled: true, MinSeverity: SeverityInfo}) tr := &captureTransport{} n := NewEmailNotifier(emailCfg(), prefs, staticLookup{email: " "}, tr, newTestLogger()) require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError})) require.Equal(t, 0, tr.calls, "空收件人应静默跳过") } func TestEmailNotifier_UsesPlainAuthWhenUsernameSet(t *testing.T) { uid := uuid.New() prefs := newFakePrefs() prefs.set(Prefs{UserID: uid, EmailEnabled: true, MinSeverity: SeverityInfo}) tr := &captureTransport{} cfg := emailCfg() cfg.Username = "u" cfg.Password = "p" n := NewEmailNotifier(cfg, prefs, staticLookup{email: "user@test"}, tr, newTestLogger()) require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError, Title: "x"})) require.Equal(t, 1, tr.calls) require.NotNil(t, tr.auth, "配置 username 时应携带 PlainAuth") } func TestEmailNotifier_NameIsEmailChannel(t *testing.T) { n := NewEmailNotifier(emailCfg(), newFakePrefs(), staticLookup{}, &captureTransport{}, newTestLogger()) require.Equal(t, ChannelEmail, n.Name()) } func TestBuildEmailMessage_Headers(t *testing.T) { body := string(buildEmailMessage("from@test", "to@test", Message{ Topic: "t", Severity: SeverityError, Title: "Title", Body: "Body", Link: "http://l", })) require.True(t, strings.HasPrefix(body, "From: from@test\r\n")) require.Contains(t, body, "Subject: [ERROR] Title") require.Contains(t, body, "Content-Type: text/plain") } func TestEmailNotifier_PrefsErrorPropagates(t *testing.T) { prefs := newFakePrefs() prefs.err = errors.New("db down") n := NewEmailNotifier(emailCfg(), prefs, staticLookup{email: "u@t"}, &captureTransport{}, newTestLogger()) require.Error(t, n.Send(context.Background(), Message{UserID: uuid.New(), Severity: SeverityError})) }