Files
hfb_sys/backend/internal/jobs/listingstale/job_test.go
T

236 lines
7.2 KiB
Go

package listingstale
import (
"context"
"testing"
"time"
"hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/sms"
"go.uber.org/zap"
"gorm.io/gorm"
)
type fakeConfigReader struct {
value string
}
func (r *fakeConfigReader) FindValue(ctx context.Context, key string) (string, error) {
if key != sms.ConfigKey {
return "", gorm.ErrRecordNotFound
}
return r.value, nil
}
func staleConfig(t *testing.T, enabled bool, staleDays int) string {
t.Helper()
raw, err := sms.MarshalConfig(sms.Config{
Enabled: true,
Nodes: map[string]sms.NodeConfig{
sms.NodeListingStalePriceSuggestion: {
Enabled: enabled,
TemplateCode: "SMS_512620985",
StaleDays: staleDays,
},
},
})
if err != nil {
t.Fatalf("MarshalConfig() error = %v", err)
}
return raw
}
func newStaleTestDB(t *testing.T) *gorm.DB {
t.Helper()
db := database.NewTestDB()
if err := database.AutoMigrateIfMissing(db, &model.SmsMessage{}, &model.RentalListing{}, &model.User{}); err != nil {
t.Fatalf("AutoMigrate() error = %v", err)
}
return db
}
func seedListing(t *testing.T, db *gorm.DB, listing model.RentalListing) model.RentalListing {
t.Helper()
if err := db.Create(&listing).Error; err != nil {
t.Fatalf("创建商品失败: %v", err)
}
return listing
}
func TestRunOnceEnqueuesOnlyListingsJustCrossingThreshold(t *testing.T) {
db := newStaleTestDB(t)
ctx := context.Background()
owner := model.User{Phone: "13900000001", Nickname: "号主"}
if err := db.Create(&owner).Error; err != nil {
t.Fatalf("创建用户失败: %v", err)
}
now := time.Now()
justStale := now.Add(-3*24*time.Hour - 6*time.Hour) // 刚跨过 3 天阈值,窗口内
longStale := now.Add(-10 * 24 * time.Hour) // 窗口外,功能上线前的历史滞销账号
fresh := now.Add(-24 * time.Hour) // 还没到阈值
staleAt := now.Add(-3*24*time.Hour - 6*time.Hour)
// 命中:上架满 3 天、仍在架、未成交。
hit := seedListing(t, db, model.RentalListing{
ListingNo: "L-HIT", OwnerID: owner.ID, Status: "published", PublishedAt: &justStale,
})
// 窗口外:上架过久,避免功能上线时批量轰炸。
seedListing(t, db, model.RentalListing{
ListingNo: "L-OLD", OwnerID: owner.ID, Status: "published", PublishedAt: &longStale,
})
// 未到阈值。
seedListing(t, db, model.RentalListing{
ListingNo: "L-FRESH", OwnerID: owner.ID, Status: "published", PublishedAt: &fresh,
})
// 已成交(在租)。
seedListing(t, db, model.RentalListing{
ListingNo: "L-RENTED", OwnerID: owner.ID, Status: "rented", PublishedAt: &staleAt,
})
// 交易中。
seedListing(t, db, model.RentalListing{
ListingNo: "L-INCHARGE", OwnerID: owner.ID, Status: "published", InTransaction: true, PublishedAt: &staleAt,
})
job := New(db, nil, zap.NewNop(), &fakeConfigReader{value: staleConfig(t, true, 3)})
count, err := job.RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce() error = %v", err)
}
if count != 1 {
t.Fatalf("RunOnce() = %d, want 1", count)
}
var rows []model.SmsMessage
if err := db.Where("node = ?", sms.NodeListingStalePriceSuggestion).Find(&rows).Error; err != nil {
t.Fatalf("查询短信记录失败: %v", err)
}
if len(rows) != 1 {
t.Fatalf("短信记录数 = %d, want 1", len(rows))
}
if rows[0].BizID != hit.ID || rows[0].BizType != "listing" {
t.Fatalf("业务对象 = %s/%d, want listing/%d", rows[0].BizType, rows[0].BizID, hit.ID)
}
if rows[0].UserID != owner.ID || rows[0].Phone != owner.Phone {
t.Fatalf("接收人 = %d/%s, want %d/%s", rows[0].UserID, rows[0].Phone, owner.ID, owner.Phone)
}
// 再次执行应因唯一索引去重而不重复入队。
count, err = job.RunOnce(ctx)
if err != nil {
t.Fatalf("重复 RunOnce() error = %v", err)
}
if count != 0 {
t.Fatalf("重复 RunOnce() = %d, want 0(同一账号只提醒一次)", count)
}
var total int64
if err := db.Model(&model.SmsMessage{}).Count(&total).Error; err != nil {
t.Fatalf("统计失败: %v", err)
}
if total != 1 {
t.Fatalf("短信记录总数 = %d, want 1", total)
}
}
// 同一候选窗口内的账号数超过批次上限时,已提醒过的账号不能继续占用批次名额,
// 否则排在后面的账号会在窗口结束后被永久漏发。
func TestRunOnceDoesNotStarveListingsBeyondBatchSize(t *testing.T) {
db := newStaleTestDB(t)
ctx := context.Background()
owner := model.User{Phone: "13900000004"}
if err := db.Create(&owner).Error; err != nil {
t.Fatalf("创建用户失败: %v", err)
}
staleAt := time.Now().Add(-3*24*time.Hour - 6*time.Hour)
expected := map[uint64]bool{}
for _, no := range []string{"L-BATCH-1", "L-BATCH-2", "L-BATCH-3"} {
listing := seedListing(t, db, model.RentalListing{
ListingNo: no, OwnerID: owner.ID, Status: "published", PublishedAt: &staleAt,
})
expected[listing.ID] = true
}
job := New(db, nil, zap.NewNop(), &fakeConfigReader{value: staleConfig(t, true, 3)})
job.batchSize = 1
for round := 1; round <= len(expected); round++ {
count, err := job.RunOnce(ctx)
if err != nil {
t.Fatalf("第 %d 轮 RunOnce() error = %v", round, err)
}
if count != 1 {
t.Fatalf("第 %d 轮 RunOnce() = %d, want 1(已提醒的账号不应占满批次名额)", round, count)
}
}
var rows []model.SmsMessage
if err := db.Where("node = ?", sms.NodeListingStalePriceSuggestion).Find(&rows).Error; err != nil {
t.Fatalf("查询短信记录失败: %v", err)
}
if len(rows) != len(expected) {
t.Fatalf("短信记录数 = %d, want %d", len(rows), len(expected))
}
for _, row := range rows {
if !expected[row.BizID] {
t.Fatalf("出现未预期的业务对象 %d", row.BizID)
}
delete(expected, row.BizID)
}
if len(expected) != 0 {
t.Fatalf("仍有账号未被提醒: %v", expected)
}
}
func TestRunOnceSkipsWhenNodeInactive(t *testing.T) {
db := newStaleTestDB(t)
ctx := context.Background()
owner := model.User{Phone: "13900000002"}
if err := db.Create(&owner).Error; err != nil {
t.Fatalf("创建用户失败: %v", err)
}
staleAt := time.Now().Add(-3*24*time.Hour - 6*time.Hour)
seedListing(t, db, model.RentalListing{
ListingNo: "L-OFF", OwnerID: owner.ID, Status: "published", PublishedAt: &staleAt,
})
job := New(db, nil, zap.NewNop(), &fakeConfigReader{value: staleConfig(t, false, 3)})
count, err := job.RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce() error = %v", err)
}
if count != 0 {
t.Fatalf("节点关闭时 RunOnce() = %d, want 0", count)
}
}
func TestRunOnceRespectsConfiguredStaleDays(t *testing.T) {
db := newStaleTestDB(t)
ctx := context.Background()
owner := model.User{Phone: "13900000003"}
if err := db.Create(&owner).Error; err != nil {
t.Fatalf("创建用户失败: %v", err)
}
// 上架 3.5 天:阈值为 3 天时命中,阈值调成 5 天则不应命中。
staleAt := time.Now().Add(-3*24*time.Hour - 12*time.Hour)
seedListing(t, db, model.RentalListing{
ListingNo: "L-DAYS", OwnerID: owner.ID, Status: "published", PublishedAt: &staleAt,
})
job := New(db, nil, zap.NewNop(), &fakeConfigReader{value: staleConfig(t, true, 5)})
count, err := job.RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce() error = %v", err)
}
if count != 0 {
t.Fatalf("阈值 5 天时 RunOnce() = %d, want 0", count)
}
}