重置项目上下文与规范文档
This commit is contained in:
@@ -1,81 +0,0 @@
|
||||
package asset
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
)
|
||||
|
||||
func TestComputeUsageSummary_EmptyList(t *testing.T) {
|
||||
totalUsed, totalRemaining := computeUsageSummary(nil)
|
||||
if totalUsed != 0.0 {
|
||||
t.Errorf("期望 totalUsed=0.0,实际=%v", totalUsed)
|
||||
}
|
||||
if totalRemaining != 0.0 {
|
||||
t.Errorf("期望 totalRemaining=0.0,实际=%v", totalRemaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeUsageSummary_SingleVirtualPackage(t *testing.T) {
|
||||
// 启用虚流量:真实总量=100MB,虚拟总量=200MB,倍率=0.5
|
||||
// 真实已用=40MB → 虚拟已用=min(40*0.5, 100)=20MB
|
||||
// 虚拟剩余=200-20=180MB
|
||||
usage := &model.PackageUsage{
|
||||
DataLimitMB: 100,
|
||||
DataUsageMB: 40,
|
||||
VirtualTotalMBSnapshot: 200,
|
||||
DisplayGainRatioSnapshot: 0.5,
|
||||
EnableVirtualDataSnapshot: true,
|
||||
}
|
||||
totalUsed, totalRemaining := computeUsageSummary([]*model.PackageUsage{usage})
|
||||
if totalUsed != 20.0 {
|
||||
t.Errorf("期望 totalUsed=20.0,实际=%v", totalUsed)
|
||||
}
|
||||
if totalRemaining != 180.0 {
|
||||
t.Errorf("期望 totalRemaining=180.0,实际=%v", totalRemaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeUsageSummary_SingleNonVirtualPackage(t *testing.T) {
|
||||
// 未启用虚流量:退化为真实值
|
||||
// 真实总量=100MB,真实已用=40MB → 虚拟已用=40MB,虚拟剩余=60MB
|
||||
usage := &model.PackageUsage{
|
||||
DataLimitMB: 100,
|
||||
DataUsageMB: 40,
|
||||
EnableVirtualDataSnapshot: false,
|
||||
}
|
||||
totalUsed, totalRemaining := computeUsageSummary([]*model.PackageUsage{usage})
|
||||
if totalUsed != 40.0 {
|
||||
t.Errorf("期望 totalUsed=40.0,实际=%v", totalUsed)
|
||||
}
|
||||
if totalRemaining != 60.0 {
|
||||
t.Errorf("期望 totalRemaining=60.0,实际=%v", totalRemaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeUsageSummary_MultiplePackages(t *testing.T) {
|
||||
// 套餐1(启用虚流量):VirtualUsed=20, VirtualTotal=200
|
||||
// 套餐2(未启用虚流量):VirtualUsed=10, VirtualTotal=50
|
||||
// 汇总:totalUsed=30, totalRemaining=(200+50)-30=220
|
||||
usages := []*model.PackageUsage{
|
||||
{
|
||||
DataLimitMB: 100,
|
||||
DataUsageMB: 40,
|
||||
VirtualTotalMBSnapshot: 200,
|
||||
DisplayGainRatioSnapshot: 0.5,
|
||||
EnableVirtualDataSnapshot: true,
|
||||
},
|
||||
{
|
||||
DataLimitMB: 50,
|
||||
DataUsageMB: 10,
|
||||
EnableVirtualDataSnapshot: false,
|
||||
},
|
||||
}
|
||||
totalUsed, totalRemaining := computeUsageSummary(usages)
|
||||
if totalUsed != 30.0 {
|
||||
t.Errorf("期望 totalUsed=30.0,实际=%v", totalUsed)
|
||||
}
|
||||
if totalRemaining != 220.0 {
|
||||
t.Errorf("期望 totalRemaining=220.0,实际=%v", totalRemaining)
|
||||
}
|
||||
}
|
||||
@@ -1,715 +0,0 @@
|
||||
package customer_binding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
)
|
||||
|
||||
// ---- 测试工具 ----
|
||||
|
||||
// bindKey 构建 mock 中使用的查找键
|
||||
func bindKey(customerID uint, key string) string {
|
||||
return fmt.Sprintf("%d:%s", customerID, key)
|
||||
}
|
||||
|
||||
// ---- mock 实现 ----
|
||||
|
||||
type mockCardReader struct {
|
||||
cards map[uint]*model.IotCard
|
||||
}
|
||||
|
||||
func (m *mockCardReader) GetByID(_ context.Context, id uint) (*model.IotCard, error) {
|
||||
if c, ok := m.cards[id]; ok {
|
||||
return c, nil
|
||||
}
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
type mockDeviceReader struct {
|
||||
devices map[uint]*model.Device
|
||||
}
|
||||
|
||||
func (m *mockDeviceReader) GetByID(_ context.Context, id uint) (*model.Device, error) {
|
||||
if d, ok := m.devices[id]; ok {
|
||||
return d, nil
|
||||
}
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
// mockPCDOps 模拟 tb_personal_customer_device 操作
|
||||
type mockPCDOps struct {
|
||||
// bindings["customerID:virtualNo"] = true 表示有效(status=1)绑定
|
||||
bindings map[string]bool
|
||||
created []*model.PersonalCustomerDevice
|
||||
counts map[string]int64 // virtualNo → count(用于首绑判断)
|
||||
allRecords []*model.PersonalCustomerDevice // GetByDeviceNo 和 UpdateStatus/UpdateVirtualNo 使用
|
||||
}
|
||||
|
||||
func (m *mockPCDOps) ExistsByCustomerAndDevice(_ context.Context, customerID uint, deviceNo string) (bool, error) {
|
||||
return m.bindings[bindKey(customerID, deviceNo)], nil
|
||||
}
|
||||
|
||||
func (m *mockPCDOps) Create(_ context.Context, record *model.PersonalCustomerDevice) error {
|
||||
m.created = append(m.created, record)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockPCDOps) CountByVirtualNo(_ context.Context, virtualNo string) (int64, error) {
|
||||
if m.counts != nil {
|
||||
return m.counts[virtualNo], nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockPCDOps) GetByDeviceNo(_ context.Context, deviceNo string) ([]*model.PersonalCustomerDevice, error) {
|
||||
var result []*model.PersonalCustomerDevice
|
||||
for _, r := range m.allRecords {
|
||||
if r.VirtualNo == deviceNo {
|
||||
result = append(result, r)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockPCDOps) UpdateStatus(_ context.Context, id uint, status int) error {
|
||||
for _, r := range m.allRecords {
|
||||
if r.ID == id {
|
||||
r.Status = status
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockPCDOps) UpdateVirtualNo(_ context.Context, id uint, newVirtualNo string) error {
|
||||
for _, r := range m.allRecords {
|
||||
if r.ID == id {
|
||||
r.VirtualNo = newVirtualNo
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mockPCIOps 模拟 tb_personal_customer_iccid 操作
|
||||
type mockPCIOps struct {
|
||||
bindings map[string]bool
|
||||
created []*model.PersonalCustomerICCID
|
||||
counts map[string]int64 // iccid → count
|
||||
allRecords []*model.PersonalCustomerICCID // GetByICCID 和 UpdateStatus 使用
|
||||
}
|
||||
|
||||
func (m *mockPCIOps) ExistsByCustomerAndICCID(_ context.Context, customerID uint, iccid string) (bool, error) {
|
||||
return m.bindings[bindKey(customerID, iccid)], nil
|
||||
}
|
||||
|
||||
func (m *mockPCIOps) Create(_ context.Context, record *model.PersonalCustomerICCID) error {
|
||||
m.created = append(m.created, record)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockPCIOps) CountByICCID(_ context.Context, iccid string) (int64, error) {
|
||||
if m.counts != nil {
|
||||
return m.counts[iccid], nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockPCIOps) GetByICCID(_ context.Context, iccid string) ([]*model.PersonalCustomerICCID, error) {
|
||||
var result []*model.PersonalCustomerICCID
|
||||
for _, r := range m.allRecords {
|
||||
if r.ICCID == iccid {
|
||||
result = append(result, r)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockPCIOps) UpdateStatus(_ context.Context, id uint, status int) error {
|
||||
for _, r := range m.allRecords {
|
||||
if r.ID == id {
|
||||
r.Status = status
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- 测试 Service 构造器 ----
|
||||
|
||||
// soldCalls 记录 markAsSold 调用
|
||||
type soldCalls struct {
|
||||
items []string
|
||||
}
|
||||
|
||||
func (s *soldCalls) mark(_ context.Context, _ *gorm.DB, assetType string, _ uint) error {
|
||||
s.items = append(s.items, assetType)
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTestService(cards cardReader, devices deviceReader, pcd pcdOps, pci pciOps) (*Service, *soldCalls) {
|
||||
sold := &soldCalls{}
|
||||
return &Service{
|
||||
cards: cards,
|
||||
devices: devices,
|
||||
readCard: func(ctx context.Context, _ *gorm.DB, id uint) (*model.IotCard, error) {
|
||||
return cards.GetByID(ctx, id)
|
||||
},
|
||||
readDevice: func(ctx context.Context, _ *gorm.DB, id uint) (*model.Device, error) {
|
||||
return devices.GetByID(ctx, id)
|
||||
},
|
||||
makePCD: func(_ *gorm.DB) pcdOps { return pcd },
|
||||
makePCI: func(_ *gorm.DB) pciOps { return pci },
|
||||
markAsSold: sold.mark,
|
||||
}, sold
|
||||
}
|
||||
|
||||
// ---- OwnsAsset 测试 ----
|
||||
|
||||
// 验证:有虚拟号卡 + 客户有有效绑定 → true(tracer bullet)
|
||||
func TestOwnsAsset_有虚拟号卡_有效绑定_返回true(t *testing.T) {
|
||||
card := &model.IotCard{VirtualNo: "VN001"}
|
||||
card.ID = 1
|
||||
|
||||
pcd := &mockPCDOps{
|
||||
bindings: map[string]bool{bindKey(10, "VN001"): true},
|
||||
}
|
||||
svc, _ := newTestService(
|
||||
&mockCardReader{cards: map[uint]*model.IotCard{1: card}},
|
||||
&mockDeviceReader{},
|
||||
pcd,
|
||||
&mockPCIOps{},
|
||||
)
|
||||
|
||||
owned, err := svc.OwnsAsset(context.Background(), 10, "iot_card", 1)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if !owned {
|
||||
t.Fatal("期望返回 true,实际返回 false")
|
||||
}
|
||||
}
|
||||
|
||||
// 验证:有虚拟号卡 + status=0 的绑定 → false(修复安全缺口)
|
||||
func TestOwnsAsset_有虚拟号卡_禁用绑定_返回false(t *testing.T) {
|
||||
card := &model.IotCard{VirtualNo: "VN002"}
|
||||
card.ID = 2
|
||||
|
||||
pcd := &mockPCDOps{
|
||||
// status=0 的绑定:在 ExistsByCustomerAndDevice 中会过滤掉(bindings 中不存在)
|
||||
bindings: map[string]bool{},
|
||||
}
|
||||
svc, _ := newTestService(
|
||||
&mockCardReader{cards: map[uint]*model.IotCard{2: card}},
|
||||
&mockDeviceReader{},
|
||||
pcd,
|
||||
&mockPCIOps{},
|
||||
)
|
||||
|
||||
owned, err := svc.OwnsAsset(context.Background(), 10, "iot_card", 2)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if owned {
|
||||
t.Fatal("期望返回 false(status=0),实际返回 true")
|
||||
}
|
||||
}
|
||||
|
||||
// 验证:有虚拟号卡 + 无绑定 → false
|
||||
func TestOwnsAsset_有虚拟号卡_无绑定_返回false(t *testing.T) {
|
||||
card := &model.IotCard{VirtualNo: "VN003"}
|
||||
card.ID = 3
|
||||
|
||||
svc, _ := newTestService(
|
||||
&mockCardReader{cards: map[uint]*model.IotCard{3: card}},
|
||||
&mockDeviceReader{},
|
||||
&mockPCDOps{bindings: map[string]bool{}},
|
||||
&mockPCIOps{},
|
||||
)
|
||||
|
||||
owned, err := svc.OwnsAsset(context.Background(), 10, "iot_card", 3)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if owned {
|
||||
t.Fatal("期望返回 false(无绑定),实际返回 true")
|
||||
}
|
||||
}
|
||||
|
||||
// 验证:assetType="card"(来自 assetService.Resolve)等价于 "iot_card"
|
||||
func TestOwnsAsset_assetType_card_等价iot_card(t *testing.T) {
|
||||
card := &model.IotCard{VirtualNo: "VN_CARD"}
|
||||
card.ID = 9
|
||||
|
||||
pcd := &mockPCDOps{
|
||||
bindings: map[string]bool{bindKey(10, "VN_CARD"): true},
|
||||
}
|
||||
svc, _ := newTestService(
|
||||
&mockCardReader{cards: map[uint]*model.IotCard{9: card}},
|
||||
&mockDeviceReader{},
|
||||
pcd,
|
||||
&mockPCIOps{},
|
||||
)
|
||||
|
||||
owned, err := svc.OwnsAsset(context.Background(), 10, "card", 9)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if !owned {
|
||||
t.Fatal("期望 card 类型等价 iot_card 返回 true,实际 false")
|
||||
}
|
||||
}
|
||||
|
||||
// 验证:设备资产 + 有效绑定 → true
|
||||
func TestOwnsAsset_设备_有效绑定_返回true(t *testing.T) {
|
||||
device := &model.Device{VirtualNo: "DEV001"}
|
||||
device.ID = 5
|
||||
|
||||
pcd := &mockPCDOps{
|
||||
bindings: map[string]bool{bindKey(10, "DEV001"): true},
|
||||
}
|
||||
svc, _ := newTestService(
|
||||
&mockCardReader{},
|
||||
&mockDeviceReader{devices: map[uint]*model.Device{5: device}},
|
||||
pcd,
|
||||
&mockPCIOps{},
|
||||
)
|
||||
|
||||
owned, err := svc.OwnsAsset(context.Background(), 10, "device", 5)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if !owned {
|
||||
t.Fatal("期望返回 true,实际返回 false")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Bind 测试 ----
|
||||
|
||||
// 验证:首次绑定有虚拟号卡 → 写入 pcd 记录 + 触发 markAssetAsSold
|
||||
func TestBind_有虚拟号卡_首次绑定_创建记录并标记已售(t *testing.T) {
|
||||
card := &model.IotCard{VirtualNo: "VN010"}
|
||||
card.ID = 10
|
||||
|
||||
pcd := &mockPCDOps{
|
||||
bindings: map[string]bool{},
|
||||
counts: map[string]int64{"VN010": 0}, // 首次绑定
|
||||
}
|
||||
svc, sold := newTestService(
|
||||
&mockCardReader{cards: map[uint]*model.IotCard{10: card}},
|
||||
&mockDeviceReader{},
|
||||
pcd,
|
||||
&mockPCIOps{},
|
||||
)
|
||||
|
||||
err := svc.Bind(context.Background(), nil, 20, "iot_card", 10)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if len(pcd.created) != 1 {
|
||||
t.Fatalf("期望创建 1 条 pcd 记录,实际: %d", len(pcd.created))
|
||||
}
|
||||
if pcd.created[0].VirtualNo != "VN010" {
|
||||
t.Errorf("期望 VirtualNo=VN010,实际: %s", pcd.created[0].VirtualNo)
|
||||
}
|
||||
if pcd.created[0].CustomerID != 20 {
|
||||
t.Errorf("期望 CustomerID=20,实际: %d", pcd.created[0].CustomerID)
|
||||
}
|
||||
if len(sold.items) != 1 || sold.items[0] != "iot_card" {
|
||||
t.Errorf("期望触发 markAssetAsSold(iot_card),实际: %v", sold.items)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证:重复绑定 → 不创建新记录
|
||||
func TestBind_有虚拟号卡_已有绑定_不重复创建(t *testing.T) {
|
||||
card := &model.IotCard{VirtualNo: "VN011"}
|
||||
card.ID = 11
|
||||
|
||||
pcd := &mockPCDOps{
|
||||
bindings: map[string]bool{bindKey(20, "VN011"): true},
|
||||
counts: map[string]int64{"VN011": 1},
|
||||
}
|
||||
svc, _ := newTestService(
|
||||
&mockCardReader{cards: map[uint]*model.IotCard{11: card}},
|
||||
&mockDeviceReader{},
|
||||
pcd,
|
||||
&mockPCIOps{},
|
||||
)
|
||||
|
||||
err := svc.Bind(context.Background(), nil, 20, "iot_card", 11)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if len(pcd.created) != 0 {
|
||||
t.Fatalf("期望不创建新记录,实际创建了 %d 条", len(pcd.created))
|
||||
}
|
||||
}
|
||||
|
||||
// 验证:非首次绑定(已有其他客户绑定)→ 创建记录但不触发 markAssetAsSold
|
||||
func TestBind_有虚拟号卡_非首次绑定_创建记录不标记已售(t *testing.T) {
|
||||
card := &model.IotCard{VirtualNo: "VN012"}
|
||||
card.ID = 12
|
||||
|
||||
pcd := &mockPCDOps{
|
||||
bindings: map[string]bool{},
|
||||
counts: map[string]int64{"VN012": 1}, // 已有其他绑定
|
||||
}
|
||||
svc, sold := newTestService(
|
||||
&mockCardReader{cards: map[uint]*model.IotCard{12: card}},
|
||||
&mockDeviceReader{},
|
||||
pcd,
|
||||
&mockPCIOps{},
|
||||
)
|
||||
|
||||
err := svc.Bind(context.Background(), nil, 30, "iot_card", 12)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if len(pcd.created) != 1 {
|
||||
t.Fatalf("期望创建 1 条记录,实际: %d", len(pcd.created))
|
||||
}
|
||||
if len(sold.items) != 0 {
|
||||
t.Errorf("期望不触发 markAssetAsSold,实际触发了: %v", sold.items)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Issue 02: 无虚拟号卡 pci 路径测试 ----
|
||||
|
||||
// 验证:无虚拟号卡 + 客户有有效 PCI 绑定 → true
|
||||
func TestOwnsAsset_无虚拟号卡_有效PCI绑定_返回true(t *testing.T) {
|
||||
card := &model.IotCard{ICCID: "89860000000000000001"} // VirtualNo 为空
|
||||
card.ID = 20
|
||||
|
||||
pci := &mockPCIOps{
|
||||
bindings: map[string]bool{bindKey(10, "89860000000000000001"): true},
|
||||
}
|
||||
svc, _ := newTestService(
|
||||
&mockCardReader{cards: map[uint]*model.IotCard{20: card}},
|
||||
&mockDeviceReader{},
|
||||
&mockPCDOps{bindings: map[string]bool{}},
|
||||
pci,
|
||||
)
|
||||
|
||||
owned, err := svc.OwnsAsset(context.Background(), 10, "iot_card", 20)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if !owned {
|
||||
t.Fatal("期望返回 true,实际返回 false")
|
||||
}
|
||||
}
|
||||
|
||||
// 验证:无虚拟号卡 + 无 PCI 绑定 → false
|
||||
func TestOwnsAsset_无虚拟号卡_无PCI绑定_返回false(t *testing.T) {
|
||||
card := &model.IotCard{ICCID: "89860000000000000002"} // VirtualNo 为空
|
||||
card.ID = 21
|
||||
|
||||
svc, _ := newTestService(
|
||||
&mockCardReader{cards: map[uint]*model.IotCard{21: card}},
|
||||
&mockDeviceReader{},
|
||||
&mockPCDOps{bindings: map[string]bool{}},
|
||||
&mockPCIOps{bindings: map[string]bool{}},
|
||||
)
|
||||
|
||||
owned, err := svc.OwnsAsset(context.Background(), 10, "iot_card", 21)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if owned {
|
||||
t.Fatal("期望返回 false(无 PCI 绑定),实际返回 true")
|
||||
}
|
||||
}
|
||||
|
||||
// 验证:无虚拟号卡首次绑定 → 写入 pci 记录 + 触发 markAssetAsSold
|
||||
func TestBind_无虚拟号卡_首次绑定_创建PCI记录并标记已售(t *testing.T) {
|
||||
card := &model.IotCard{ICCID: "89860000000000000010"} // VirtualNo 为空
|
||||
card.ID = 30
|
||||
|
||||
pci := &mockPCIOps{
|
||||
bindings: map[string]bool{},
|
||||
counts: map[string]int64{"89860000000000000010": 0}, // 首次绑定
|
||||
}
|
||||
svc, sold := newTestService(
|
||||
&mockCardReader{cards: map[uint]*model.IotCard{30: card}},
|
||||
&mockDeviceReader{},
|
||||
&mockPCDOps{bindings: map[string]bool{}},
|
||||
pci,
|
||||
)
|
||||
|
||||
err := svc.Bind(context.Background(), nil, 50, "iot_card", 30)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if len(pci.created) != 1 {
|
||||
t.Fatalf("期望创建 1 条 pci 记录,实际: %d", len(pci.created))
|
||||
}
|
||||
if pci.created[0].ICCID != "89860000000000000010" {
|
||||
t.Errorf("期望 ICCID=89860000000000000010,实际: %s", pci.created[0].ICCID)
|
||||
}
|
||||
if pci.created[0].CustomerID != 50 {
|
||||
t.Errorf("期望 CustomerID=50,实际: %d", pci.created[0].CustomerID)
|
||||
}
|
||||
if len(sold.items) != 1 || sold.items[0] != "iot_card" {
|
||||
t.Errorf("期望触发 markAssetAsSold(iot_card),实际: %v", sold.items)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证:无虚拟号卡已有绑定 → 不重复创建
|
||||
func TestBind_无虚拟号卡_已有绑定_不重复创建(t *testing.T) {
|
||||
card := &model.IotCard{ICCID: "89860000000000000011"} // VirtualNo 为空
|
||||
card.ID = 31
|
||||
|
||||
pci := &mockPCIOps{
|
||||
bindings: map[string]bool{bindKey(50, "89860000000000000011"): true},
|
||||
counts: map[string]int64{"89860000000000000011": 1},
|
||||
}
|
||||
svc, _ := newTestService(
|
||||
&mockCardReader{cards: map[uint]*model.IotCard{31: card}},
|
||||
&mockDeviceReader{},
|
||||
&mockPCDOps{bindings: map[string]bool{}},
|
||||
pci,
|
||||
)
|
||||
|
||||
err := svc.Bind(context.Background(), nil, 50, "iot_card", 31)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if len(pci.created) != 0 {
|
||||
t.Fatalf("期望不创建新记录,实际创建了 %d 条", len(pci.created))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Issue 03: Migrate 迁移测试 ----
|
||||
|
||||
// newMockPCDRecord 构建一条带 ID 的 pcd 记录(用于迁移测试)
|
||||
func newMockPCDRecord(id, customerID uint, virtualNo string, status int) *model.PersonalCustomerDevice {
|
||||
r := &model.PersonalCustomerDevice{
|
||||
CustomerID: customerID,
|
||||
VirtualNo: virtualNo,
|
||||
Status: status,
|
||||
}
|
||||
r.ID = id
|
||||
return r
|
||||
}
|
||||
|
||||
// newMockPCIRecord 构建一条带 ID 的 pci 记录(用于迁移测试)
|
||||
func newMockPCIRecord(id, customerID uint, iccid string, status int) *model.PersonalCustomerICCID {
|
||||
r := &model.PersonalCustomerICCID{
|
||||
CustomerID: customerID,
|
||||
ICCID: iccid,
|
||||
Status: status,
|
||||
}
|
||||
r.ID = id
|
||||
return r
|
||||
}
|
||||
|
||||
// 验证:旧卡有虚拟号 + pcd 有绑定 + 新卡有虚拟号 → pcd.virtual_no 更新为新卡虚拟号
|
||||
func TestMigrate_有虚拟号旧卡_有绑定_换有虚拟号新卡_更新VirtualNo(t *testing.T) {
|
||||
oldCard := &model.IotCard{VirtualNo: "OLD_VN"}
|
||||
oldCard.ID = 100
|
||||
newCard := &model.IotCard{VirtualNo: "NEW_VN"}
|
||||
newCard.ID = 101
|
||||
|
||||
existing := newMockPCDRecord(1, 50, "OLD_VN", 1)
|
||||
pcd := &mockPCDOps{
|
||||
bindings: map[string]bool{},
|
||||
allRecords: []*model.PersonalCustomerDevice{existing},
|
||||
}
|
||||
svc, _ := newTestService(
|
||||
&mockCardReader{cards: map[uint]*model.IotCard{100: oldCard, 101: newCard}},
|
||||
&mockDeviceReader{},
|
||||
pcd,
|
||||
&mockPCIOps{},
|
||||
)
|
||||
|
||||
err := svc.Migrate(context.Background(), nil, "iot_card", 100, "iot_card", 101)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if existing.VirtualNo != "NEW_VN" {
|
||||
t.Errorf("期望 VirtualNo 更新为 NEW_VN,实际: %s", existing.VirtualNo)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证:旧卡有虚拟号 + pcd 有绑定 + 新卡无虚拟号 → 旧 pcd status=0,新 pci 创建
|
||||
func TestMigrate_有虚拟号旧卡_有绑定_换无虚拟号新卡_迁移到PCI(t *testing.T) {
|
||||
oldCard := &model.IotCard{VirtualNo: "OLD_VN2"}
|
||||
oldCard.ID = 110
|
||||
newCard := &model.IotCard{ICCID: "89860000000000000099"} // 无虚拟号
|
||||
newCard.ID = 111
|
||||
|
||||
existing := newMockPCDRecord(2, 60, "OLD_VN2", 1)
|
||||
pcd := &mockPCDOps{
|
||||
allRecords: []*model.PersonalCustomerDevice{existing},
|
||||
}
|
||||
pci := &mockPCIOps{}
|
||||
svc, _ := newTestService(
|
||||
&mockCardReader{cards: map[uint]*model.IotCard{110: oldCard, 111: newCard}},
|
||||
&mockDeviceReader{},
|
||||
pcd,
|
||||
pci,
|
||||
)
|
||||
|
||||
err := svc.Migrate(context.Background(), nil, "iot_card", 110, "iot_card", 111)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if existing.Status != 0 {
|
||||
t.Errorf("期望旧 pcd 记录 status=0,实际: %d", existing.Status)
|
||||
}
|
||||
if len(pci.created) != 1 {
|
||||
t.Fatalf("期望创建 1 条 pci 记录,实际: %d", len(pci.created))
|
||||
}
|
||||
if pci.created[0].CustomerID != 60 {
|
||||
t.Errorf("期望 pci CustomerID=60,实际: %d", pci.created[0].CustomerID)
|
||||
}
|
||||
if pci.created[0].ICCID != "89860000000000000099" {
|
||||
t.Errorf("期望 pci ICCID=89860000000000000099,实际: %s", pci.created[0].ICCID)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证:旧卡有虚拟号 + pcd 无绑定 + 新卡无虚拟号 → 跳过,无写入
|
||||
func TestMigrate_有虚拟号旧卡_无绑定_换无虚拟号新卡_跳过(t *testing.T) {
|
||||
oldCard := &model.IotCard{VirtualNo: "OLD_VN3"}
|
||||
oldCard.ID = 120
|
||||
newCard := &model.IotCard{ICCID: "89860000000000000088"} // 无虚拟号
|
||||
newCard.ID = 121
|
||||
|
||||
pcd := &mockPCDOps{allRecords: []*model.PersonalCustomerDevice{}} // 空
|
||||
pci := &mockPCIOps{}
|
||||
svc, _ := newTestService(
|
||||
&mockCardReader{cards: map[uint]*model.IotCard{120: oldCard, 121: newCard}},
|
||||
&mockDeviceReader{},
|
||||
pcd,
|
||||
pci,
|
||||
)
|
||||
|
||||
err := svc.Migrate(context.Background(), nil, "iot_card", 120, "iot_card", 121)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if len(pci.created) != 0 {
|
||||
t.Fatalf("期望无写入,实际创建了 %d 条 pci 记录", len(pci.created))
|
||||
}
|
||||
}
|
||||
|
||||
// 验证:旧卡无虚拟号 + pci 有绑定 + 新卡有虚拟号 → 旧 pci status=0,新 pcd 创建
|
||||
func TestMigrate_无虚拟号旧卡_有绑定_换有虚拟号新卡_迁移到PCD(t *testing.T) {
|
||||
oldCard := &model.IotCard{ICCID: "89860000000000000077"} // 无虚拟号
|
||||
oldCard.ID = 130
|
||||
newCard := &model.IotCard{VirtualNo: "NEW_VN3"}
|
||||
newCard.ID = 131
|
||||
|
||||
existingPCI := newMockPCIRecord(3, 70, "89860000000000000077", 1)
|
||||
pci := &mockPCIOps{allRecords: []*model.PersonalCustomerICCID{existingPCI}}
|
||||
pcd := &mockPCDOps{}
|
||||
svc, _ := newTestService(
|
||||
&mockCardReader{cards: map[uint]*model.IotCard{130: oldCard, 131: newCard}},
|
||||
&mockDeviceReader{},
|
||||
pcd,
|
||||
pci,
|
||||
)
|
||||
|
||||
err := svc.Migrate(context.Background(), nil, "iot_card", 130, "iot_card", 131)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if existingPCI.Status != 0 {
|
||||
t.Errorf("期望旧 pci 记录 status=0,实际: %d", existingPCI.Status)
|
||||
}
|
||||
if len(pcd.created) != 1 {
|
||||
t.Fatalf("期望创建 1 条 pcd 记录,实际: %d", len(pcd.created))
|
||||
}
|
||||
if pcd.created[0].CustomerID != 70 {
|
||||
t.Errorf("期望 pcd CustomerID=70,实际: %d", pcd.created[0].CustomerID)
|
||||
}
|
||||
if pcd.created[0].VirtualNo != "NEW_VN3" {
|
||||
t.Errorf("期望 pcd VirtualNo=NEW_VN3,实际: %s", pcd.created[0].VirtualNo)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证:旧卡无虚拟号 + pci 有绑定 + 新卡也无虚拟号 → 旧 pci status=0,新 pci 创建新 ICCID
|
||||
func TestMigrate_无虚拟号旧卡_有绑定_换无虚拟号新卡_迁移PCI(t *testing.T) {
|
||||
oldCard := &model.IotCard{ICCID: "89860000000000000066"} // 无虚拟号
|
||||
oldCard.ID = 140
|
||||
newCard := &model.IotCard{ICCID: "89860000000000000055"} // 无虚拟号
|
||||
newCard.ID = 141
|
||||
|
||||
existingPCI := newMockPCIRecord(4, 80, "89860000000000000066", 1)
|
||||
pci := &mockPCIOps{allRecords: []*model.PersonalCustomerICCID{existingPCI}}
|
||||
pcd := &mockPCDOps{}
|
||||
svc, _ := newTestService(
|
||||
&mockCardReader{cards: map[uint]*model.IotCard{140: oldCard, 141: newCard}},
|
||||
&mockDeviceReader{},
|
||||
pcd,
|
||||
pci,
|
||||
)
|
||||
|
||||
err := svc.Migrate(context.Background(), nil, "iot_card", 140, "iot_card", 141)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if existingPCI.Status != 0 {
|
||||
t.Errorf("期望旧 pci 记录 status=0,实际: %d", existingPCI.Status)
|
||||
}
|
||||
if len(pci.created) != 1 {
|
||||
t.Fatalf("期望创建 1 条新 pci 记录,实际: %d", len(pci.created))
|
||||
}
|
||||
if pci.created[0].CustomerID != 80 {
|
||||
t.Errorf("期望 pci CustomerID=80,实际: %d", pci.created[0].CustomerID)
|
||||
}
|
||||
if pci.created[0].ICCID != "89860000000000000055" {
|
||||
t.Errorf("期望 pci ICCID=89860000000000000055,实际: %s", pci.created[0].ICCID)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证:无虚拟号卡非首次绑定(已有其他客户)→ 创建记录但不触发 markAssetAsSold
|
||||
func TestBind_无虚拟号卡_非首次绑定_创建记录不标记已售(t *testing.T) {
|
||||
card := &model.IotCard{ICCID: "89860000000000000012"} // VirtualNo 为空
|
||||
card.ID = 32
|
||||
|
||||
pci := &mockPCIOps{
|
||||
bindings: map[string]bool{},
|
||||
counts: map[string]int64{"89860000000000000012": 1}, // 已有其他客户绑定
|
||||
}
|
||||
svc, sold := newTestService(
|
||||
&mockCardReader{cards: map[uint]*model.IotCard{32: card}},
|
||||
&mockDeviceReader{},
|
||||
&mockPCDOps{bindings: map[string]bool{}},
|
||||
pci,
|
||||
)
|
||||
|
||||
err := svc.Bind(context.Background(), nil, 60, "iot_card", 32)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if len(pci.created) != 1 {
|
||||
t.Fatalf("期望创建 1 条记录,实际: %d", len(pci.created))
|
||||
}
|
||||
if len(sold.items) != 0 {
|
||||
t.Errorf("期望不触发 markAssetAsSold,实际触发了: %v", sold.items)
|
||||
}
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
package exchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
customerBindingSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestResolveAssetByIdentifierUsesAuthoritativeSnapshot 验证任意受支持标识都生成权威换货快照。
|
||||
func TestResolveAssetByIdentifierUsesAuthoritativeSnapshot(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
service := &Service{
|
||||
iotCardStore: postgres.NewIotCardStore(tx, nil),
|
||||
deviceStore: postgres.NewDeviceStore(tx, nil),
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
card := &model.IotCard{ICCID: "89860012345678901234", ICCID19: "8986001234567890123", MSISDN: "13800138000", VirtualNo: "UR45-CARD", AssetStatus: constants.AssetStatusInStock}
|
||||
if err := tx.Create(card).Error; err != nil {
|
||||
t.Fatalf("创建测试卡失败:%v", err)
|
||||
}
|
||||
for _, identifier := range []string{card.ICCID, card.MSISDN, card.VirtualNo} {
|
||||
asset, err := service.resolveAssetByIdentifier(ctx, constants.ExchangeAssetTypeIotCard, identifier)
|
||||
if err != nil {
|
||||
t.Fatalf("通过 %s 解析测试卡失败:%v", identifier, err)
|
||||
}
|
||||
if asset.Identifier != card.ICCID {
|
||||
t.Fatalf("卡快照应为 ICCID,输入 %s,实际 %s", identifier, asset.Identifier)
|
||||
}
|
||||
}
|
||||
|
||||
device := &model.Device{VirtualNo: "UR45-DEVICE", IMEI: "860000000000001", SN: "UR45-SN-1", AssetStatus: constants.AssetStatusInStock}
|
||||
if err := tx.Create(device).Error; err != nil {
|
||||
t.Fatalf("创建设备失败:%v", err)
|
||||
}
|
||||
asset, err := service.resolveAssetByIdentifier(ctx, constants.ExchangeAssetTypeDevice, device.IMEI)
|
||||
if err != nil {
|
||||
t.Fatalf("解析设备失败:%v", err)
|
||||
}
|
||||
if asset.Identifier != device.VirtualNo {
|
||||
t.Fatalf("设备应优先保存虚拟号,实际 %s", asset.Identifier)
|
||||
}
|
||||
for _, testCase := range []struct {
|
||||
device *model.Device
|
||||
expected string
|
||||
}{
|
||||
{device: &model.Device{IMEI: "860000000000002", SN: "UR45-SN-2"}, expected: "860000000000002"},
|
||||
{device: &model.Device{SN: "UR45-SN-3"}, expected: "UR45-SN-3"},
|
||||
} {
|
||||
if actual := newResolvedDeviceAsset(testCase.device).Identifier; actual != testCase.expected {
|
||||
t.Fatalf("设备快照优先级错误,期望 %s,实际 %s", testCase.expected, actual)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestExchangeWriteEntrypointsPersistAuthoritativeCardSnapshots 验证三个写入入口持久化卡的权威 ICCID。
|
||||
func TestExchangeWriteEntrypointsPersistAuthoritativeCardSnapshots(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
identifier func(*model.IotCard) string
|
||||
}{
|
||||
{name: "ICCID", identifier: func(card *model.IotCard) string { return card.ICCID }},
|
||||
{name: "接入号", identifier: func(card *model.IotCard) string { return card.MSISDN }},
|
||||
{name: "虚拟号", identifier: func(card *model.IotCard) string { return card.VirtualNo }},
|
||||
}
|
||||
|
||||
for index, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
service := newExchangeSnapshotTestService(tx)
|
||||
oldCard := createExchangeSnapshotCard(t, tx, index*10+1)
|
||||
newCard := createExchangeSnapshotCard(t, tx, index*10+2)
|
||||
|
||||
direct, err := service.Create(context.Background(), &dto.CreateExchangeRequest{
|
||||
OldAssetType: constants.ExchangeAssetTypeIotCard,
|
||||
OldIdentifier: testCase.identifier(oldCard),
|
||||
FlowType: constants.ExchangeFlowTypeDirect,
|
||||
NewIdentifier: testCase.identifier(newCard),
|
||||
ExchangeReason: "UR45 卡快照测试",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建直接换货失败:%v", err)
|
||||
}
|
||||
if direct.OldAssetIdentifier != oldCard.ICCID || direct.NewAssetIdentifier != newCard.ICCID {
|
||||
t.Fatalf("直接换货卡快照错误:old=%s new=%s", direct.OldAssetIdentifier, direct.NewAssetIdentifier)
|
||||
}
|
||||
|
||||
shippingOld := createExchangeSnapshotCard(t, tx, index*10+3)
|
||||
shippingNew := createExchangeSnapshotCard(t, tx, index*10+4)
|
||||
shipping, err := service.Create(context.Background(), &dto.CreateExchangeRequest{
|
||||
OldAssetType: constants.ExchangeAssetTypeIotCard,
|
||||
OldIdentifier: testCase.identifier(shippingOld),
|
||||
FlowType: constants.ExchangeFlowTypeShipping,
|
||||
ExchangeReason: "UR45 物流换货快照测试",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建物流换货失败:%v", err)
|
||||
}
|
||||
if shipping.OldAssetIdentifier != shippingOld.ICCID {
|
||||
t.Fatalf("物流换货旧卡快照应为 ICCID,实际 %s", shipping.OldAssetIdentifier)
|
||||
}
|
||||
if err = tx.Model(&model.ExchangeOrder{}).Where("id = ?", shipping.ID).Updates(map[string]any{
|
||||
"status": constants.ExchangeStatusPendingShip, "recipient_name": "测试用户",
|
||||
"recipient_phone": "13800138000", "recipient_address": "测试地址",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("建立待发货测试前置状态失败:%v", err)
|
||||
}
|
||||
shipped, err := service.Ship(context.Background(), shipping.ID, &dto.ExchangeShipRequest{
|
||||
ExpressCompany: "测试快递", ExpressNo: "UR45-EXPRESS", NewIdentifier: testCase.identifier(shippingNew),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("物流换货发货失败:%v", err)
|
||||
}
|
||||
if shipped.NewAssetIdentifier != shippingNew.ICCID {
|
||||
t.Fatalf("物流换货新卡快照应为 ICCID,实际 %s", shipped.NewAssetIdentifier)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExchangeWriteEntrypointsPersistPreferredDeviceSnapshots 验证设备输入标识不影响稳定快照优先级。
|
||||
func TestExchangeWriteEntrypointsPersistPreferredDeviceSnapshots(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
identifier func(*model.Device) string
|
||||
}{
|
||||
{name: "虚拟号", identifier: func(device *model.Device) string { return device.VirtualNo }},
|
||||
{name: "IMEI", identifier: func(device *model.Device) string { return device.IMEI }},
|
||||
{name: "SN", identifier: func(device *model.Device) string { return device.SN }},
|
||||
}
|
||||
|
||||
for index, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
service := newExchangeSnapshotTestService(tx)
|
||||
oldDevice := createExchangeSnapshotDevice(t, tx, index*10+1, true, true)
|
||||
newDevice := createExchangeSnapshotDevice(t, tx, index*10+2, true, true)
|
||||
order, err := service.Create(context.Background(), &dto.CreateExchangeRequest{
|
||||
OldAssetType: constants.ExchangeAssetTypeDevice,
|
||||
OldIdentifier: testCase.identifier(oldDevice),
|
||||
FlowType: constants.ExchangeFlowTypeDirect,
|
||||
NewIdentifier: testCase.identifier(newDevice),
|
||||
ExchangeReason: "UR45 设备快照测试",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建设备直接换货失败:%v", err)
|
||||
}
|
||||
if order.OldAssetIdentifier != oldDevice.VirtualNo || order.NewAssetIdentifier != newDevice.VirtualNo {
|
||||
t.Fatalf("设备快照应优先使用虚拟号:old=%s new=%s", order.OldAssetIdentifier, order.NewAssetIdentifier)
|
||||
}
|
||||
persisted, err := service.Get(context.Background(), order.ID)
|
||||
if err != nil || persisted.OldAssetIdentifier != oldDevice.VirtualNo || persisted.NewAssetIdentifier != newDevice.VirtualNo {
|
||||
t.Fatalf("详情未读回设备权威快照:order=%+v err=%v", persisted, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for index, testCase := range testCases {
|
||||
t.Run("物流"+testCase.name, func(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
service := newExchangeSnapshotTestService(tx)
|
||||
oldDevice := createExchangeSnapshotDevice(t, tx, 80+index*10+1, true, true)
|
||||
newDevice := createExchangeSnapshotDevice(t, tx, 80+index*10+2, true, true)
|
||||
shipping, err := service.Create(context.Background(), &dto.CreateExchangeRequest{
|
||||
OldAssetType: constants.ExchangeAssetTypeDevice, OldIdentifier: testCase.identifier(oldDevice),
|
||||
FlowType: constants.ExchangeFlowTypeShipping, ExchangeReason: "UR45 设备物流快照测试",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建设备物流换货失败:%v", err)
|
||||
}
|
||||
if shipping.OldAssetIdentifier != oldDevice.VirtualNo {
|
||||
t.Fatalf("物流创建设备旧快照应使用虚拟号,实际 %s", shipping.OldAssetIdentifier)
|
||||
}
|
||||
if err = tx.Model(&model.ExchangeOrder{}).Where("id = ?", shipping.ID).Update("status", constants.ExchangeStatusPendingShip).Error; err != nil {
|
||||
t.Fatalf("建立待发货状态失败:%v", err)
|
||||
}
|
||||
shipped, err := service.Ship(context.Background(), shipping.ID, &dto.ExchangeShipRequest{
|
||||
ExpressCompany: "测试快递", ExpressNo: "UR45-DEVICE-EXPRESS", NewIdentifier: testCase.identifier(newDevice),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("设备物流换货发货失败:%v", err)
|
||||
}
|
||||
if shipped.OldAssetIdentifier != oldDevice.VirtualNo || shipped.NewAssetIdentifier != newDevice.VirtualNo {
|
||||
t.Fatalf("设备物流快照应使用虚拟号:old=%s new=%s", shipped.OldAssetIdentifier, shipped.NewAssetIdentifier)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newExchangeSnapshotTestService(tx *gorm.DB) *Service {
|
||||
iotCardStore := postgres.NewIotCardStore(tx, nil)
|
||||
deviceStore := postgres.NewDeviceStore(tx, nil)
|
||||
return New(
|
||||
tx,
|
||||
postgres.NewExchangeOrderStore(tx),
|
||||
iotCardStore,
|
||||
deviceStore,
|
||||
postgres.NewAssetWalletStore(tx, nil),
|
||||
postgres.NewAssetWalletTransactionStore(tx, nil),
|
||||
postgres.NewPackageUsageStore(tx, nil),
|
||||
postgres.NewPackageUsageDailyRecordStore(tx, nil),
|
||||
postgres.NewResourceTagStore(tx),
|
||||
customerBindingSvc.New(tx, iotCardStore, deviceStore),
|
||||
zap.NewNop(),
|
||||
)
|
||||
}
|
||||
|
||||
func createExchangeSnapshotCard(t *testing.T, tx *gorm.DB, suffix int) *model.IotCard {
|
||||
t.Helper()
|
||||
iccid := fmt.Sprintf("8986000000000000%04d", suffix)
|
||||
card := &model.IotCard{
|
||||
ICCID: iccid, ICCID19: iccid[:19], MSISDN: fmt.Sprintf("1390000%04d", suffix),
|
||||
VirtualNo: fmt.Sprintf("UR45-CARD-%04d", suffix), AssetStatus: constants.AssetStatusInStock,
|
||||
}
|
||||
if err := tx.Create(card).Error; err != nil {
|
||||
t.Fatalf("创建测试卡失败:%v", err)
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
func createExchangeSnapshotDevice(t *testing.T, tx *gorm.DB, suffix int, withVirtualNo, withIMEI bool) *model.Device {
|
||||
t.Helper()
|
||||
device := &model.Device{SN: fmt.Sprintf("UR45-SN-%04d", suffix), AssetStatus: constants.AssetStatusInStock}
|
||||
if withVirtualNo {
|
||||
device.VirtualNo = fmt.Sprintf("UR45-DEVICE-%04d", suffix)
|
||||
}
|
||||
if withIMEI {
|
||||
device.IMEI = fmt.Sprintf("86000000000%04d", suffix)
|
||||
}
|
||||
if err := tx.Create(device).Error; err != nil {
|
||||
t.Fatalf("创建设备失败:%v", err)
|
||||
}
|
||||
return device
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package iot_card
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestIsRiskGatewayExtend 验证网关扩展状态的风险判断逻辑
|
||||
func TestIsRiskGatewayExtend(t *testing.T) {
|
||||
cases := []struct {
|
||||
extend string
|
||||
want bool
|
||||
}{
|
||||
{"风险停机", true},
|
||||
{"已销户", true},
|
||||
{"机卡分离停机", false},
|
||||
{"待激活", false},
|
||||
{"", false},
|
||||
{" 风险停机 ", true}, // 含空白字符
|
||||
{"已注销", false}, // 非目标状态
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got := isRiskGatewayExtend(tc.extend)
|
||||
if got != tc.want {
|
||||
t.Errorf("isRiskGatewayExtend(%q) = %v, 期望 %v", tc.extend, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package order
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// TestAssetWalletOrderReservationLifecycle 验证个人钱包订单冻结、超额拦截、支付核销和历史订单兼容。
|
||||
func TestAssetWalletOrderReservationLifecycle(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
resourceID := uint(time.Now().UnixNano() & testIDMask)
|
||||
wallet := &model.AssetWallet{
|
||||
ResourceType: constants.AssetWalletResourceTypeIotCard,
|
||||
ResourceID: resourceID,
|
||||
Balance: 1000,
|
||||
Status: 1,
|
||||
}
|
||||
if err := tx.Create(wallet).Error; err != nil {
|
||||
t.Fatalf("创建测试资产钱包失败:%v", err)
|
||||
}
|
||||
service := &Service{}
|
||||
order := &model.Order{
|
||||
OrderType: model.OrderTypeSingleCard, BuyerType: model.BuyerTypePersonal,
|
||||
PaymentMethod: model.PaymentMethodWallet, TotalAmount: 700, IotCardID: &resourceID,
|
||||
}
|
||||
if err := service.freezeAssetWalletForOrder(context.Background(), tx, order); err != nil {
|
||||
t.Fatalf("冻结订单金额失败:%v", err)
|
||||
}
|
||||
assertAssetWalletFunds(t, tx, wallet.ID, 1000, 700)
|
||||
if order.AssetWalletReservationWalletID == nil || *order.AssetWalletReservationWalletID != wallet.ID || order.AssetWalletReservedAmount != 700 {
|
||||
t.Fatalf("订单预占快照错误:wallet_id=%v amount=%d", order.AssetWalletReservationWalletID, order.AssetWalletReservedAmount)
|
||||
}
|
||||
|
||||
overdrawOrder := &model.Order{
|
||||
OrderType: model.OrderTypeSingleCard, BuyerType: model.BuyerTypePersonal,
|
||||
PaymentMethod: model.PaymentMethodWallet, TotalAmount: 400, IotCardID: &resourceID,
|
||||
}
|
||||
if err := service.freezeAssetWalletForOrder(context.Background(), tx, overdrawOrder); err == nil {
|
||||
t.Fatal("可用余额不足时必须拒绝第二笔冻结")
|
||||
}
|
||||
assertAssetWalletFunds(t, tx, wallet.ID, 1000, 700)
|
||||
|
||||
if _, err := service.deductAssetWalletForOrder(context.Background(), tx, order, constants.AssetWalletResourceTypeIotCard, resourceID); err != nil {
|
||||
t.Fatalf("核销订单预占失败:%v", err)
|
||||
}
|
||||
assertAssetWalletFunds(t, tx, wallet.ID, 300, 0)
|
||||
|
||||
if err := tx.Model(&model.AssetWallet{}).Where("id = ?", wallet.ID).
|
||||
Updates(map[string]any{"balance": 300, "frozen_balance": 200, "version": 10}).Error; err != nil {
|
||||
t.Fatalf("准备历史订单场景失败:%v", err)
|
||||
}
|
||||
legacyOrder := &model.Order{
|
||||
OrderType: model.OrderTypeSingleCard, BuyerType: model.BuyerTypePersonal,
|
||||
PaymentMethod: model.PaymentMethodWallet, TotalAmount: 150, IotCardID: &resourceID,
|
||||
}
|
||||
if err := service.releaseAssetWalletReservation(context.Background(), tx, legacyOrder); err != nil {
|
||||
t.Fatalf("历史订单取消不应释放其他订单冻结额:%v", err)
|
||||
}
|
||||
if _, err := service.deductAssetWalletForOrder(context.Background(), tx, legacyOrder, constants.AssetWalletResourceTypeIotCard, resourceID); err == nil {
|
||||
t.Fatal("历史订单不得占用其他订单的冻结余额")
|
||||
}
|
||||
assertAssetWalletFunds(t, tx, wallet.ID, 300, 200)
|
||||
}
|
||||
|
||||
func assertAssetWalletFunds(t *testing.T, tx *gorm.DB, walletID uint, balance, frozenBalance int64) {
|
||||
t.Helper()
|
||||
var wallet model.AssetWallet
|
||||
if err := tx.First(&wallet, walletID).Error; err != nil {
|
||||
t.Fatalf("查询资产钱包失败:%v", err)
|
||||
}
|
||||
if wallet.Balance != balance || wallet.FrozenBalance != frozenBalance {
|
||||
t.Fatalf("资产钱包金额错误:balance=%d frozen=%d,期望 balance=%d frozen=%d", wallet.Balance, wallet.FrozenBalance, balance, frozenBalance)
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
package order
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// testIDMask 用于将纳秒时间戳截断为合法的 uint 主键范围,仅限测试用。
|
||||
const testIDMask = 0x7fffffff
|
||||
|
||||
// TestSynchronousPurchasePersistsImmutableTermsSnapshots 验证同步主套餐和加油包固化购买时计时条款。
|
||||
func TestSynchronousPurchasePersistsImmutableTermsSnapshots(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
card := createOrderTermsCard(t, tx, constants.RealNameStatusNotVerified)
|
||||
formal := createOrderTermsPackage(t, tx, constants.PackageTypeFormal, constants.PackageExpiryBaseFromActivation, 45)
|
||||
addon := createOrderTermsPackage(t, tx, constants.PackageTypeAddon, constants.PackageExpiryBaseFromActivation, 10)
|
||||
shopID := uint(time.Now().UnixNano() & testIDMask)
|
||||
override := constants.PackageExpiryBaseFromPurchase
|
||||
for _, packageID := range []uint{formal.ID, addon.ID} {
|
||||
allocation := &model.ShopPackageAllocation{ShopID: shopID, PackageID: packageID, CostPrice: 1, RetailPrice: 2, Status: constants.StatusEnabled, ShelfStatus: 1, ExpiryBaseOverride: &override}
|
||||
if err := tx.Create(allocation).Error; err != nil {
|
||||
t.Fatalf("创建套餐覆盖配置失败:%v", err)
|
||||
}
|
||||
}
|
||||
service := &Service{shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()}
|
||||
now := time.Date(2026, 7, 22, 10, 0, 0, 0, time.Local)
|
||||
formalOrder := newOrderTermsOrder(formal.ID, card.ID, &shopID)
|
||||
if err := service.activateMainPackage(context.Background(), tx, formalOrder, formal, constants.AssetWalletResourceTypeIotCard, card.ID, now); err != nil {
|
||||
t.Fatalf("同步创建主套餐失败:%v", err)
|
||||
}
|
||||
addonOrder := newOrderTermsOrder(addon.ID, card.ID, &shopID)
|
||||
if err := service.activateAddonPackage(context.Background(), tx, addonOrder, addon, constants.AssetWalletResourceTypeIotCard, card.ID, now.Add(time.Minute)); err != nil {
|
||||
t.Fatalf("同步创建加油包失败:%v", err)
|
||||
}
|
||||
|
||||
for _, item := range []struct {
|
||||
orderID uint
|
||||
packageID uint
|
||||
durationDay int
|
||||
}{
|
||||
{formalOrder.ID, formal.ID, 45},
|
||||
{addonOrder.ID, addon.ID, 10},
|
||||
} {
|
||||
var usage model.PackageUsage
|
||||
if err := tx.Where("order_id = ? AND package_id = ?", item.orderID, item.packageID).First(&usage).Error; err != nil {
|
||||
t.Fatalf("查询套餐使用记录失败:%v", err)
|
||||
}
|
||||
if usage.ExpiryBaseSnapshot != constants.PackageExpiryBaseFromPurchase || usage.CalendarTypeSnapshot != constants.PackageCalendarTypeByDay || usage.DurationDaysSnapshot != item.durationDay {
|
||||
t.Fatalf("同步购买快照错误:%+v", usage)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Model(&model.Package{}).Where("id IN ?", []uint{formal.ID, addon.ID}).Updates(map[string]any{"expiry_base": constants.PackageExpiryBaseFromActivation, "duration_days": 99}).Error; err != nil {
|
||||
t.Fatalf("修改套餐当前配置失败:%v", err)
|
||||
}
|
||||
if err := tx.Model(&model.ShopPackageAllocation{}).Where("shop_id = ?", shopID).Update("expiry_base_override", nil).Error; err != nil {
|
||||
t.Fatalf("修改分配当前配置失败:%v", err)
|
||||
}
|
||||
var usages []model.PackageUsage
|
||||
if err := tx.Where("order_id IN ?", []uint{formalOrder.ID, addonOrder.ID}).Order("order_id").Find(&usages).Error; err != nil {
|
||||
t.Fatalf("重新查询购买快照失败:%v", err)
|
||||
}
|
||||
if len(usages) != 2 || usages[0].ExpiryBaseSnapshot != constants.PackageExpiryBaseFromPurchase || usages[1].ExpiryBaseSnapshot != constants.PackageExpiryBaseFromPurchase || usages[0].DurationDaysSnapshot == 99 || usages[1].DurationDaysSnapshot == 99 {
|
||||
t.Fatalf("购买后配置变化不应修改快照:%+v", usages)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSynchronousPurchaseRejectsInvalidTermsWithoutUsage 验证非法配置不会留下空快照使用记录。
|
||||
func TestSynchronousPurchaseRejectsInvalidTermsWithoutUsage(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
card := createOrderTermsCard(t, tx, constants.RealNameStatusVerified)
|
||||
pkg := createOrderTermsPackage(t, tx, constants.PackageTypeFormal, "invalid", 30)
|
||||
order := newOrderTermsOrder(pkg.ID, card.ID, nil)
|
||||
service := &Service{shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()}
|
||||
if err := service.activateMainPackage(context.Background(), tx, order, pkg, constants.AssetWalletResourceTypeIotCard, card.ID, time.Now()); err == nil {
|
||||
t.Fatal("非法计时条款必须拒绝同步创建")
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Model(&model.PackageUsage{}).Where("order_id = ?", order.ID).Count(&count).Error; err != nil || count != 0 {
|
||||
t.Fatalf("失败路径不应留下使用记录:count=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCEndPurchasePersistsSnapshotAndActivatesImmediately 验证 C 端购买无论 expiry_base 如何均写入快照并立即激活。
|
||||
func TestCEndPurchasePersistsSnapshotAndActivatesImmediately(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
// C 端购买前已做实名前置检查;测试用未实名卡以区分"C 端即时激活"与"实名才激活"
|
||||
card := createOrderTermsCard(t, tx, constants.RealNameStatusNotVerified)
|
||||
pkg := createOrderTermsPackage(t, tx, constants.PackageTypeFormal, constants.PackageExpiryBaseFromActivation, 20)
|
||||
service := &Service{shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()}
|
||||
now := time.Now()
|
||||
order := &model.Order{
|
||||
Model: gorm.Model{ID: uint(time.Now().UnixNano() & testIDMask)},
|
||||
OrderNo: "UR55-CEND-" + strconv.FormatInt(time.Now().UnixNano(), 10),
|
||||
OrderType: model.OrderTypeSingleCard,
|
||||
BuyerType: model.BuyerTypePersonal,
|
||||
IotCardID: &card.ID,
|
||||
TotalAmount: 100,
|
||||
Generation: 1,
|
||||
}
|
||||
if err := service.activateMainPackage(context.Background(), tx, order, pkg, constants.AssetWalletResourceTypeIotCard, card.ID, now); err != nil {
|
||||
t.Fatalf("C 端购买创建主套餐失败:%v", err)
|
||||
}
|
||||
var usage model.PackageUsage
|
||||
if err := tx.Where("order_id = ? AND package_id = ?", order.ID, pkg.ID).First(&usage).Error; err != nil {
|
||||
t.Fatalf("查询 C 端购买使用记录失败:%v", err)
|
||||
}
|
||||
// 快照必须完整
|
||||
if usage.ExpiryBaseSnapshot != constants.PackageExpiryBaseFromActivation || usage.CalendarTypeSnapshot != constants.PackageCalendarTypeByDay || usage.DurationDaysSnapshot != 20 {
|
||||
t.Fatalf("C 端购买快照错误:%+v", usage)
|
||||
}
|
||||
// C 端购买不等实名,必须立即激活
|
||||
if usage.Status != constants.PackageUsageStatusActive || usage.PendingRealnameActivation {
|
||||
t.Fatalf("C 端购买必须立即激活:status=%d pendingRealname=%v", usage.Status, usage.PendingRealnameActivation)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlatformPurchaseWithoutAllocationPersistsPackageDefaultSnapshot 验证无分配配置时使用套餐默认值写入快照。
|
||||
func TestPlatformPurchaseWithoutAllocationPersistsPackageDefaultSnapshot(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
card := createOrderTermsCard(t, tx, constants.RealNameStatusVerified)
|
||||
pkg := createOrderTermsPackage(t, tx, constants.PackageTypeFormal, constants.PackageExpiryBaseFromPurchase, 15)
|
||||
service := &Service{shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()}
|
||||
// sellerShopID=nil 模拟平台后台代购(无代理分配)
|
||||
order := newOrderTermsOrder(pkg.ID, card.ID, nil)
|
||||
if err := service.activateMainPackage(context.Background(), tx, order, pkg, constants.AssetWalletResourceTypeIotCard, card.ID, time.Now()); err != nil {
|
||||
t.Fatalf("平台代购创建主套餐失败:%v", err)
|
||||
}
|
||||
var usage model.PackageUsage
|
||||
if err := tx.Where("order_id = ? AND package_id = ?", order.ID, pkg.ID).First(&usage).Error; err != nil {
|
||||
t.Fatalf("查询平台代购使用记录失败:%v", err)
|
||||
}
|
||||
// 无分配时快照应取套餐默认值 from_purchase
|
||||
if usage.ExpiryBaseSnapshot != constants.PackageExpiryBaseFromPurchase || usage.DurationDaysSnapshot != 15 {
|
||||
t.Fatalf("平台代购快照应使用套餐默认值:%+v", usage)
|
||||
}
|
||||
}
|
||||
|
||||
func createOrderTermsCard(t *testing.T, tx *gorm.DB, realnameStatus int) *model.IotCard {
|
||||
t.Helper()
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano()%100000000000000000, 10)
|
||||
iccid := "89" + suffix
|
||||
if len(iccid) < 19 {
|
||||
iccid += "0000000000000000000"[:19-len(iccid)]
|
||||
}
|
||||
iccid = iccid[:19]
|
||||
card := &model.IotCard{ICCID: iccid, ICCID19: iccid, CarrierID: 1, RealNameStatus: realnameStatus, AssetStatus: constants.AssetStatusInStock, Generation: 1}
|
||||
if err := tx.Create(card).Error; err != nil {
|
||||
t.Fatalf("创建测试卡失败:%v", err)
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
func createOrderTermsPackage(t *testing.T, tx *gorm.DB, packageType, expiryBase string, durationDays int) *model.Package {
|
||||
t.Helper()
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
pkg := &model.Package{PackageCode: "UR55-ORDER-" + suffix, PackageName: "UR55同步购买测试套餐", PackageType: packageType, DurationMonths: 1, DurationDays: durationDays, CalendarType: constants.PackageCalendarTypeByDay, ExpiryBase: expiryBase, Status: constants.StatusEnabled, ShelfStatus: 1, DataResetCycle: "monthly"}
|
||||
if err := tx.Create(pkg).Error; err != nil {
|
||||
t.Fatalf("创建测试套餐失败:%v", err)
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
|
||||
func newOrderTermsOrder(packageID, cardID uint, sellerShopID *uint) *model.Order {
|
||||
orderID := uint(time.Now().UnixNano() & testIDMask)
|
||||
return &model.Order{Model: gorm.Model{ID: orderID}, OrderNo: "UR55-ORDER-" + strconv.FormatUint(uint64(orderID), 10), OrderType: model.OrderTypeSingleCard, BuyerType: model.BuyerTypeAgent, IotCardID: &cardID, SellerShopID: sellerShopID, TotalAmount: 100, Generation: 1}
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
package packagepkg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"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"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// testIDMask 用于将纳秒时间戳截断为合法的 uint 主键范围,仅限测试用。
|
||||
const testIDMask = 0x7fffffff
|
||||
|
||||
// TestActivateByRealnameUsesPurchasedSnapshot 验证实名激活只使用购买快照计算起止时间。
|
||||
func TestActivateByRealnameUsesPurchasedSnapshot(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
card := createActivationTermsCard(t, tx, constants.RealNameStatusVerified)
|
||||
pkg := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromPurchase, 99)
|
||||
purchasedAt := time.Now().Add(-48 * time.Hour).Truncate(time.Second)
|
||||
usage := createActivationTermsUsage(t, tx, pkg.ID, card.ID, 1, constants.PackageExpiryBaseFromPurchase, 10, true, purchasedAt)
|
||||
service := NewActivationService(tx, nil, nil, nil, nil, zap.NewNop())
|
||||
if err := service.ActivateByRealname(context.Background(), constants.AssetTypeIotCard, card.ID); err != nil {
|
||||
t.Fatalf("实名激活失败:%v", err)
|
||||
}
|
||||
var refreshed model.PackageUsage
|
||||
if err := tx.First(&refreshed, usage.ID).Error; err != nil {
|
||||
t.Fatalf("查询激活结果失败:%v", err)
|
||||
}
|
||||
expectedExpiry := CalculateExpiryTime(constants.PackageCalendarTypeByDay, refreshed.CreatedAt, 0, 10)
|
||||
if refreshed.ActivatedAt == nil || refreshed.ExpiresAt == nil || refreshed.ActivatedAt.Unix() != refreshed.CreatedAt.Unix() || refreshed.ExpiresAt.Unix() != expectedExpiry.Unix() {
|
||||
t.Fatalf("实名激活未使用购买快照:%+v", refreshed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActivateNextPendingMainPackageUsesQueueSnapshotAndIsIdempotent 验证连续排队按优先级和各自快照接续。
|
||||
func TestActivateNextPendingMainPackageUsesQueueSnapshotAndIsIdempotent(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
redisClient := testutil.NewRedisClient(t)
|
||||
card := createActivationTermsCard(t, tx, constants.RealNameStatusVerified)
|
||||
firstPackage := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromPurchase, 90)
|
||||
secondPackage := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromPurchase, 90)
|
||||
first := createActivationTermsUsage(t, tx, firstPackage.ID, card.ID, 1, constants.PackageExpiryBaseFromActivation, 3, false, time.Now().Add(-time.Hour))
|
||||
second := createActivationTermsUsage(t, tx, secondPackage.ID, card.ID, 2, constants.PackageExpiryBaseFromActivation, 4, false, time.Now())
|
||||
service := NewActivationService(tx, redisClient, nil, nil, nil, zap.NewNop())
|
||||
activated, err := service.ActivateNextPendingMainPackage(context.Background(), constants.AssetTypeIotCard, card.ID)
|
||||
if err != nil || !activated {
|
||||
t.Fatalf("激活队首套餐失败:activated=%v err=%v", activated, err)
|
||||
}
|
||||
activated, err = service.ActivateNextPendingMainPackage(context.Background(), constants.AssetTypeIotCard, card.ID)
|
||||
if err != nil || activated {
|
||||
t.Fatalf("已有生效主套餐时重复接续应幂等:activated=%v err=%v", activated, err)
|
||||
}
|
||||
if err := tx.Model(&model.PackageUsage{}).Where("id = ?", first.ID).Update("status", constants.PackageUsageStatusExpired).Error; err != nil {
|
||||
t.Fatalf("结束队首套餐失败:%v", err)
|
||||
}
|
||||
activated, err = service.ActivateNextPendingMainPackage(context.Background(), constants.AssetTypeIotCard, card.ID)
|
||||
if err != nil || !activated {
|
||||
t.Fatalf("激活第二个排队套餐失败:activated=%v err=%v", activated, err)
|
||||
}
|
||||
var refreshed []model.PackageUsage
|
||||
if err := tx.Where("id IN ?", []uint{first.ID, second.ID}).Order("priority").Find(&refreshed).Error; err != nil {
|
||||
t.Fatalf("查询队列接续结果失败:%v", err)
|
||||
}
|
||||
if len(refreshed) != 2 || refreshed[1].ExpiresAt == nil || refreshed[1].ActivatedAt == nil || !refreshed[1].ExpiresAt.Equal(CalculateExpiryTime(constants.PackageCalendarTypeByDay, *refreshed[1].ActivatedAt, 0, 4)) {
|
||||
t.Fatalf("第二个排队套餐未使用自身快照:%+v", refreshed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefundFollowUpActivatesNextPackageFromSnapshot 验证退款失效后下一套餐按购买快照接续。
|
||||
func TestRefundFollowUpActivatesNextPackageFromSnapshot(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
redisClient := testutil.NewRedisClient(t)
|
||||
card := createActivationTermsCard(t, tx, constants.RealNameStatusVerified)
|
||||
currentPackage := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromPurchase, 60)
|
||||
nextPackage := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromPurchase, 60)
|
||||
current := createActivationTermsUsage(t, tx, currentPackage.ID, card.ID, 1, constants.PackageExpiryBaseFromPurchase, 30, false, time.Now().Add(-time.Hour))
|
||||
now := time.Now()
|
||||
if err := tx.Model(current).Updates(map[string]any{"status": constants.PackageUsageStatusActive, "activated_at": now, "expires_at": now.AddDate(0, 0, 30)}).Error; err != nil {
|
||||
t.Fatalf("设置当前生效套餐失败:%v", err)
|
||||
}
|
||||
next := createActivationTermsUsage(t, tx, nextPackage.ID, card.ID, 2, constants.PackageExpiryBaseFromActivation, 6, false, time.Now())
|
||||
service := NewActivationService(tx, redisClient, nil, nil, nil, zap.NewNop())
|
||||
if err := service.InvalidatePackagesForRefund(context.Background(), constants.AssetTypeIotCard, card.ID, current.OrderID, 77, "UR55-REFUND", ¤t.ID); err != nil {
|
||||
t.Fatalf("退款失效当前套餐失败:%v", err)
|
||||
}
|
||||
activated, err := service.ActivateNextPendingMainPackage(context.Background(), constants.AssetTypeIotCard, card.ID)
|
||||
if err != nil || !activated {
|
||||
t.Fatalf("退款后接续下一套餐失败:activated=%v err=%v", activated, err)
|
||||
}
|
||||
var refreshed model.PackageUsage
|
||||
if err := tx.First(&refreshed, next.ID).Error; err != nil {
|
||||
t.Fatalf("查询退款接续结果失败:%v", err)
|
||||
}
|
||||
if refreshed.ExpiresAt == nil || refreshed.ActivatedAt == nil || !refreshed.ExpiresAt.Equal(CalculateExpiryTime(constants.PackageCalendarTypeByDay, *refreshed.ActivatedAt, 0, 6)) {
|
||||
t.Fatalf("退款接续未使用下一套餐快照:%+v", refreshed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFromPurchaseActivatesImmediatelyAtPurchaseTime 验证 from_purchase 快照在购买时直接激活,不等实名。
|
||||
func TestFromPurchaseActivatesImmediatelyAtPurchaseTime(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
redisClient := testutil.NewRedisClient(t)
|
||||
card := createActivationTermsCard(t, tx, constants.RealNameStatusNotVerified)
|
||||
pkg := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromPurchase, 7)
|
||||
usage := createActivationTermsUsage(t, tx, pkg.ID, card.ID, 1, constants.PackageExpiryBaseFromPurchase, 7, false, time.Now())
|
||||
service := NewActivationService(tx, redisClient, nil, nil, nil, zap.NewNop())
|
||||
if err := service.ActivateSpecificPackage(context.Background(), usage.ID); err != nil {
|
||||
t.Fatalf("from_purchase 直接激活失败:%v", err)
|
||||
}
|
||||
var refreshed model.PackageUsage
|
||||
if err := tx.First(&refreshed, usage.ID).Error; err != nil {
|
||||
t.Fatalf("查询激活结果失败:%v", err)
|
||||
}
|
||||
if refreshed.Status != constants.PackageUsageStatusActive || refreshed.ActivatedAt == nil || refreshed.ExpiresAt == nil {
|
||||
t.Fatalf("from_purchase 套餐应已激活:status=%d activatedAt=%v expiresAt=%v", refreshed.Status, refreshed.ActivatedAt, refreshed.ExpiresAt)
|
||||
}
|
||||
if refreshed.PendingRealnameActivation {
|
||||
t.Fatal("from_purchase 不应标记待实名")
|
||||
}
|
||||
expectedExpiry := CalculateExpiryTime(constants.PackageCalendarTypeByDay, *refreshed.ActivatedAt, 0, 7)
|
||||
if !refreshed.ExpiresAt.Equal(expectedExpiry) {
|
||||
t.Fatalf("to期时间不符:want=%v got=%v", expectedExpiry, refreshed.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistoricalFallbackFiresWarningAndIncrementsCounter 验证历史记录缺少快照时回退套餐当前配置并递增计数器。
|
||||
func TestHistoricalFallbackFiresWarningAndIncrementsCounter(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
redisClient := testutil.NewRedisClient(t)
|
||||
card := createActivationTermsCard(t, tx, constants.RealNameStatusVerified)
|
||||
pkg := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromActivation, 21)
|
||||
// 仅在会回滚的测试事务内禁用触发器,以构造 UR#55 上线前的历史空快照记录。
|
||||
if err := tx.Exec("ALTER TABLE tb_package_usage DISABLE TRIGGER trg_validate_package_usage_terms_snapshot").Error; err != nil {
|
||||
t.Fatalf("禁用快照校验触发器失败:%v", err)
|
||||
}
|
||||
// 历史记录:四个快照字段全为空值/零值
|
||||
unique := uint(time.Now().UnixNano() & testIDMask)
|
||||
usage := &model.PackageUsage{
|
||||
OrderID: unique, OrderNo: "UR55-HIST-" + strconv.FormatUint(uint64(unique), 10),
|
||||
PackageID: pkg.ID, PackageName: "UR55历史测试套餐",
|
||||
UsageType: constants.AssetWalletResourceTypeIotCard, IotCardID: card.ID,
|
||||
DataLimitMB: 1, Status: constants.PackageUsageStatusPending, Priority: 1, Generation: 1,
|
||||
// 快照字段均留空,模拟 UR#55 上线前的旧数据
|
||||
}
|
||||
if err := tx.Omit("status").Create(usage).Error; err != nil {
|
||||
t.Fatalf("创建历史测试使用记录失败:%v", err)
|
||||
}
|
||||
if err := tx.Model(usage).Update("status", constants.PackageUsageStatusPending).Error; err != nil {
|
||||
t.Fatalf("设置历史测试状态失败:%v", err)
|
||||
}
|
||||
before := HistoricalTermsFallbackCount()
|
||||
service := NewActivationService(tx, redisClient, nil, nil, nil, zap.NewNop())
|
||||
if err := service.ActivateSpecificPackage(context.Background(), usage.ID); err != nil {
|
||||
t.Fatalf("历史记录兼容激活失败:%v", err)
|
||||
}
|
||||
after := HistoricalTermsFallbackCount()
|
||||
if after <= before {
|
||||
t.Fatalf("历史回退计数器未递增:before=%d after=%d", before, after)
|
||||
}
|
||||
var refreshed model.PackageUsage
|
||||
if err := tx.First(&refreshed, usage.ID).Error; err != nil || refreshed.Status != constants.PackageUsageStatusActive {
|
||||
t.Fatalf("历史记录应兼容激活成功:status=%d err=%v", refreshed.Status, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActivateSpecificPackageRejectsPartialSnapshot 验证新记录非法快照不会静默回退当前套餐配置。
|
||||
func TestActivateSpecificPackageRejectsPartialSnapshot(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
redisClient := testutil.NewRedisClient(t)
|
||||
card := createActivationTermsCard(t, tx, constants.RealNameStatusVerified)
|
||||
pkg := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromPurchase, 30)
|
||||
usage := createActivationTermsUsage(t, tx, pkg.ID, card.ID, 1, constants.PackageExpiryBaseFromPurchase, 30, false, time.Now())
|
||||
if err := tx.Exec("ALTER TABLE tb_package_usage DISABLE TRIGGER trg_validate_package_usage_terms_snapshot").Error; err != nil {
|
||||
t.Fatalf("禁用快照校验触发器失败:%v", err)
|
||||
}
|
||||
if err := tx.Model(usage).Update("calendar_type_snapshot", "invalid").Error; err != nil {
|
||||
t.Fatalf("构造非法快照失败:%v", err)
|
||||
}
|
||||
if err := tx.Exec("ALTER TABLE tb_package_usage ENABLE TRIGGER trg_validate_package_usage_terms_snapshot").Error; err != nil {
|
||||
t.Fatalf("恢复快照校验触发器失败:%v", err)
|
||||
}
|
||||
service := NewActivationService(tx, redisClient, nil, nil, nil, zap.NewNop())
|
||||
if err := service.ActivateSpecificPackage(context.Background(), usage.ID); err == nil {
|
||||
t.Fatal("非法部分快照必须拒绝激活")
|
||||
}
|
||||
var refreshed model.PackageUsage
|
||||
if err := tx.First(&refreshed, usage.ID).Error; err != nil || refreshed.Status != constants.PackageUsageStatusPending {
|
||||
t.Fatalf("非法快照失败后状态必须保持待生效:status=%d err=%v", refreshed.Status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func createActivationTermsCard(t *testing.T, tx *gorm.DB, realnameStatus int) *model.IotCard {
|
||||
t.Helper()
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano()%100000000000000000, 10)
|
||||
iccid := "87" + suffix
|
||||
if len(iccid) < 19 {
|
||||
iccid += "0000000000000000000"[:19-len(iccid)]
|
||||
}
|
||||
iccid = iccid[:19]
|
||||
card := &model.IotCard{ICCID: iccid, ICCID19: iccid, CarrierID: 1, RealNameStatus: realnameStatus, AssetStatus: constants.AssetStatusInStock, Generation: 1}
|
||||
if err := tx.Create(card).Error; err != nil {
|
||||
t.Fatalf("创建激活测试卡失败:%v", err)
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
func createActivationTermsPackage(t *testing.T, tx *gorm.DB, expiryBase string, durationDays int) *model.Package {
|
||||
t.Helper()
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
pkg := &model.Package{PackageCode: "UR55-ACTIVATE-" + suffix, PackageName: "UR55激活测试套餐", PackageType: constants.PackageTypeFormal, DurationMonths: 1, DurationDays: durationDays, CalendarType: constants.PackageCalendarTypeByDay, ExpiryBase: expiryBase, Status: constants.StatusEnabled, ShelfStatus: 1, DataResetCycle: "monthly"}
|
||||
if err := tx.Create(pkg).Error; err != nil {
|
||||
t.Fatalf("创建激活测试套餐失败:%v", err)
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
|
||||
func createActivationTermsUsage(t *testing.T, tx *gorm.DB, packageID, cardID uint, priority int, expiryBase string, durationDays int, pendingRealname bool, createdAt time.Time) *model.PackageUsage {
|
||||
t.Helper()
|
||||
unique := uint(time.Now().UnixNano() & testIDMask)
|
||||
usage := &model.PackageUsage{Model: gorm.Model{CreatedAt: createdAt}, OrderID: unique, OrderNo: "UR55-ACTIVATE-" + strconv.FormatUint(uint64(unique), 10), PackageID: packageID, PackageName: "UR55激活测试套餐", UsageType: constants.AssetWalletResourceTypeIotCard, IotCardID: cardID, DataLimitMB: 1, Status: constants.PackageUsageStatusPending, Priority: priority, PendingRealnameActivation: pendingRealname, Generation: 1, ExpiryBaseSnapshot: expiryBase, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: durationDays}
|
||||
if err := tx.Omit("status", "pending_realname_activation").Create(usage).Error; err != nil {
|
||||
t.Fatalf("创建激活测试使用记录失败:%v", err)
|
||||
}
|
||||
if err := tx.Model(usage).Updates(map[string]any{"status": constants.PackageUsageStatusPending, "pending_realname_activation": pendingRealname}).Error; err != nil {
|
||||
t.Fatalf("设置激活测试状态失败:%v", err)
|
||||
}
|
||||
return usage
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package packagepkg
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// TestValidateExpiryBaseOverride 验证覆盖字段必须显式提交且仅接受既定枚举。
|
||||
func TestValidateExpiryBaseOverride(t *testing.T) {
|
||||
fromPurchase := constants.PackageExpiryBaseFromPurchase
|
||||
invalid := "from_realname"
|
||||
tests := []struct {
|
||||
name string
|
||||
value *string
|
||||
submitted bool
|
||||
wantValue *string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "字段缺失", wantErr: true},
|
||||
{name: "跟随默认", submitted: true},
|
||||
{name: "购买即生效", value: &fromPurchase, submitted: true, wantValue: &fromPurchase},
|
||||
{name: "非法枚举", value: &invalid, submitted: true, wantErr: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := ValidateExpiryBaseOverride(test.value, test.submitted)
|
||||
if (err != nil) != test.wantErr {
|
||||
t.Fatalf("错误状态不符合预期:%v", err)
|
||||
}
|
||||
if test.wantValue != nil && (got == nil || *got != *test.wantValue) {
|
||||
t.Fatalf("覆盖值不符合预期:%v", got)
|
||||
}
|
||||
if test.wantValue == nil && !test.wantErr && got != nil {
|
||||
t.Fatalf("期望跟随默认,实际为:%v", *got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEffectiveExpiryBase 验证覆盖优先于套餐默认值。
|
||||
func TestEffectiveExpiryBase(t *testing.T) {
|
||||
pkg := &model.Package{ExpiryBase: constants.PackageExpiryBaseFromActivation}
|
||||
if got := EffectiveExpiryBase(pkg, nil); got != constants.PackageExpiryBaseFromActivation {
|
||||
t.Fatalf("无覆盖时应使用套餐默认值,实际为 %s", got)
|
||||
}
|
||||
override := constants.PackageExpiryBaseFromPurchase
|
||||
allocation := &model.ShopPackageAllocation{ExpiryBaseOverride: &override}
|
||||
if got := EffectiveExpiryBase(pkg, allocation); got != constants.PackageExpiryBaseFromPurchase {
|
||||
t.Fatalf("有覆盖时应使用覆盖值,实际为 %s", got)
|
||||
}
|
||||
pkg.ExpiryBase = constants.PackageExpiryBaseFromPurchase
|
||||
if got := EffectiveExpiryBase(pkg, allocation); got != constants.PackageExpiryBaseFromPurchase {
|
||||
t.Fatalf("套餐默认值变化不应改变覆盖结果,实际为 %s", got)
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package packagepkg
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// TestResolveUsageTerms 验证快照优先、历史完整空值回退和部分缺失拒绝。
|
||||
func TestResolveUsageTerms(t *testing.T) {
|
||||
pkg := &model.Package{
|
||||
ExpiryBase: constants.PackageExpiryBaseFromActivation,
|
||||
CalendarType: constants.PackageCalendarTypeByDay, DurationDays: 30,
|
||||
}
|
||||
usage := &model.PackageUsage{
|
||||
ExpiryBaseSnapshot: constants.PackageExpiryBaseFromPurchase,
|
||||
CalendarTypeSnapshot: constants.PackageCalendarTypeNaturalMonth,
|
||||
DurationMonthsSnapshot: 12,
|
||||
}
|
||||
terms, err := ResolveUsageTerms(usage, pkg, zap.NewNop())
|
||||
if err != nil || terms.ExpiryBase != constants.PackageExpiryBaseFromPurchase || terms.DurationMonths != 12 {
|
||||
t.Fatalf("应优先读取不可变快照:%+v, %v", terms, err)
|
||||
}
|
||||
|
||||
before := HistoricalTermsFallbackCount()
|
||||
historical := &model.PackageUsage{Model: usage.Model}
|
||||
terms, err = ResolveUsageTerms(historical, pkg, zap.NewNop())
|
||||
if err != nil || terms.DurationDays != 30 || HistoricalTermsFallbackCount() != before+1 {
|
||||
t.Fatalf("历史空快照应可观测回退:%+v, %v", terms, err)
|
||||
}
|
||||
|
||||
broken := &model.PackageUsage{ExpiryBaseSnapshot: constants.PackageExpiryBaseFromPurchase}
|
||||
if _, err = ResolveUsageTerms(broken, pkg, zap.NewNop()); err == nil {
|
||||
t.Fatal("部分缺失快照必须拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveUsageTermsKeepsPurchasedTerms 验证购买后修改套餐配置不改变已有使用记录语义。
|
||||
func TestResolveUsageTermsKeepsPurchasedTerms(t *testing.T) {
|
||||
usage := &model.PackageUsage{
|
||||
ExpiryBaseSnapshot: constants.PackageExpiryBaseFromPurchase,
|
||||
CalendarTypeSnapshot: constants.PackageCalendarTypeByDay,
|
||||
DurationDaysSnapshot: 90,
|
||||
}
|
||||
pkg := &model.Package{
|
||||
ExpiryBase: constants.PackageExpiryBaseFromActivation,
|
||||
CalendarType: constants.PackageCalendarTypeNaturalMonth,
|
||||
DurationMonths: 1,
|
||||
}
|
||||
terms, err := ResolveUsageTerms(usage, pkg, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("读取购买快照失败:%v", err)
|
||||
}
|
||||
if terms.ExpiryBase != constants.PackageExpiryBaseFromPurchase || terms.CalendarType != constants.PackageCalendarTypeByDay || terms.DurationDays != 90 {
|
||||
t.Fatalf("套餐当前配置不应覆盖购买快照:%+v", terms)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user