Files
junhong_cmp_fiber/internal/query/exchange/list_test.go

220 lines
10 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 exchange
import (
"context"
"fmt"
"sort"
"sync/atomic"
"testing"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/testutil"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// TestListQuerySearchesOldAndNewAssetsIndependently 验证六类资产标识、新旧独立和双条件 AND。
func TestListQuerySearchesOldAndNewAssetsIndependently(t *testing.T) {
tx := testutil.NewPostgresTransaction(t)
query := NewListQuery(tx)
oldCard := createListTestCard(t, tx, 1, nil)
newCard := createListTestCard(t, tx, 2, nil)
oldDevice := createListTestDevice(t, tx, 1, nil)
newDevice := createListTestDevice(t, tx, 2, nil)
cardOrder := createListTestOrder(t, tx, "UR45-Q-CARD", oldCard.ID, constants.ExchangeAssetTypeIotCard, newCard.ID, constants.ExchangeAssetTypeIotCard, time.Now().Add(-time.Hour))
deviceOrder := createListTestOrder(t, tx, "UR45-Q-DEVICE", oldDevice.ID, constants.ExchangeAssetTypeDevice, newDevice.ID, constants.ExchangeAssetTypeDevice, time.Now())
for name, keyword := range map[string]string{"卡ICCID": oldCard.ICCID, "卡接入号": oldCard.MSISDN, "卡虚拟号": oldCard.VirtualNo} {
t.Run(name, func(t *testing.T) {
assertListQueryIDs(t, query, &dto.ExchangeListRequest{OldAssetKeyword: keyword}, []uint{cardOrder.ID})
})
}
for name, keyword := range map[string]string{"设备虚拟号": newDevice.VirtualNo, "设备IMEI": newDevice.IMEI, "设备SN": newDevice.SN} {
t.Run(name, func(t *testing.T) {
assertListQueryIDs(t, query, &dto.ExchangeListRequest{NewAssetKeyword: keyword}, []uint{deviceOrder.ID})
})
}
assertListQueryIDs(t, query, &dto.ExchangeListRequest{OldAssetKeyword: oldCard.MSISDN, NewAssetKeyword: newCard.VirtualNo}, []uint{cardOrder.ID})
assertListQueryIDs(t, query, &dto.ExchangeListRequest{OldAssetKeyword: oldCard.ICCID, NewAssetKeyword: newDevice.IMEI}, nil)
}
// TestListQueryCombinesFiltersAndPreservesHistoricalSnapshots 验证组合条件、历史快照和空结果契约。
func TestListQueryCombinesFiltersAndPreservesHistoricalSnapshots(t *testing.T) {
tx := testutil.NewPostgresTransaction(t)
query := NewListQuery(tx)
oldCard := createListTestCard(t, tx, 11, nil)
newCard := createListTestCard(t, tx, 12, nil)
createdAt := time.Now().Add(-2 * time.Hour).Truncate(time.Second)
order := createListTestOrder(t, tx, "UR45-Q-HISTORY", oldCard.ID, constants.ExchangeAssetTypeIotCard, newCard.ID, constants.ExchangeAssetTypeIotCard, createdAt)
if err := tx.Model(order).Updates(map[string]any{
"old_asset_identifier": "历史旧快照", "new_asset_identifier": "历史新快照",
"status": constants.ExchangeStatusCompleted, "flow_type": constants.ExchangeFlowTypeDirect,
}).Error; err != nil {
t.Fatalf("更新历史快照失败:%v", err)
}
status := constants.ExchangeStatusCompleted
start, end := createdAt.Add(-time.Minute), createdAt.Add(time.Minute)
result, err := query.List(context.Background(), &dto.ExchangeListRequest{
OldAssetKeyword: oldCard.VirtualNo, NewAssetKeyword: newCard.MSISDN,
Status: &status, FlowType: constants.ExchangeFlowTypeDirect, CreatedAtStart: &start, CreatedAtEnd: &end,
})
if err != nil {
t.Fatalf("查询历史换货单失败:%v", err)
}
if result.Total != 1 || len(result.List) != 1 || result.List[0].OldAssetIdentifier != "历史旧快照" || result.List[0].NewAssetIdentifier != "历史新快照" {
t.Fatalf("历史快照查询结果错误:%+v", result)
}
assertListQueryIDs(t, query, &dto.ExchangeListRequest{OldAssetKeyword: "不存在"}, nil)
}
// TestListQueryExcludesDeletedAssetsAndAppliesShopScope 验证候选软删除和最终换货单店铺范围。
func TestListQueryExcludesDeletedAssetsAndAppliesShopScope(t *testing.T) {
tx := testutil.NewPostgresTransaction(t)
query := NewListQuery(tx)
shopOne, shopTwo := uint(45101), uint(45102)
cardOne := createListTestCard(t, tx, 21, &shopOne)
cardTwo := createListTestCard(t, tx, 22, &shopTwo)
orderOne := createListTestOrder(t, tx, "UR45-Q-SCOPE-1", cardOne.ID, constants.ExchangeAssetTypeIotCard, 0, "", time.Now())
orderTwo := createListTestOrder(t, tx, "UR45-Q-SCOPE-2", cardTwo.ID, constants.ExchangeAssetTypeIotCard, 0, "", time.Now().Add(time.Second))
if err := tx.Model(orderOne).Update("shop_id", shopOne).Error; err != nil {
t.Fatalf("更新店铺范围失败:%v", err)
}
if err := tx.Model(orderTwo).Update("shop_id", shopTwo).Error; err != nil {
t.Fatalf("更新店铺范围失败:%v", err)
}
ctx := context.WithValue(context.Background(), constants.ContextKeySubordinateShopIDs, []uint{shopOne})
result, err := query.List(ctx, &dto.ExchangeListRequest{OldAssetKeyword: "UR45-Q-CARD"})
if err != nil {
t.Fatalf("按店铺范围查询失败:%v", err)
}
if result.Total != 1 || result.List[0].ID != orderOne.ID {
t.Fatalf("店铺范围被关键词绕过:%+v", result)
}
if err := tx.Delete(cardOne).Error; err != nil {
t.Fatalf("软删除测试卡失败:%v", err)
}
result, err = query.List(ctx, &dto.ExchangeListRequest{OldAssetKeyword: cardOne.ICCID})
if err != nil || result.Total != 0 {
t.Fatalf("软删除候选资产不应命中result=%+v err=%v", result, err)
}
}
// TestListQueryUsesFixedQueriesAndConsistentPagination 验证大结果集仅执行计数和分页两条 SQL。
func TestListQueryUsesFixedQueriesAndConsistentPagination(t *testing.T) {
tx := testutil.NewPostgresTransaction(t)
card := createListTestCard(t, tx, 31, nil)
for index := 0; index < 120; index++ {
createListTestOrder(t, tx, fmt.Sprintf("UR45-Q-PERF-%03d", index), card.ID, constants.ExchangeAssetTypeIotCard, 0, "", time.Now().Add(time.Duration(index)*time.Second))
}
counter := &queryCounter{Interface: tx.Logger}
query := NewListQuery(tx.Session(&gorm.Session{Logger: counter}))
page, pageSize := 2, 20
req := &dto.ExchangeListRequest{OldAssetKeyword: card.ICCID, Page: &page, PageSize: &pageSize}
durations := make([]time.Duration, 100)
var result *dto.ExchangeListResponse
for index := range durations {
startedAt := time.Now()
var err error
result, err = query.List(context.Background(), req)
durations[index] = time.Since(startedAt)
if err != nil {
t.Fatalf("第 %d 次查询大结果集失败:%v", index+1, err)
}
}
if counter.count.Load() != 200 || result.Total != 120 || len(result.List) != 20 || result.List[0].ExchangeNo != "UR45-Q-PERF-099" {
t.Fatalf("查询次数或分页不一致queries=%d total=%d items=%d first=%s", counter.count.Load(), result.Total, len(result.List), result.List[0].ExchangeNo)
}
sort.Slice(durations, func(left, right int) bool { return durations[left] < durations[right] })
p95, p99 := durations[94], durations[98]
if p95 >= 200*time.Millisecond || p99 >= 500*time.Millisecond {
t.Fatalf("列表查询超过性能目标p95=%s p99=%s", p95, p99)
}
planSQL := query.db.ToSQL(func(db *gorm.DB) *gorm.DB {
return applyListFilters(db.Model(&model.ExchangeOrder{}), req).Order("created_at DESC").Offset(20).Limit(20).Find(&[]*model.ExchangeOrder{})
})
var planLines []string
if err := tx.Raw("EXPLAIN " + planSQL).Scan(&planLines).Error; err != nil || len(planLines) == 0 {
t.Fatalf("记录查询计划失败plan=%v err=%v", planLines, err)
}
}
// TestListQueryWrapsDatabaseErrors 验证数据库故障不会降级为空结果。
func TestListQueryWrapsDatabaseErrors(t *testing.T) {
tx := testutil.NewPostgresTransaction(t)
callbackName := "ur45:force_query_error"
if err := tx.Callback().Query().Before("gorm:query").Register(callbackName, func(db *gorm.DB) {
db.AddError(fmt.Errorf("UR45 模拟数据库故障"))
}); err != nil {
t.Fatalf("注册数据库故障回调失败:%v", err)
}
t.Cleanup(func() { _ = tx.Callback().Query().Remove(callbackName) })
_, err := NewListQuery(tx).List(context.Background(), &dto.ExchangeListRequest{})
appErr, ok := err.(*errors.AppError)
if !ok || appErr.Code != errors.CodeDatabaseError {
t.Fatalf("数据库故障应转换为统一错误,实际:%v", err)
}
}
type queryCounter struct {
logger.Interface
count atomic.Int64
}
func (l *queryCounter) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
l.count.Add(1)
l.Interface.Trace(ctx, begin, fc, err)
}
func createListTestCard(t *testing.T, tx *gorm.DB, suffix int, shopID *uint) *model.IotCard {
t.Helper()
iccid := fmt.Sprintf("8986111111111111%04d", suffix)
card := &model.IotCard{ICCID: iccid, ICCID19: iccid[:19], MSISDN: fmt.Sprintf("1370000%04d", suffix), VirtualNo: fmt.Sprintf("UR45-Q-CARD-%04d", suffix), ShopID: shopID, AssetStatus: constants.AssetStatusInStock}
if err := tx.Create(card).Error; err != nil {
t.Fatalf("创建测试卡失败:%v", err)
}
return card
}
func createListTestDevice(t *testing.T, tx *gorm.DB, suffix int, shopID *uint) *model.Device {
t.Helper()
device := &model.Device{VirtualNo: fmt.Sprintf("UR45-Q-DEVICE-%04d", suffix), IMEI: fmt.Sprintf("86111111111%04d", suffix), SN: fmt.Sprintf("UR45-Q-SN-%04d", suffix), ShopID: shopID, AssetStatus: constants.AssetStatusInStock}
if err := tx.Create(device).Error; err != nil {
t.Fatalf("创建设备失败:%v", err)
}
return device
}
func createListTestOrder(t *testing.T, tx *gorm.DB, exchangeNo string, oldID uint, oldType string, newID uint, newType string, createdAt time.Time) *model.ExchangeOrder {
t.Helper()
order := &model.ExchangeOrder{ExchangeNo: exchangeNo, FlowType: constants.ExchangeFlowTypeShipping, OldAssetType: oldType, OldAssetID: oldID, OldAssetIdentifier: "历史旧快照", NewAssetType: newType, NewAssetIdentifier: "历史新快照", ExchangeReason: "UR45 查询测试", Status: constants.ExchangeStatusPendingInfo}
if newID > 0 {
order.NewAssetID = &newID
}
order.CreatedAt, order.UpdatedAt = createdAt, createdAt
if err := tx.Create(order).Error; err != nil {
t.Fatalf("创建换货单失败:%v", err)
}
return order
}
func assertListQueryIDs(t *testing.T, query *ListQuery, req *dto.ExchangeListRequest, expected []uint) {
t.Helper()
result, err := query.List(context.Background(), req)
if err != nil {
t.Fatalf("查询换货单失败:%v", err)
}
if result.Total != int64(len(expected)) || len(result.List) != len(expected) {
t.Fatalf("命中数量错误total=%d items=%d expected=%d", result.Total, len(result.List), len(expected))
}
for index, id := range expected {
if result.List[index].ID != id {
t.Fatalf("命中换货单错误:期望 %d实际 %d", id, result.List[index].ID)
}
}
}