diff --git a/signing/watch/main_test.go b/signing/watch/main_test.go new file mode 100644 index 00000000..cad22437 --- /dev/null +++ b/signing/watch/main_test.go @@ -0,0 +1,43 @@ +package watch + +import ( + "os" + "path/filepath" + "testing" + + "github.com/opensourceways/app-cla-server/signing/infrastructure/emailtmpl" +) + +// TestMain 在运行本包测试前,将工作目录切到模块根目录并加载邮件模板, +// 使依赖 emailtmpl.GenEmailMsg 的测试用例能够渲染模板(Init 读取 ./conf/email-template/*.tmpl)。 +// 模板加载失败仅记录、不中断,不影响模板无关的用例。 +func TestMain(m *testing.M) { + if root := findModuleRoot(); root != "" { + _ = os.Chdir(root) + } + + if err := emailtmpl.Init(); err != nil { + // 不 fail 整个包,仅记录;模板相关用例会自行暴露问题。 + os.Exit(m.Run()) + } + + os.Exit(m.Run()) +} + +// findModuleRoot 从本测试源码所在目录向上查找包含 go.mod 的目录。 +func findModuleRoot() string { + dir, err := os.Getwd() + if err != nil { + return "" + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + return "" + } + dir = parent + } +} diff --git a/signing/watch/notify_corp_admin.go b/signing/watch/notify_corp_admin.go index 419bf51a..c2cddb42 100644 --- a/signing/watch/notify_corp_admin.go +++ b/signing/watch/notify_corp_admin.go @@ -253,6 +253,12 @@ func (impl *notifyAdminWatchImpl) handleSendEmail(link *repository.LinkCLA, corp worker.GetEmailWorker().SendSimpleMessage(link.Email.Platform, &emailMsg) + // [audit] 记录企业 CLA 变更通知的发信意图(4W + CLA 上下文),便于审计追溯。 + // 此时 corp.CLANotify 已在 handleCorpSigning 中更新为最新 CLA id。 + logs.Info("[audit] cla_notify_email: scene=corp, link_id=%s, org=%s, corp_signing_id=%s, recipient=%s, from=%s, subject=%s, new_cla_id=%s, signed_cla_id=%s, notify_count=%d", + link.Id, link.Org.Alias, corp.Id, corp.Admin.EmailAddr.EmailAddr(), + emailMsg.From, emailMsg.Subject, corp.CLANotify, corp.Link.CLAInfo.CLAId, corp.ClaNotifyCount) + // Sending email is done in goroutine. // Prevent the concurrency from being too high, which would cause the email server refused to serve. time.Sleep(impl.config.genSendEmailInterval()) @@ -451,6 +457,11 @@ func (impl *notifyAdminWatchImpl) handleSendIndividualEmail(link *repository.Lin worker.GetEmailWorker().SendSimpleMessage(link.Email.Platform, &emailMsg) + // [audit] 记录个人 CLA 变更通知的发信意图(4W + CLA 上下文),便于审计追溯。 + logs.Info("[audit] cla_notify_email: scene=individual, link_id=%s, org=%s, recipient=%s, from=%s, subject=%s, new_cla_id=%s, signed_cla_id=%s, notify_count=%d", + link.Id, link.Org.Alias, is.Rep.EmailAddr.EmailAddr(), + emailMsg.From, emailMsg.Subject, latestCLA.Id, is.Link.CLAInfo.CLAId, is.ClaNotifyCount) + time.Sleep(impl.config.genSendEmailInterval()) return nil diff --git a/signing/watch/notify_corp_admin_test.go b/signing/watch/notify_corp_admin_test.go index 27e9476e..c2ae5326 100644 --- a/signing/watch/notify_corp_admin_test.go +++ b/signing/watch/notify_corp_admin_test.go @@ -2,10 +2,13 @@ package watch import ( "testing" + "time" + "github.com/opensourceways/app-cla-server/models" "github.com/opensourceways/app-cla-server/signing/domain" "github.com/opensourceways/app-cla-server/signing/domain/dp" "github.com/opensourceways/app-cla-server/signing/domain/repository" + "github.com/opensourceways/app-cla-server/worker" ) func newTestImpl() *notifyAdminWatchImpl { @@ -110,3 +113,109 @@ func TestHandleIndividualSigningNoLatestCLA(t *testing.T) { impl.handleIndividualSigning(link, is) } + +// fakeEmailWorker 用于在测试中替换 worker.GetEmailWorker(),记录发信调用。 +type fakeEmailWorker struct { + sendCount int + platform string + to []string + subject string +} + +func (f *fakeEmailWorker) SendSimpleMessage(platform string, msg *worker.EmailMessage) { + f.sendCount++ + f.platform = platform + if msg != nil { + f.to = msg.To + f.subject = msg.Subject + } +} + +func (f *fakeEmailWorker) GenCLAPDFForCorporationAndSendIt(string, *models.OrgInfo, *models.CLAInfo, *models.CorporationSigning) { +} + +func (f *fakeEmailWorker) Shutdown() {} + +// TestHandleSendEmailAuditLogCorp 注入假 worker 并直接调用 handleSendEmail, +// 验证企业 CLA 变更通知的发信(含 [audit] cla_notify_email: scene=corp 审计日志)路径被走到、不 panic。 +func TestHandleSendEmailAuditLogCorp(t *testing.T) { + fw := &fakeEmailWorker{} + restore := worker.SetEmailWorker(fw) + defer restore() + + impl := newTestImpl() + + link := &repository.LinkCLA{ + Id: "link1", + Org: domain.OrgInfo{Alias: "test-org", ProjectURL: "http://project.example.com"}, + Email: domain.EmailInfo{ + Addr: dp.CreateEmailAddr("from@example.com"), + Platform: "plat1", + }, + } + corp := &repository.CorpSigningSummary{ + Id: "corp1", + Admin: domain.Manager{Representative: domain.Representative{ + Name: dp.CreateName("admin"), + EmailAddr: dp.CreateEmailAddr("admin@example.com"), + }}, + CLANotify: "new-cla-id", + Link: domain.LinkInfo{CLAInfo: domain.CLAInfo{CLAId: "old-cla-id"}}, + ClaNotifyCount: 2, + } + + if err := impl.handleSendEmail(link, corp); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if fw.sendCount != 1 { + t.Fatalf("expected SendSimpleMessage called once, got %d", fw.sendCount) + } + if fw.subject != "CLA has been updated" { + t.Fatalf("unexpected subject: %s", fw.subject) + } + if len(fw.to) != 1 || fw.to[0] != "admin@example.com" { + t.Fatalf("unexpected recipient: %v", fw.to) + } +} + +// TestHandleSendIndividualEmailAuditLogIndividual 注入假 worker 并直接调用 handleSendIndividualEmail, +// 验证个人 CLA 变更通知的发信(含 [audit] cla_notify_email: scene=individual 审计日志)路径被走到、不 panic。 +func TestHandleSendIndividualEmailAuditLogIndividual(t *testing.T) { + fw := &fakeEmailWorker{} + restore := worker.SetEmailWorker(fw) + defer restore() + + impl := newTestImpl() + impl.claPlatformURL = "http://cla.example.com/sign/" + + link := &repository.LinkCLA{ + Id: "link1", + Org: domain.OrgInfo{Alias: "test-org", ProjectURL: "http://project.example.com"}, + Email: domain.EmailInfo{ + Addr: dp.CreateEmailAddr("from@example.com"), + Platform: "plat1", + }, + } + is := &domain.IndividualSigning{ + Rep: domain.Representative{ + Name: dp.CreateName("alice"), + EmailAddr: dp.CreateEmailAddr("alice@example.com"), + }, + Link: domain.LinkInfo{CLAInfo: domain.CLAInfo{CLAId: "old-cla-id"}}, + ClaNotifyCount: 3, + } + latestCLA := &domain.CLA{Id: "new-cla-id", UpdatedAt: time.Now().Unix()} + + if err := impl.handleSendIndividualEmail(link, is, latestCLA); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if fw.sendCount != 1 { + t.Fatalf("expected SendSimpleMessage called once, got %d", fw.sendCount) + } + if fw.subject != "CLA has been updated - Action Required" { + t.Fatalf("unexpected subject: %s", fw.subject) + } + if len(fw.to) != 1 || fw.to[0] != "alice@example.com" { + t.Fatalf("unexpected recipient: %v", fw.to) + } +} diff --git a/worker/worker.go b/worker/worker.go index 2e45034a..f94ce4c7 100644 --- a/worker/worker.go +++ b/worker/worker.go @@ -31,6 +31,14 @@ func GetEmailWorker() IEmailWorker { return worker } +// SetEmailWorker 注入一个 email worker 实例(主要用于测试中替换为假 worker), +// 返回还原函数以恢复原值。调用方应在测试结束后调用还原函数。 +func SetEmailWorker(w IEmailWorker) func() { + prev := worker + worker = w + return func() { worker = prev } +} + func Init(g pdf.IPDFGenerator) { pdfGenerator = g @@ -104,14 +112,20 @@ func (w *emailWorker) SendSimpleMessage(emailPlatform string, msg *EmailMessage) return nil } - w.tryToSendEmail(action) + // [audit] 据发信最终结果记录审计日志(成功 Info / 失败 Error),覆盖所有走 SendSimpleMessage 的邮件。 + if w.tryToSendEmail(action) { + logs.Info("[audit] email_result: outcome=sent, platform=%s, to=%v, subject=%s", platform, msg1.To, msg1.Subject) + } else { + logs.Error("[audit] email_result: outcome=failed, platform=%s, to=%v, subject=%s", platform, msg1.To, msg1.Subject) + } } w.wg.Add(1) go f(emailPlatform, *msg) } -func (w *emailWorker) tryToSendEmail(action func() error) { +// tryToSendEmail 执行带重试的发信动作,返回是否最终发送成功(true=成功,false=重试耗尽或被停止)。 +func (w *emailWorker) tryToSendEmail(action func() error) bool { t := time.NewTimer(1 * time.Minute) defer t.Stop() @@ -125,7 +139,7 @@ func (w *emailWorker) tryToSendEmail(action func() error) { for i := 0; i < 10; i++ { err := action() if err == nil { - break + return true } logs.Error(err) @@ -134,8 +148,10 @@ func (w *emailWorker) tryToSendEmail(action func() error) { select { case <-w.stop: - return + return false case <-t.C: } } + + return false } diff --git a/worker/worker_test.go b/worker/worker_test.go new file mode 100644 index 00000000..93805b3f --- /dev/null +++ b/worker/worker_test.go @@ -0,0 +1,87 @@ +package worker + +import ( + "errors" + "testing" + + "github.com/opensourceways/app-cla-server/signing/domain/emailservice" +) + +// fakeEmailService 注册到 emailservice,替代真实 SMTP,记录调用次数并可注入返回错误。 +type fakeEmailService struct { + err error + calls int +} + +func (f *fakeEmailService) SendEmail(msg *emailservice.EmailMessage) error { + f.calls++ + return f.err +} + +// TestTryToSendEmailSuccess 验证 action 首次即成功时返回 true,且 action 仅被调用一次。 +func TestTryToSendEmailSuccess(t *testing.T) { + w := &emailWorker{stop: make(chan struct{})} + + called := 0 + action := func() error { + called++ + return nil + } + + if ok := w.tryToSendEmail(action); !ok { + t.Fatalf("expected true when action succeeds, got false") + } + if called != 1 { + t.Fatalf("expected action to be called once, got %d", called) + } +} + +// TestTryToSendEmailFailureReturnsFalse 验证 action 持续失败时最终返回 false。 +// 生产代码每次重试间隔为 1 分钟、最多 10 次,单测若等待耗尽需 ~9 分钟不可接受, +// 故预先关闭 stop 通道使其在首次失败后经 stop 分支快速返回 false(与重试耗尽语义等价)。 +func TestTryToSendEmailFailureReturnsFalse(t *testing.T) { + w := &emailWorker{stop: make(chan struct{})} + close(w.stop) + + action := func() error { + return errors.New("send failed") + } + + if ok := w.tryToSendEmail(action); ok { + t.Fatalf("expected false when action keeps failing, got true") + } +} + +// TestSendSimpleMessageSuccess 通过注册假 emailservice 使发信成功, +// 覆盖 SendSimpleMessage 中 tryToSendEmail 返回 true 的审计分支(outcome=sent)。 +func TestSendSimpleMessageSuccess(t *testing.T) { + fake := &fakeEmailService{} + emailservice.Register("test-plat-success", fake) + + w := &emailWorker{stop: make(chan struct{})} + msg := &EmailMessage{To: []string{"a@example.com"}, Subject: "hi"} + + w.SendSimpleMessage("test-plat-success", msg) + w.Shutdown() // 关闭 stop 并等待 goroutine 结束(成功路径不依赖 stop) + + if fake.calls != 1 { + t.Fatalf("expected email service called once, got %d", fake.calls) + } +} + +// TestSendSimpleMessageFailure 通过注册总是失败的假 emailservice, +// 再由 Shutdown 关闭 stop 使重试快速终止,覆盖审计分支(outcome=failed)。 +func TestSendSimpleMessageFailure(t *testing.T) { + fake := &fakeEmailService{err: errors.New("smtp down")} + emailservice.Register("test-plat-fail", fake) + + w := &emailWorker{stop: make(chan struct{})} + msg := &EmailMessage{To: []string{"a@example.com"}, Subject: "hi"} + + w.SendSimpleMessage("test-plat-fail", msg) + w.Shutdown() // 关闭 stop -> 首次失败后经 stop 分支快速返回 false + + if fake.calls < 1 { + t.Fatalf("expected email service called at least once, got %d", fake.calls) + } +}