Files
junhong_cmp_fiber/internal/query/packageexpiry/query_test.go
2026-07-23 15:09:24 +09:00

219 lines
9.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package packageexpiry
import (
"context"
"testing"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/testutil"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"gorm.io/gorm"
)
func TestCalculate(t *testing.T) {
location := time.FixedZone("Asia/Shanghai", 8*60*60)
now := time.Date(2026, 7, 23, 12, 0, 0, 0, location)
currentExpiry := time.Date(2026, 7, 31, 23, 59, 59, 0, location)
createdAt := time.Date(2026, 7, 1, 12, 0, 0, 0, location)
newUsage := func(id uint, status int, priority int) *model.PackageUsage {
return &model.PackageUsage{
Model: modelBase(id, createdAt.Add(time.Duration(id)*time.Second)),
PackageID: id,
Status: status,
Priority: priority,
ExpiryBaseSnapshot: constants.PackageExpiryBaseFromActivation,
CalendarTypeSnapshot: constants.PackageCalendarTypeByDay,
DurationDaysSnapshot: 3,
}
}
tests := []struct {
name string
usages []*model.PackageUsage
packages map[uint]*model.Package
wantStatus string
wantDate *time.Time
wantDays *int
}{
{name: "无套餐", wantStatus: constants.PackageExpiryEstimateStatusNone},
{
name: "当前套餐", usages: func() []*model.PackageUsage {
u := newUsage(1, constants.PackageUsageStatusActive, 1)
u.ExpiresAt = &currentExpiry
return []*model.PackageUsage{u}
}(), wantStatus: constants.PackageExpiryEstimateStatusExact, wantDate: &currentExpiry, wantDays: intPointer(8),
},
{
name: "多段队列按下一时刻接续", usages: func() []*model.PackageUsage {
current := newUsage(1, constants.PackageUsageStatusActive, 1)
current.ExpiresAt = &currentExpiry
queued := newUsage(2, constants.PackageUsageStatusPending, 2)
queued.DurationDaysSnapshot = 3
return []*model.PackageUsage{current, queued}
}(), wantStatus: constants.PackageExpiryEstimateStatusExact,
wantDate: timePointer(time.Date(2026, 8, 4, 23, 59, 59, 0, location)), wantDays: intPointer(12),
},
{
name: "自然月月末", usages: func() []*model.PackageUsage {
current := newUsage(1, constants.PackageUsageStatusActive, 1)
current.ExpiresAt = &currentExpiry
queued := newUsage(2, constants.PackageUsageStatusPending, 2)
queued.CalendarTypeSnapshot = constants.PackageCalendarTypeNaturalMonth
queued.DurationDaysSnapshot = 0
queued.DurationMonthsSnapshot = 1
return []*model.PackageUsage{current, queued}
}(), wantStatus: constants.PackageExpiryEstimateStatusExact,
wantDate: timePointer(time.Date(2026, 9, 30, 23, 59, 59, 0, location)), wantDays: intPointer(69),
},
{
name: "等待实名激活", usages: func() []*model.PackageUsage {
u := newUsage(1, constants.PackageUsageStatusPending, 1)
u.PendingRealnameActivation = true
return []*model.PackageUsage{u}
}(), wantStatus: constants.PackageExpiryEstimateStatusWaitingActivation,
},
{
name: "历史空快照回退", usages: func() []*model.PackageUsage {
current := newUsage(1, constants.PackageUsageStatusActive, 1)
current.ExpiresAt = &currentExpiry
queued := newUsage(2, constants.PackageUsageStatusPending, 2)
queued.ExpiryBaseSnapshot, queued.CalendarTypeSnapshot, queued.DurationDaysSnapshot = "", "", 0
return []*model.PackageUsage{current, queued}
}(), packages: map[uint]*model.Package{2: {ExpiryBase: constants.PackageExpiryBaseFromActivation, CalendarType: constants.PackageCalendarTypeByDay, DurationDays: 3}},
wantStatus: constants.PackageExpiryEstimateStatusExact,
},
{
name: "非法快照", usages: func() []*model.PackageUsage {
current := newUsage(1, constants.PackageUsageStatusActive, 1)
current.ExpiresAt = &currentExpiry
queued := newUsage(2, constants.PackageUsageStatusPending, 2)
queued.CalendarTypeSnapshot = "bad"
return []*model.PackageUsage{current, queued}
}(), wantStatus: constants.PackageExpiryEstimateStatusInvalidData,
},
{
name: "已过期退款软删除加油包均排除", usages: func() []*model.PackageUsage {
expired := newUsage(1, constants.PackageUsageStatusExpired, 1)
invalidated := newUsage(2, constants.PackageUsageStatusInvalidated, 2)
refundID := uint(3)
refunded := newUsage(3, constants.PackageUsageStatusActive, 3)
refunded.RefundID = &refundID
deleted := newUsage(4, constants.PackageUsageStatusActive, 4)
deleted.DeletedAt = gorm.DeletedAt{Time: now, Valid: true}
masterID := uint(1)
addon := newUsage(5, constants.PackageUsageStatusActive, 5)
addon.MasterUsageID = &masterID
return []*model.PackageUsage{expired, invalidated, refunded, deleted, addon}
}(), wantStatus: constants.PackageExpiryEstimateStatusNone,
},
{
name: "多个当前套餐为异常数据", usages: func() []*model.PackageUsage {
first := newUsage(1, constants.PackageUsageStatusActive, 1)
first.ExpiresAt = &currentExpiry
second := newUsage(2, constants.PackageUsageStatusDepleted, 2)
second.ExpiresAt = &currentExpiry
return []*model.PackageUsage{first, second}
}(), wantStatus: constants.PackageExpiryEstimateStatusInvalidData,
},
{
name: "已过期当前套餐返回负数自然日", usages: func() []*model.PackageUsage {
expiry := time.Date(2026, 7, 20, 23, 59, 59, 0, location)
u := newUsage(1, constants.PackageUsageStatusActive, 1)
u.ExpiresAt = &expiry
return []*model.PackageUsage{u}
}(), wantStatus: constants.PackageExpiryEstimateStatusExact, wantDays: intPointer(-3),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Calculate(tt.usages, tt.packages, now)
if got.ExpiryEstimateStatus != tt.wantStatus {
t.Fatalf("状态错误want=%s got=%s", tt.wantStatus, got.ExpiryEstimateStatus)
}
if tt.wantDate != nil && (got.EstimatedFinalExpiresAt == nil || !got.EstimatedFinalExpiresAt.Equal(*tt.wantDate)) {
t.Fatalf("到期时间错误want=%v got=%v", tt.wantDate, got.EstimatedFinalExpiresAt)
}
if tt.wantDays != nil && (got.DaysUntilFinalExpiry == nil || *got.DaysUntilFinalExpiry != *tt.wantDays) {
t.Fatalf("剩余天数错误want=%v got=%v", *tt.wantDays, got.DaysUntilFinalExpiry)
}
if tt.wantStatus != constants.PackageExpiryEstimateStatusExact && (got.EstimatedFinalExpiresAt != nil || got.DaysUntilFinalExpiry != nil) {
t.Fatalf("非精确状态必须返回 null%+v", got)
}
})
}
}
// TestQueryResolveBatchUsesOneBatchLoad 验证一页资产只通过批量读取计算,不逐卡查询套餐。
func TestQueryResolveBatchUsesOneBatchLoad(t *testing.T) {
tx := testutil.NewPostgresTransaction(t)
location := time.FixedZone("Asia/Shanghai", 8*60*60)
now := time.Date(2026, 7, 23, 12, 0, 0, 0, location)
assetIDs := make([]uint, 0, 100)
for i := 1; i <= 100; i++ {
assetID := uint(970000 + i)
assetIDs = append(assetIDs, assetID)
expiresAt := now.AddDate(0, 0, i%16)
usage := &model.PackageUsage{
OrderID: assetID,
OrderNo: "UR46-BATCH",
PackageID: assetID,
UsageType: constants.AssetTypeIotCard,
IotCardID: assetID,
DataLimitMB: 1,
Status: constants.PackageUsageStatusActive,
Priority: 1,
ActivatedAt: &now,
ExpiresAt: &expiresAt,
ExpiryBaseSnapshot: constants.PackageExpiryBaseFromActivation,
CalendarTypeSnapshot: constants.PackageCalendarTypeByDay,
DurationDaysSnapshot: 30,
DurationMonthsSnapshot: 0,
}
if err := tx.Create(usage).Error; err != nil {
t.Fatalf("创建批量套餐使用记录失败:%v", err)
}
}
query := NewQuery(tx)
query.now = func() time.Time { return now }
results, err := query.ResolveBatch(context.Background(), constants.AssetTypeIotCard, assetIDs)
if err != nil {
t.Fatalf("批量查询失败:%v", err)
}
if len(results) != len(assetIDs) {
t.Fatalf("批量结果数量错误want=%d got=%d", len(assetIDs), len(results))
}
for _, assetID := range assetIDs {
if results[assetID].ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusExact {
t.Fatalf("资产 %d 应得到精确结果:%+v", assetID, results[assetID])
}
}
}
// TestQueryIndexesExplainAnalyze 验证开发 PostgreSQL 为两类资产队列查询选择 UR46 部分索引。
func TestQueryIndexesExplainAnalyze(t *testing.T) {
tx := testutil.NewPostgresTransaction(t)
queries := []string{
"EXPLAIN ANALYZE SELECT id FROM tb_package_usage WHERE iot_card_id = 970001 AND master_usage_id IS NULL AND refund_id IS NULL AND status IN (0, 1, 2) ORDER BY priority ASC, created_at ASC, id ASC",
"EXPLAIN ANALYZE SELECT id FROM tb_package_usage WHERE device_id = 970001 AND master_usage_id IS NULL AND refund_id IS NULL AND status IN (0, 1, 2) ORDER BY priority ASC, created_at ASC, id ASC",
}
for _, query := range queries {
var lines []string
if err := tx.Raw(query).Scan(&lines).Error; err != nil {
t.Fatalf("执行 EXPLAIN ANALYZE 失败:%v", err)
}
if len(lines) == 0 {
t.Fatal("EXPLAIN ANALYZE 未返回执行计划")
}
t.Logf("%s", lines)
}
}
func modelBase(id uint, createdAt time.Time) gorm.Model {
return gorm.Model{ID: id, CreatedAt: createdAt}
}
func intPointer(value int) *int { return &value }
func timePointer(value time.Time) *time.Time { return &value }