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) } } }