package notices import ( "context" "database/sql" "errors" "sync" "testing" "time" ) // fakeMailer 记录每次 Send 调用,便于断言收件人集合与失败模拟。 type fakeMailer struct { mu sync.Mutex sent []string // to failTo map[string]bool } func (f *fakeMailer) Send(ctx context.Context, to, subject, body string) error { f.mu.Lock() defer f.mu.Unlock() if f.failTo != nil && f.failTo[to] { return errors.New("fake smtp send failure") } f.sent = append(f.sent, to) return nil } type auditRow struct { Target string Meta string } func auditRows(t *testing.T, db *sql.DB, action string) []auditRow { t.Helper() rows, err := db.Query(`SELECT target, COALESCE(meta,'') FROM audit_log WHERE action=? ORDER BY id`, action) if err != nil { t.Fatal(err) } defer rows.Close() var out []auditRow for rows.Next() { var r auditRow if err := rows.Scan(&r.Target, &r.Meta); err != nil { t.Fatal(err) } out = append(out, r) } return out } func TestPublish_ValidationErrors(t *testing.T) { db := openDB(t) st := NewStore(db) svc := NewService(st, nil, db) ctx := context.Background() cases := []struct { name string in PublishInput }{ {"bad type", PublishInput{Type: "spam", TitleZH: "标题", TitleEN: "title"}}, {"empty title zh", PublishInput{Type: "news", TitleZH: "", TitleEN: "title"}}, {"empty title en", PublishInput{Type: "news", TitleZH: "标题", TitleEN: ""}}, {"email non-important", PublishInput{Type: "news", TitleZH: "标题", TitleEN: "title", Email: true}}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if _, err := svc.Publish(ctx, c.in); err == nil { t.Fatalf("want error, got nil") } }) } } func TestPublish_SuccessNoEmail(t *testing.T) { db := openDB(t) seedU(t, db, 1, "u1") st := NewStore(db) svc := NewService(st, nil, db) ctx := context.Background() id, err := svc.Publish(ctx, PublishInput{ Type: "news", TitleZH: "标题", TitleEN: "title", BodyZH: "正文", BodyEN: "body", }) if err != nil { t.Fatalf("Publish: %v", err) } if id == 0 { t.Fatalf("want non-zero id") } items, _, err := st.ListForUser(ctx, 1, time.Now().UTC(), 50) if err != nil { t.Fatal(err) } found := false for _, it := range items { if it.ID == id { found = true } } if !found { t.Fatalf("published notice not visible via ListForUser: %+v", items) } rows := auditRows(t, db, "notice_publish") if len(rows) != 1 { t.Fatalf("want 1 audit row, got %d: %+v", len(rows), rows) } } func TestPublish_EmailImportant_AllActiveUsersNotified(t *testing.T) { db := openDB(t) seedU(t, db, 1, "u1") seedU(t, db, 2, "u2") st := NewStore(db) mailer := &fakeMailer{} svc := NewService(st, mailer, db) ctx := context.Background() id, err := svc.Publish(ctx, PublishInput{ Type: "important", TitleZH: "重要通知", TitleEN: "Important notice", BodyZH: "维护中", BodyEN: "under maintenance", Email: true, }) if err != nil { t.Fatalf("Publish: %v", err) } mailer.mu.Lock() sentCount := len(mailer.sent) mailer.mu.Unlock() if sentCount != 2 { t.Fatalf("want 2 emails sent (all active users), got %d: %+v", sentCount, mailer.sent) } var emailSentAt sql.NullTime if err := db.QueryRow(`SELECT email_sent_at FROM notices WHERE id=?`, id).Scan(&emailSentAt); err != nil { t.Fatal(err) } if !emailSentAt.Valid { t.Fatalf("email_sent_at not set after Publish with Email=true") } // 再次 Publish 是新公告(幂等的是单公告不重发,不是全局):第二条也应各自发全量邮件。 id2, err := svc.Publish(ctx, PublishInput{ Type: "important", TitleZH: "第二条", TitleEN: "Second notice", Email: true, }) if err != nil { t.Fatalf("Publish #2: %v", err) } if id2 == id { t.Fatalf("want new notice id, got same as first") } mailer.mu.Lock() sentCount2 := len(mailer.sent) mailer.mu.Unlock() if sentCount2 != 4 { t.Fatalf("want 4 emails total after 2nd publish, got %d: %+v", sentCount2, mailer.sent) } } func TestPublish_EmailSendFailure_ContinuesAndStillMarksSent(t *testing.T) { db := openDB(t) seedU(t, db, 1, "u1") seedU(t, db, 2, "u2") st := NewStore(db) mailer := &fakeMailer{failTo: map[string]bool{"u1@x": true}} svc := NewService(st, mailer, db) ctx := context.Background() id, err := svc.Publish(ctx, PublishInput{ Type: "important", TitleZH: "重要", TitleEN: "Important", Email: true, }) if err != nil { t.Fatalf("Publish should not fail even if one recipient's send fails: %v", err) } mailer.mu.Lock() sent := append([]string(nil), mailer.sent...) mailer.mu.Unlock() if len(sent) != 1 || sent[0] != "u2@x" { t.Fatalf("want only u2@x to have succeeded, got %+v", sent) } var emailSentAt sql.NullTime if err := db.QueryRow(`SELECT email_sent_at FROM notices WHERE id=?`, id).Scan(&emailSentAt); err != nil { t.Fatal(err) } if !emailSentAt.Valid { t.Fatalf("email_sent_at should still be set after best-effort send attempt") } } func TestPublish_EmailTrueWithNilMailer_ReturnsError(t *testing.T) { db := openDB(t) st := NewStore(db) svc := NewService(st, nil, db) ctx := context.Background() if _, err := svc.Publish(ctx, PublishInput{ Type: "important", TitleZH: "标题", TitleEN: "title", Email: true, }); err == nil { t.Fatalf("want error when mailer is nil and Email=true") } } func TestRevokeByID_HidesFromListAndAudits(t *testing.T) { db := openDB(t) seedU(t, db, 1, "u1") st := NewStore(db) svc := NewService(st, nil, db) ctx := context.Background() id, err := svc.Publish(ctx, PublishInput{Type: "news", TitleZH: "标题", TitleEN: "title"}) if err != nil { t.Fatal(err) } items, _, _ := st.ListForUser(ctx, 1, time.Now().UTC(), 50) if len(items) != 1 { t.Fatalf("expected notice visible before revoke, got %d", len(items)) } if err := svc.RevokeByID(ctx, id); err != nil { t.Fatalf("RevokeByID: %v", err) } items, _, _ = st.ListForUser(ctx, 1, time.Now().UTC(), 50) if len(items) != 0 { t.Fatalf("expected no notices visible after revoke, got %+v", items) } rows := auditRows(t, db, "notice_revoke") if len(rows) != 1 { t.Fatalf("want 1 audit row for revoke, got %d: %+v", len(rows), rows) } } func TestRevokeByID_NotFound(t *testing.T) { db := openDB(t) st := NewStore(db) svc := NewService(st, nil, db) if err := svc.RevokeByID(context.Background(), 9999); err == nil { t.Fatalf("want error for nonexistent notice id") } }