Files
hfb_sys/backend/internal/jobs/smsdispatch/job_test.go
T
yml2213 b7b68abf6b test(sms): 修正重试用例因 DATETIME(0) 取整导致的时间敏感失败
updated_at 为 DATETIME(0),写库会把小数秒向上取整到下一秒,退避设为 0 时刚失败的记录会被
取数条件 updated_at <= now-退避 取不到,导致 TestDispatchRetriesThenFails 必挂;退避量改为 -2s 保证连续重试可确定执行。
2026-10-15 18:21:18 +08:00

400 lines
12 KiB
Go

package smsdispatch
import (
"context"
"errors"
"testing"
"time"
"hfb_sys/backend/internal/database"
smsprovider "hfb_sys/backend/internal/integrations/sms"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/sms"
"go.uber.org/zap"
"gorm.io/datatypes"
"gorm.io/gorm"
)
type fakeProvider struct {
sent []smsprovider.TemplateRequest
err error
}
func (p *fakeProvider) SendLoginCode(ctx context.Context, phone string, code string) error {
return nil
}
func (p *fakeProvider) SendTemplate(ctx context.Context, req smsprovider.TemplateRequest) error {
p.sent = append(p.sent, req)
return p.err
}
type fakeConfigReader struct {
value string
err error
}
func (r *fakeConfigReader) FindValue(ctx context.Context, key string) (string, error) {
if r.err != nil {
return "", r.err
}
if key != sms.ConfigKey {
return "", gorm.ErrRecordNotFound
}
return r.value, nil
}
type fakeSiteResolver struct {
provider smsprovider.Provider
signName string
templates map[string]string
err error
}
func (r *fakeSiteResolver) ResolveBusinessSender(ctx context.Context, siteID uint64) (smsprovider.Provider, string, map[string]string, error) {
return r.provider, r.signName, r.templates, r.err
}
func newDispatchTestDB(t *testing.T) *gorm.DB {
t.Helper()
db := database.NewTestDB()
if err := database.AutoMigrateIfMissing(db, &model.SmsMessage{}); err != nil {
t.Fatalf("AutoMigrate() error = %v", err)
}
return db
}
func configValue(t *testing.T, nodes map[string]sms.NodeConfig) string {
t.Helper()
raw, err := sms.MarshalConfig(sms.Config{Enabled: true, Nodes: nodes})
if err != nil {
t.Fatalf("MarshalConfig() error = %v", err)
}
return raw
}
func seedPending(t *testing.T, db *gorm.DB, row model.SmsMessage) model.SmsMessage {
t.Helper()
if row.Status == "" {
row.Status = model.SmsStatusPending
}
if row.Phone == "" {
row.Phone = "13800000000"
}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("创建待发送记录失败: %v", err)
}
return row
}
func loadRow(t *testing.T, db *gorm.DB, id uint64) model.SmsMessage {
t.Helper()
var row model.SmsMessage
if err := db.First(&row, id).Error; err != nil {
t.Fatalf("查询短信记录失败: %v", err)
}
return row
}
func TestDispatchUsesMainChannelWhenNoSite(t *testing.T) {
db := newDispatchTestDB(t)
mainProvider := &fakeProvider{}
reader := &fakeConfigReader{value: configValue(t, map[string]sms.NodeConfig{
sms.NodeOrderHandoffAssist: {Enabled: true, TemplateCode: "SMS_512540945"},
})}
job := New(db, nil, zap.NewNop(), mainProvider, "大锤商行", reader, nil)
row := seedPending(t, db, model.SmsMessage{
UserID: 1,
Phone: "13800000001",
Node: sms.NodeOrderHandoffAssist,
BizType: "order",
BizID: 1001,
})
handled, err := job.dispatch(context.Background())
if err != nil {
t.Fatalf("dispatch() error = %v", err)
}
if handled != 1 {
t.Fatalf("dispatch() handled = %d, want 1", handled)
}
if len(mainProvider.sent) != 1 {
t.Fatalf("主站通道发送次数 = %d, want 1", len(mainProvider.sent))
}
req := mainProvider.sent[0]
if req.TemplateCode != "SMS_512540945" || req.SignName != "大锤商行" || req.Phone != "13800000001" {
t.Fatalf("请求 = %+v", req)
}
got := loadRow(t, db, row.ID)
if got.Status != model.SmsStatusSent || got.TemplateCode != "SMS_512540945" || got.SignName != "大锤商行" || got.SentAt == nil {
t.Fatalf("落库记录 = %+v", got)
}
}
func TestDispatchPrefersSiteChannelWhenTemplateConfigured(t *testing.T) {
db := newDispatchTestDB(t)
mainProvider := &fakeProvider{}
siteProvider := &fakeProvider{}
siteID := uint64(9)
reader := &fakeConfigReader{value: configValue(t, map[string]sms.NodeConfig{
sms.NodeOrderHandoffAssist: {Enabled: true, TemplateCode: "SMS_MAIN"},
})}
resolver := &fakeSiteResolver{
provider: siteProvider,
signName: "分站签名",
templates: map[string]string{sms.NodeOrderHandoffAssist: "SMS_SITE"},
}
job := New(db, nil, zap.NewNop(), mainProvider, "大锤商行", reader, resolver)
row := seedPending(t, db, model.SmsMessage{
UserID: 2,
Node: sms.NodeOrderHandoffAssist,
BizType: "order",
BizID: 1002,
DistributionSiteID: &siteID,
})
if _, err := job.dispatch(context.Background()); err != nil {
t.Fatalf("dispatch() error = %v", err)
}
if len(mainProvider.sent) != 0 {
t.Fatalf("分站已配置模板时不应使用主站通道")
}
if len(siteProvider.sent) != 1 {
t.Fatalf("分站通道发送次数 = %d, want 1", len(siteProvider.sent))
}
if req := siteProvider.sent[0]; req.TemplateCode != "SMS_SITE" || req.SignName != "分站签名" {
t.Fatalf("分站请求 = %+v", req)
}
if got := loadRow(t, db, row.ID); got.TemplateCode != "SMS_SITE" || got.SignName != "分站签名" {
t.Fatalf("分站落库记录 = %+v", got)
}
}
func TestDispatchFallsBackToMainWhenSiteTemplateMissing(t *testing.T) {
db := newDispatchTestDB(t)
mainProvider := &fakeProvider{}
siteProvider := &fakeProvider{}
siteID := uint64(10)
reader := &fakeConfigReader{value: configValue(t, map[string]sms.NodeConfig{
sms.NodeListingReviewRejected: {Enabled: true, TemplateCode: "SMS_MAIN"},
})}
resolver := &fakeSiteResolver{
provider: siteProvider,
signName: "分站签名",
templates: map[string]string{sms.NodeOrderHandoffAssist: "SMS_SITE"},
}
job := New(db, nil, zap.NewNop(), mainProvider, "大锤商行", reader, resolver)
row := seedPending(t, db, model.SmsMessage{
UserID: 3,
Node: sms.NodeListingReviewRejected,
BizType: "listing",
BizID: 1003,
DistributionSiteID: &siteID,
})
if _, err := job.dispatch(context.Background()); err != nil {
t.Fatalf("dispatch() error = %v", err)
}
if len(siteProvider.sent) != 0 {
t.Fatalf("分站未配置该节点模板时不应走分站通道")
}
if len(mainProvider.sent) != 1 || mainProvider.sent[0].TemplateCode != "SMS_MAIN" {
t.Fatalf("应回退主站模板,实际 = %+v", mainProvider.sent)
}
if got := loadRow(t, db, row.ID); got.Status != model.SmsStatusSent {
t.Fatalf("status = %q, want sent", got.Status)
}
}
func TestDispatchFallsBackToMainWhenSiteResolutionFails(t *testing.T) {
db := newDispatchTestDB(t)
mainProvider := &fakeProvider{}
siteID := uint64(11)
reader := &fakeConfigReader{value: configValue(t, map[string]sms.NodeConfig{
sms.NodeOrderHandoffAssist: {Enabled: true, TemplateCode: "SMS_MAIN"},
})}
resolver := &fakeSiteResolver{err: errors.New("解密失败")}
job := New(db, nil, zap.NewNop(), mainProvider, "大锤商行", reader, resolver)
seedPending(t, db, model.SmsMessage{
UserID: 4,
Node: sms.NodeOrderHandoffAssist,
BizType: "order",
BizID: 1004,
DistributionSiteID: &siteID,
})
if _, err := job.dispatch(context.Background()); err != nil {
t.Fatalf("dispatch() error = %v", err)
}
if len(mainProvider.sent) != 1 {
t.Fatalf("分站解析失败应回退主站通道,实际发送 %d 次", len(mainProvider.sent))
}
}
func TestDispatchSkipsWhenTemplateMissing(t *testing.T) {
db := newDispatchTestDB(t)
mainProvider := &fakeProvider{}
reader := &fakeConfigReader{value: configValue(t, map[string]sms.NodeConfig{
sms.NodeOrderHandoffAssist: {Enabled: true, TemplateCode: ""},
})}
job := New(db, nil, zap.NewNop(), mainProvider, "大锤商行", reader, nil)
row := seedPending(t, db, model.SmsMessage{
UserID: 5,
Node: sms.NodeOrderHandoffAssist,
BizType: "order",
BizID: 1005,
})
if _, err := job.dispatch(context.Background()); err != nil {
t.Fatalf("dispatch() error = %v", err)
}
if len(mainProvider.sent) != 0 {
t.Fatalf("未配置模板时不应发送")
}
got := loadRow(t, db, row.ID)
if got.Status != model.SmsStatusSkipped || got.SkipReason == "" {
t.Fatalf("应标记 skipped 并写原因,实际 = %+v", got)
}
}
func TestDispatchSkipsWhenNodeDisabled(t *testing.T) {
db := newDispatchTestDB(t)
mainProvider := &fakeProvider{}
reader := &fakeConfigReader{value: configValue(t, map[string]sms.NodeConfig{
sms.NodeOrderHandoffAssist: {Enabled: false, TemplateCode: "SMS_512540945"},
})}
job := New(db, nil, zap.NewNop(), mainProvider, "大锤商行", reader, nil)
row := seedPending(t, db, model.SmsMessage{
UserID: 6,
Node: sms.NodeOrderHandoffAssist,
BizType: "order",
BizID: 1006,
})
if _, err := job.dispatch(context.Background()); err != nil {
t.Fatalf("dispatch() error = %v", err)
}
if len(mainProvider.sent) != 0 {
t.Fatalf("节点关闭时不应发送")
}
if got := loadRow(t, db, row.ID); got.Status != model.SmsStatusSkipped {
t.Fatalf("status = %q, want skipped", got.Status)
}
}
func TestDispatchRetriesThenFails(t *testing.T) {
db := newDispatchTestDB(t)
mainProvider := &fakeProvider{err: errors.New("渠道异常")}
reader := &fakeConfigReader{value: configValue(t, map[string]sms.NodeConfig{
sms.NodeOrderHandoffAssist: {Enabled: true, TemplateCode: "SMS_512540945"},
})}
job := New(db, nil, zap.NewNop(), mainProvider, "大锤商行", reader, nil)
// 退避设为负值,便于在测试内连续重试。
// updated_at 是 DATETIME(0),写库时小数秒会向上取整到下一秒(如 .690 → 下一秒),
// 若把退避设为 0,刚失败的记录会被取数条件 updated_at <= now-退避 误判为「仍在退避中」而取不到。
job.retryDelay = -2 * time.Second
row := seedPending(t, db, model.SmsMessage{
UserID: 7,
Node: sms.NodeOrderHandoffAssist,
BizType: "order",
BizID: 1007,
})
if _, err := job.dispatch(context.Background()); err != nil {
t.Fatalf("第一次 dispatch() error = %v", err)
}
got := loadRow(t, db, row.ID)
if got.Status != model.SmsStatusPending || got.RetryCount != 1 || got.LastError == "" {
t.Fatalf("首次失败应留待重试,实际 = %+v", got)
}
// 第二次达到 maxRetry(3) 前仍是 pending,第三次用尽后置为 failed。
if _, err := job.dispatch(context.Background()); err != nil {
t.Fatalf("第二次 dispatch() error = %v", err)
}
if _, err := job.dispatch(context.Background()); err != nil {
t.Fatalf("第三次 dispatch() error = %v", err)
}
got = loadRow(t, db, row.ID)
if got.Status != model.SmsStatusFailed || got.RetryCount != job.maxRetry {
t.Fatalf("重试用尽后应标记 failed,实际 = %+v", got)
}
if len(mainProvider.sent) != job.maxRetry {
t.Fatalf("发送尝试次数 = %d, want %d", len(mainProvider.sent), job.maxRetry)
}
}
func TestDispatchPassesTemplateParams(t *testing.T) {
db := newDispatchTestDB(t)
mainProvider := &fakeProvider{}
reader := &fakeConfigReader{value: configValue(t, map[string]sms.NodeConfig{
sms.NodeOrderHandoffAssist: {Enabled: true, TemplateCode: "SMS_512540945"},
})}
job := New(db, nil, zap.NewNop(), mainProvider, "大锤商行", reader, nil)
seedPending(t, db, model.SmsMessage{
UserID: 8,
Node: sms.NodeOrderHandoffAssist,
BizType: "order",
BizID: 1008,
TemplateParams: datatypes.JSON([]byte(`{"brand_name":"大锤商行"}`)),
})
if _, err := job.dispatch(context.Background()); err != nil {
t.Fatalf("dispatch() error = %v", err)
}
if len(mainProvider.sent) != 1 {
t.Fatalf("发送次数 = %d, want 1", len(mainProvider.sent))
}
if got := mainProvider.sent[0].Params["brand_name"]; got != "大锤商行" {
t.Fatalf("params.brand_name = %q, want 大锤商行", got)
}
}
func TestDecodeParamsInvalidJSONFallsBackToNil(t *testing.T) {
if params := decodeParams(datatypes.JSON([]byte(`{oops`))); params != nil {
t.Fatalf("非法 JSON 应回落 nil, got %+v", params)
}
if params := decodeParams(datatypes.JSON([]byte(`{}`))); params != nil {
t.Fatalf("空对象应回落 nil, got %+v", params)
}
if params := decodeParams(nil); params != nil {
t.Fatalf("nil 应回落 nil, got %+v", params)
}
}
func TestDispatchIgnoresRecordsInRetryBackoff(t *testing.T) {
db := newDispatchTestDB(t)
mainProvider := &fakeProvider{}
reader := &fakeConfigReader{value: configValue(t, map[string]sms.NodeConfig{
sms.NodeOrderHandoffAssist: {Enabled: true, TemplateCode: "SMS_512540945"},
})}
job := New(db, nil, zap.NewNop(), mainProvider, "大锤商行", reader, nil)
job.retryDelay = time.Hour
seedPending(t, db, model.SmsMessage{
UserID: 12,
Node: sms.NodeOrderHandoffAssist,
BizType: "order",
BizID: 1012,
Status: model.SmsStatusPending,
RetryCount: 1,
})
if _, err := job.dispatch(context.Background()); err != nil {
t.Fatalf("dispatch() error = %v", err)
}
if len(mainProvider.sent) != 0 {
t.Fatalf("退避窗口内不应重试")
}
}