247 lines
6.3 KiB
Go
247 lines
6.3 KiB
Go
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")
|
|
}
|
|
}
|