Files
junhong_cmp_fiber/internal/routes/asset_exchange_trace_integration_test.go
break f7c42252c0
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m55s
补齐UR46后端验收测试
2026-07-23 16:15:02 +09:00

955 lines
46 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 routes
import (
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"testing"
"time"
"github.com/bytedance/sonic"
"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
apphandler "github.com/break/junhong_cmp_fiber/internal/handler/app"
internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/model"
assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset"
packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
assetService "github.com/break/junhong_cmp_fiber/internal/service/asset"
customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
deviceService "github.com/break/junhong_cmp_fiber/internal/service/device"
iotCardService "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/auth"
"github.com/break/junhong_cmp_fiber/pkg/config"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/database"
"github.com/break/junhong_cmp_fiber/pkg/errors"
pkgMiddleware "github.com/break/junhong_cmp_fiber/pkg/middleware"
)
const (
// testListPageSize 是列表批量验收的代表性每页资产数量。
testListPageSize = 100
// testHTTPPerformanceLimit 是单次集成请求可验证的项目 API P99 响应耗时上限。
testHTTPPerformanceLimit = 500 * time.Millisecond
)
// TestAssetResolveHTTPIntegratesAuthenticationPermissionsAndExchangeTrace 验证真实认证、权限、资产解析和双向换货投影。
func TestAssetResolveHTTPIntegratesAuthenticationPermissionsAndExchangeTrace(t *testing.T) {
env := newAssetTraceHTTPEnv(t)
visibleShop := env.createShop(t, "UR86 可见店铺", nil, 1)
hiddenShop := env.createShop(t, "UR86 不可见店铺", nil, 1)
oldCard := env.createCard(t, 1, &hiddenShop.ID)
middleCard := env.createCard(t, 2, &visibleShop.ID)
nextCard := env.createCard(t, 3, &visibleShop.ID)
env.createOrder(t, "UR86-HTTP-PREV", oldCard, middleCard, "HTTP 历史前代快照", "HTTP 中间快照", time.Now().Add(-time.Minute))
env.createOrder(t, "UR86-HTTP-NEXT", middleCard, nextCard, "HTTP 中间快照", "HTTP 历史后代快照", time.Now())
token := env.newAgentToken(t, visibleShop.ID)
status, body := env.request(t, "/api/admin/assets/resolve/"+middleCard.ICCID, token)
if status != http.StatusOK {
t.Fatalf("查询中间资产失败,状态 %d%s", status, body)
}
var success struct {
Code int `json:"code"`
Data struct {
AssetID uint `json:"asset_id"`
ExchangeTrace struct {
PreviousAsset *struct {
AssetID *uint `json:"asset_id"`
Identifier string `json:"identifier"`
ExchangeNo string `json:"exchange_no"`
CanView bool `json:"can_view"`
} `json:"previous_asset"`
NextAsset *struct {
AssetID *uint `json:"asset_id"`
Identifier string `json:"identifier"`
ExchangeNo string `json:"exchange_no"`
CanView bool `json:"can_view"`
} `json:"next_asset"`
} `json:"exchange_trace"`
} `json:"data"`
}
if err := sonic.Unmarshal(body, &success); err != nil {
t.Fatalf("解析资产详情响应失败:%v", err)
}
if success.Code != errors.CodeSuccess || success.Data.AssetID != middleCard.ID || success.Data.ExchangeTrace.PreviousAsset == nil || success.Data.ExchangeTrace.NextAsset == nil {
t.Fatalf("双向换货响应不完整:%s", body)
}
previous := success.Data.ExchangeTrace.PreviousAsset
if previous.CanView || previous.AssetID != nil || previous.Identifier != "HTTP 历史前代快照" || previous.ExchangeNo != "UR86-HTTP-PREV" {
t.Fatalf("不可见前代未按契约隐藏 ID%s", body)
}
next := success.Data.ExchangeTrace.NextAsset
if !next.CanView || next.AssetID == nil || *next.AssetID != nextCard.ID || next.Identifier != "HTTP 历史后代快照" || next.ExchangeNo != "UR86-HTTP-NEXT" {
t.Fatalf("可见后代未按契约返回:%s", body)
}
noTraceCard := env.createCard(t, 4, &visibleShop.ID)
status, body = env.request(t, "/api/admin/assets/resolve/"+noTraceCard.ICCID, token)
assertHTTPTraceDirections(t, status, body, false, false)
previousOnlyOld := env.createCard(t, 5, &visibleShop.ID)
previousOnlyNew := env.createCard(t, 6, &visibleShop.ID)
env.createOrder(t, "UR86-HTTP-PREV-ONLY", previousOnlyOld, previousOnlyNew, "仅前代旧快照", "仅前代新快照", time.Now())
status, body = env.request(t, "/api/admin/assets/resolve/"+previousOnlyNew.ICCID, token)
assertHTTPTraceDirections(t, status, body, true, false)
nextOnlyOld := env.createCard(t, 7, &visibleShop.ID)
nextOnlyNew := env.createCard(t, 8, &visibleShop.ID)
env.createOrder(t, "UR86-HTTP-NEXT-ONLY", nextOnlyOld, nextOnlyNew, "仅后代旧快照", "仅后代新快照", time.Now())
status, body = env.request(t, "/api/admin/assets/resolve/"+nextOnlyOld.ICCID, token)
assertHTTPTraceDirections(t, status, body, false, true)
status, body = env.request(t, "/api/admin/assets/resolve/"+oldCard.ICCID, token)
if status != http.StatusNotFound {
t.Fatalf("当前资产无权限时应维持防枚举响应,状态 %d%s", status, body)
}
var denied struct {
Code int `json:"code"`
Data any `json:"data"`
}
if err := sonic.Unmarshal(body, &denied); err != nil || denied.Code != errors.CodeNotFound || denied.Data != nil {
t.Fatalf("当前资产无权限响应不符合原契约:%s", body)
}
}
// TestAssetResolveHTTPReturnsPackageExpiryEstimate 验证后台详情通过真实 JWT、Redis、Fiber 与 PostgreSQL 返回预计最终到期字段。
func TestAssetResolveHTTPReturnsPackageExpiryEstimate(t *testing.T) {
env := newAssetTraceHTTPEnv(t)
shop := env.createShop(t, "UR46详情店铺", nil, 1)
card := env.createCard(t, 46, &shop.ID)
now := time.Now().In(time.FixedZone("Asia/Shanghai", 8*60*60))
expiresAt := now.AddDate(0, 0, 5)
usage := &model.PackageUsage{OrderID: card.ID, OrderNo: "UR46-HTTP", PackageID: card.ID, UsageType: constants.AssetTypeIotCard, IotCardID: card.ID, DataLimitMB: 1, Status: constants.PackageUsageStatusActive, Priority: 1, ActivatedAt: &now, ExpiresAt: &expiresAt, ExpiryBaseSnapshot: constants.PackageExpiryBaseFromActivation, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: 30}
if err := createExpiryHTTPUsage(env.db, usage); err != nil {
t.Fatalf("创建详情套餐使用记录失败:%v", err)
}
assertPackageUsagePersisted(t, env.db, usage.ID, constants.PackageUsageStatusActive)
status, body := env.request(t, "/api/admin/assets/resolve/"+card.ICCID, env.newAgentToken(t, shop.ID))
if status != http.StatusOK {
t.Fatalf("查询资产详情失败:%d %s", status, body)
}
var response struct {
Data struct {
ExpiresAt *time.Time `json:"estimated_final_expires_at"`
Days *int `json:"days_until_final_expiry"`
Status string `json:"expiry_estimate_status"`
Name string `json:"expiry_estimate_status_name"`
Expiring bool `json:"is_expiring"`
} `json:"data"`
}
if err := sonic.Unmarshal(body, &response); err != nil {
t.Fatalf("解析预计到期响应失败:%v", err)
}
if response.Data.ExpiresAt == nil || response.Data.Days == nil || response.Data.Status != constants.PackageExpiryEstimateStatusExact || response.Data.Name == "" || !response.Data.Expiring {
t.Fatalf("预计到期字段不完整:%s", body)
}
device := env.createDevice(t, 46, &shop.ID)
deviceUsage := newExpiryHTTPUsage(device.ID, constants.AssetTypeDevice, constants.PackageUsageStatusActive, &expiresAt, false, now)
if err := createExpiryHTTPUsage(env.db, deviceUsage); err != nil {
t.Fatalf("创建设备详情套餐使用记录失败:%v", err)
}
assertPackageUsagePersisted(t, env.db, deviceUsage.ID, constants.PackageUsageStatusActive)
status, body = env.request(t, "/api/admin/assets/resolve/"+device.VirtualNo, env.newAgentToken(t, shop.ID))
assertExpiryHTTPStatus(t, status, body, constants.PackageExpiryEstimateStatusExact, true)
}
// TestAssetResolveHTTPReturnsAllPackageExpiryStatuses 验证后台详情完整返回四种状态及 nullable 语义。
func TestAssetResolveHTTPReturnsAllPackageExpiryStatuses(t *testing.T) {
env := newAssetTraceHTTPEnv(t)
shop := env.createShop(t, "UR46四状态店铺", nil, 1)
token := env.newAgentToken(t, shop.ID)
now := time.Now().In(time.FixedZone("Asia/Shanghai", 8*60*60))
for typeIndex, assetType := range []string{constants.AssetTypeIotCard, constants.AssetTypeDevice} {
for statusIndex, estimateStatus := range []string{constants.PackageExpiryEstimateStatusNone, constants.PackageExpiryEstimateStatusWaitingActivation, constants.PackageExpiryEstimateStatusInvalidData} {
name := assetType + "/" + estimateStatus
t.Run(name, func(t *testing.T) {
asset := createAdminExpiryTestAsset(t, env, assetType, 60+typeIndex*10+statusIndex, &shop.ID)
if estimateStatus != constants.PackageExpiryEstimateStatusNone {
usageStatus, waiting := constants.PackageUsageStatusActive, false
if estimateStatus == constants.PackageExpiryEstimateStatusWaitingActivation {
usageStatus, waiting = constants.PackageUsageStatusPending, true
}
usage := newExpiryHTTPUsage(asset.assetID, assetType, usageStatus, nil, waiting, now)
if err := createExpiryHTTPUsage(env.db, usage); err != nil {
t.Fatalf("创建状态套餐失败:%v", err)
}
assertPackageUsagePersisted(t, env.db, usage.ID, usageStatus)
}
status, body := env.request(t, "/api/admin/assets/resolve/"+asset.identifier, token)
assertExpiryHTTPStatus(t, status, body, estimateStatus, false)
})
}
}
}
func createAdminExpiryTestAsset(t *testing.T, env *assetTraceHTTPEnv, assetType string, suffix int, shopID *uint) clientExpiryTestAsset {
t.Helper()
if assetType == constants.AssetTypeDevice {
device := env.createDevice(t, suffix, shopID)
return clientExpiryTestAsset{assetType: assetType, assetID: device.ID, virtualNo: device.VirtualNo, identifier: device.VirtualNo}
}
card := env.createCard(t, suffix, shopID)
return clientExpiryTestAsset{assetType: assetType, assetID: card.ID, virtualNo: card.VirtualNo, identifier: card.ICCID}
}
// TestAssetResolveHTTPReflectsQueueChangesImmediately 验证新增、退款和失效队列后无需刷新资产快照。
func TestAssetResolveHTTPReflectsQueueChangesImmediately(t *testing.T) {
env := newAssetTraceHTTPEnv(t)
shop := env.createShop(t, "UR46实时变化店铺", nil, 1)
for index, assetType := range []string{constants.AssetTypeIotCard, constants.AssetTypeDevice} {
t.Run(assetType, func(t *testing.T) {
asset := createAdminExpiryTestAsset(t, env, assetType, 63+index, &shop.ID)
verifyAdminQueueChanges(t, env, asset, shop.ID)
})
}
}
// verifyAdminQueueChanges 验证后台详情实时反映指定资产的套餐队列变化。
func verifyAdminQueueChanges(t *testing.T, env *assetTraceHTTPEnv, asset clientExpiryTestAsset, shopID uint) {
t.Helper()
now := time.Now().In(time.FixedZone("Asia/Shanghai", 8*60*60))
currentExpiry := now.AddDate(0, 0, 5)
current := newExpiryHTTPUsage(asset.assetID, asset.assetType, constants.PackageUsageStatusActive, &currentExpiry, false, now)
if err := createExpiryHTTPUsage(env.db, current); err != nil {
t.Fatalf("创建当前套餐失败:%v", err)
}
assertPackageUsagePersisted(t, env.db, current.ID, constants.PackageUsageStatusActive)
token := env.newAgentToken(t, shopID)
initial := requestExpiryEstimate(t, env, asset.identifier, token)
queued := newExpiryHTTPUsage(asset.assetID, asset.assetType, constants.PackageUsageStatusPending, nil, false, now)
queued.Priority = 2
queued.PackageID = asset.assetID + 1
queued.OrderNo = fmt.Sprintf("UR46-Q-%s-%d", asset.assetType, asset.assetID)
if err := createExpiryHTTPUsage(env.db, queued); err != nil {
t.Fatalf("创建排队套餐失败:%v", err)
}
assertPackageUsagePersisted(t, env.db, queued.ID, constants.PackageUsageStatusPending)
extended := requestExpiryEstimate(t, env, asset.identifier, token)
if initial.ExpiresAt == nil || extended.ExpiresAt == nil || !extended.ExpiresAt.After(*initial.ExpiresAt) {
t.Fatalf("新增排队套餐后预计到期未延长initial=%v extended=%v", initial.ExpiresAt, extended.ExpiresAt)
}
refundID := uint(990001)
if err := env.db.Model(queued).Update("refund_id", refundID).Error; err != nil {
t.Fatalf("设置排队套餐退款状态失败:%v", err)
}
assertPackageUsageRefunded(t, env.db, queued.ID, refundID)
refunded := requestExpiryEstimate(t, env, asset.identifier, token)
if refunded.ExpiresAt == nil || !refunded.ExpiresAt.Equal(*initial.ExpiresAt) {
t.Fatalf("退款后预计到期未实时恢复initial=%v refunded=%v", initial.ExpiresAt, refunded.ExpiresAt)
}
if err := env.db.Model(queued).Updates(map[string]any{"refund_id": nil, "status": constants.PackageUsageStatusInvalidated}).Error; err != nil {
t.Fatalf("设置排队套餐失效状态失败:%v", err)
}
assertPackageUsagePersisted(t, env.db, queued.ID, constants.PackageUsageStatusInvalidated)
invalidated := requestExpiryEstimate(t, env, asset.identifier, token)
if invalidated.ExpiresAt == nil || !invalidated.ExpiresAt.Equal(*initial.ExpiresAt) {
t.Fatalf("失效后预计到期未实时恢复initial=%v invalidated=%v", initial.ExpiresAt, invalidated.ExpiresAt)
}
}
type expiryHTTPResult struct {
ExpiresAt *time.Time `json:"estimated_final_expires_at"`
Days *int `json:"days_until_final_expiry"`
Status string `json:"expiry_estimate_status"`
Name string `json:"expiry_estimate_status_name"`
Expiring bool `json:"is_expiring"`
}
// requestExpiryEstimate 请求后台统一资产详情并验证预计到期字段的 JSON 契约。
func requestExpiryEstimate(t *testing.T, env *assetTraceHTTPEnv, identifier, token string) expiryHTTPResult {
t.Helper()
status, body := env.request(t, "/api/admin/assets/resolve/"+identifier, token)
if status != http.StatusOK {
t.Fatalf("查询预计到期失败:%d %s", status, body)
}
var response struct {
Data expiryHTTPResult `json:"data"`
}
if err := sonic.Unmarshal(body, &response); err != nil {
t.Fatalf("解析预计到期响应失败:%v", err)
}
assertExpiryJSONNullable(t, body, response.Data.Status == constants.PackageExpiryEstimateStatusExact)
return response.Data
}
func newExpiryHTTPUsage(assetID uint, assetType string, status int, expiresAt *time.Time, waiting bool, now time.Time) *model.PackageUsage {
usage := &model.PackageUsage{OrderID: assetID, OrderNo: fmt.Sprintf("UR46-STATUS-%s-%d", assetType, assetID), PackageID: assetID, UsageType: assetType, DataLimitMB: 1, Status: status, Priority: 1, ActivatedAt: &now, ExpiresAt: expiresAt, PendingRealnameActivation: waiting, ExpiryBaseSnapshot: constants.PackageExpiryBaseFromActivation, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: 30}
if assetType == constants.AssetTypeDevice {
usage.DeviceID = assetID
} else {
usage.IotCardID = assetID
}
return usage
}
func createExpiryHTTPUsage(db *gorm.DB, usage *model.PackageUsage) error {
desiredStatus := usage.Status
pendingRealname := usage.PendingRealnameActivation
if err := db.Omit("status", "pending_realname_activation").Create(usage).Error; err != nil {
return err
}
usage.Status = desiredStatus
usage.PendingRealnameActivation = pendingRealname
return db.Model(usage).Updates(map[string]any{"status": desiredStatus, "pending_realname_activation": pendingRealname}).Error
}
// assertExpiryHTTPStatus 验证详情状态、中文名称、临期标记及 nullable 语义。
func assertExpiryHTTPStatus(t *testing.T, status int, body []byte, expected string, exact bool) {
t.Helper()
if status != http.StatusOK {
t.Fatalf("预计到期接口失败:%d %s", status, body)
}
var response struct {
Data struct {
ExpiresAt *time.Time `json:"estimated_final_expires_at"`
Days *int `json:"days_until_final_expiry"`
Status string `json:"expiry_estimate_status"`
Name string `json:"expiry_estimate_status_name"`
Expiring bool `json:"is_expiring"`
} `json:"data"`
}
if err := sonic.Unmarshal(body, &response); err != nil {
t.Fatalf("解析预计到期响应失败:%v", err)
}
if response.Data.Status != expected || response.Data.Name == "" {
t.Fatalf("预计到期状态错误want=%s body=%s", expected, body)
}
if exact != (response.Data.ExpiresAt != nil && response.Data.Days != nil) {
t.Fatalf("预计到期 nullable 语义错误body=%s", body)
}
if response.Data.Expiring != exact {
t.Fatalf("预计到期临期标记错误exact=%v body=%s", exact, body)
}
assertExpiryJSONNullable(t, body, exact)
}
// assertExpiryJSONNullable 验证日期和天数字段必须存在,并按状态显式返回值或 null。
func assertExpiryJSONNullable(t *testing.T, body []byte, exact bool) {
t.Helper()
var payload struct {
Data map[string]any `json:"data"`
}
if err := sonic.Unmarshal(body, &payload); err != nil {
t.Fatalf("解析预计到期原始字段失败:%v", err)
}
expiresAt, expiresAtExists := payload.Data["estimated_final_expires_at"]
days, daysExists := payload.Data["days_until_final_expiry"]
if !expiresAtExists || !daysExists || exact != (expiresAt != nil && days != nil) {
t.Fatalf("预计到期字段必须存在并显式返回值或 nullexact=%v body=%s", exact, body)
}
}
func assertPackageUsagePersisted(t *testing.T, db *gorm.DB, usageID uint, expectedStatus int) {
t.Helper()
var usage model.PackageUsage
if err := db.First(&usage, usageID).Error; err != nil || usage.Status != expectedStatus {
t.Fatalf("套餐使用记录数据库状态错误id=%d status=%d err=%v", usageID, usage.Status, err)
}
}
func assertPackageUsageRefunded(t *testing.T, db *gorm.DB, usageID, expectedRefundID uint) {
t.Helper()
var usage model.PackageUsage
if err := db.First(&usage, usageID).Error; err != nil || usage.RefundID == nil || *usage.RefundID != expectedRefundID {
t.Fatalf("套餐退款数据库状态错误id=%d refund_id=%v err=%v", usageID, usage.RefundID, err)
}
}
// TestClientAssetInfoHTTPReturnsAllPackageExpiryStatuses 验证 C 端卡、设备四状态与后台口径一致。
func TestClientAssetInfoHTTPReturnsAllPackageExpiryStatuses(t *testing.T) {
env := newAssetTraceHTTPEnv(t)
now := time.Now().In(time.FixedZone("Asia/Shanghai", 8*60*60))
assets := append(createClientExpiryStatusAssets(t, env, constants.AssetTypeIotCard, 70, now), createClientExpiryStatusAssets(t, env, constants.AssetTypeDevice, 80, now)...)
adminToken := env.newAgentToken(t, 0)
for _, asset := range assets {
client := requestClientExpiryEstimate(t, env, asset.assetType, asset.assetID, asset.virtualNo, asset.identifier)
admin := requestExpiryEstimate(t, env, asset.identifier, adminToken)
if client.Status != asset.status || client.Status != admin.Status || client.Name == "" || client.Expiring != admin.Expiring || !timePointersEqualRoute(client.ExpiresAt, admin.ExpiresAt) || !intPointersEqualRoute(client.Days, admin.Days) {
t.Fatalf("C 端与后台预计到期不一致asset=%s client=%+v admin=%+v", asset.identifier, client, admin)
}
exact := asset.status == constants.PackageExpiryEstimateStatusExact
if exact != (client.ExpiresAt != nil && client.Days != nil) || exact != client.Expiring {
t.Fatalf("C 端 nullable 或临期标记错误asset=%s result=%+v", asset.identifier, client)
}
}
}
type clientExpiryTestAsset struct {
assetType string
assetID uint
virtualNo string
identifier string
status string
}
// createClientExpiryStatusAssets 为指定资产类型创建预计到期四状态夹具。
func createClientExpiryStatusAssets(t *testing.T, env *assetTraceHTTPEnv, assetType string, suffix int, now time.Time) []clientExpiryTestAsset {
t.Helper()
statuses := []string{constants.PackageExpiryEstimateStatusExact, constants.PackageExpiryEstimateStatusNone, constants.PackageExpiryEstimateStatusWaitingActivation, constants.PackageExpiryEstimateStatusInvalidData}
assets := make([]clientExpiryTestAsset, 0, len(statuses))
for index, estimateStatus := range statuses {
asset := createClientExpiryTestAsset(t, env, assetType, suffix+index)
asset.status = estimateStatus
if estimateStatus != constants.PackageExpiryEstimateStatusNone {
usageStatus, waiting := constants.PackageUsageStatusActive, false
var expiresAt *time.Time
if estimateStatus == constants.PackageExpiryEstimateStatusExact {
value := now.AddDate(0, 0, 5)
expiresAt = &value
} else if estimateStatus == constants.PackageExpiryEstimateStatusWaitingActivation {
usageStatus, waiting = constants.PackageUsageStatusPending, true
}
usage := newExpiryHTTPUsage(asset.assetID, assetType, usageStatus, expiresAt, waiting, now)
if err := createExpiryHTTPUsage(env.db, usage); err != nil {
t.Fatalf("创建 C 端状态套餐失败:%v", err)
}
assertPackageUsagePersisted(t, env.db, usage.ID, usageStatus)
}
assets = append(assets, asset)
}
return assets
}
func createClientExpiryTestAsset(t *testing.T, env *assetTraceHTTPEnv, assetType string, suffix int) clientExpiryTestAsset {
t.Helper()
if assetType == constants.AssetTypeDevice {
device := env.createDevice(t, suffix, nil)
return clientExpiryTestAsset{assetType: assetType, assetID: device.ID, virtualNo: device.VirtualNo, identifier: device.VirtualNo}
}
card := env.createCard(t, suffix, nil)
return clientExpiryTestAsset{assetType: assetType, assetID: card.ID, virtualNo: card.VirtualNo, identifier: card.ICCID}
}
// TestClientAssetInfoHTTPReflectsQueueChangesImmediately 验证 C 端实时反映套餐新增、退款和失效。
func TestClientAssetInfoHTTPReflectsQueueChangesImmediately(t *testing.T) {
env := newAssetTraceHTTPEnv(t)
for index, assetType := range []string{constants.AssetTypeIotCard, constants.AssetTypeDevice} {
t.Run(assetType, func(t *testing.T) {
asset := createClientExpiryTestAsset(t, env, assetType, 90+index)
verifyClientQueueChanges(t, env, asset)
})
}
}
// verifyClientQueueChanges 验证 C 端详情实时反映指定资产的套餐队列变化。
func verifyClientQueueChanges(t *testing.T, env *assetTraceHTTPEnv, asset clientExpiryTestAsset) {
t.Helper()
now := time.Now().In(time.FixedZone("Asia/Shanghai", 8*60*60))
currentExpiry := now.AddDate(0, 0, 5)
current := newExpiryHTTPUsage(asset.assetID, asset.assetType, constants.PackageUsageStatusActive, &currentExpiry, false, now)
if err := createExpiryHTTPUsage(env.db, current); err != nil {
t.Fatalf("创建 C 端当前套餐失败:%v", err)
}
assertPackageUsagePersisted(t, env.db, current.ID, constants.PackageUsageStatusActive)
request := func() expiryHTTPResult {
return requestClientExpiryEstimate(t, env, asset.assetType, asset.assetID, asset.virtualNo, asset.identifier)
}
initial := request()
queued := newExpiryHTTPUsage(asset.assetID, asset.assetType, constants.PackageUsageStatusPending, nil, false, now)
queued.PackageID = asset.assetID + 1
queued.OrderNo = fmt.Sprintf("UR46-CQ-%s-%d", asset.assetType, asset.assetID)
queued.Priority = 2
if err := createExpiryHTTPUsage(env.db, queued); err != nil {
t.Fatalf("创建 C 端排队套餐失败:%v", err)
}
assertPackageUsagePersisted(t, env.db, queued.ID, constants.PackageUsageStatusPending)
extended := request()
if initial.ExpiresAt == nil || extended.ExpiresAt == nil || !extended.ExpiresAt.After(*initial.ExpiresAt) {
t.Fatalf("C 端新增套餐后预计到期未延长initial=%v extended=%v", initial.ExpiresAt, extended.ExpiresAt)
}
refundID := uint(990002)
if err := env.db.Model(queued).Update("refund_id", refundID).Error; err != nil {
t.Fatalf("设置 C 端排队套餐退款失败:%v", err)
}
assertPackageUsageRefunded(t, env.db, queued.ID, refundID)
refunded := request()
if refunded.ExpiresAt == nil || !refunded.ExpiresAt.Equal(*initial.ExpiresAt) {
t.Fatalf("C 端退款后预计到期未实时恢复initial=%v refunded=%v", initial.ExpiresAt, refunded.ExpiresAt)
}
if err := env.db.Model(queued).Updates(map[string]any{"refund_id": nil, "status": constants.PackageUsageStatusInvalidated}).Error; err != nil {
t.Fatalf("设置 C 端排队套餐失效失败:%v", err)
}
assertPackageUsagePersisted(t, env.db, queued.ID, constants.PackageUsageStatusInvalidated)
invalidated := request()
if invalidated.ExpiresAt == nil || !invalidated.ExpiresAt.Equal(*initial.ExpiresAt) {
t.Fatalf("C 端失效后预计到期未实时恢复initial=%v invalidated=%v", initial.ExpiresAt, invalidated.ExpiresAt)
}
}
// requestClientExpiryEstimate 创建真实客户认证和绑定后请求 C 端资产信息。
func requestClientExpiryEstimate(t *testing.T, env *assetTraceHTTPEnv, assetType string, assetID uint, virtualNo, identifier string) expiryHTTPResult {
t.Helper()
now := time.Now()
customer := &model.PersonalCustomer{WxOpenID: fmt.Sprintf("ur46-client-%d-%d", assetID, time.Now().UnixNano()), WxUnionID: fmt.Sprintf("ur46-union-%d", assetID), Status: constants.StatusEnabled}
if err := env.db.Create(customer).Error; err != nil {
t.Fatalf("创建 C 端客户失败:%v", err)
}
binding := &model.PersonalCustomerDevice{CustomerID: customer.ID, VirtualNo: virtualNo, BindAt: now, Status: constants.StatusEnabled}
if err := env.db.Create(binding).Error; err != nil {
t.Fatalf("创建 C 端资产绑定失败:%v", err)
}
jwtManager := auth.NewJWTManager("ur46-client-status-secret", time.Hour)
token, err := jwtManager.GeneratePersonalCustomerToken(customer.ID, "13800000000", assetType, assetID)
if err != nil {
t.Fatalf("生成 C 端 JWT 失败:%v", err)
}
if err := env.redis.Set(context.Background(), constants.RedisPersonalCustomerTokenKey(customer.ID), token, time.Hour).Err(); err != nil {
t.Fatalf("写入 C 端 Token 失败:%v", err)
}
t.Cleanup(func() {
_ = env.redis.Del(context.Background(), constants.RedisPersonalCustomerTokenKey(customer.ID)).Err()
})
cardStore := postgres.NewIotCardStore(env.db, env.redis)
deviceStore := postgres.NewDeviceStore(env.db, env.redis)
handler := apphandler.NewClientAssetHandler(env.assetService, customerBinding.New(env.db, cardStore, deviceStore), postgres.NewAssetWalletStore(env.db, env.redis), postgres.NewPackageStore(env.db), postgres.NewShopPackageAllocationStore(env.db), cardStore, deviceStore, env.db, zap.NewNop())
app := fiber.New(fiber.Config{JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, ErrorHandler: internalMiddleware.ErrorHandler(zap.NewNop())})
app.Get("/api/c/v1/asset/info", internalMiddleware.NewPersonalAuthMiddleware(jwtManager, env.redis, zap.NewNop()).Authenticate(), handler.GetAssetInfo)
request, err := http.NewRequest(http.MethodGet, "/api/c/v1/asset/info?identifier="+identifier, nil)
if err != nil {
t.Fatalf("创建 C 端请求失败:%v", err)
}
request.Header.Set(fiber.HeaderAuthorization, "Bearer "+token)
response, err := app.Test(request, -1)
if err != nil {
t.Fatalf("执行 C 端请求失败:%v", err)
}
defer response.Body.Close()
body, _ := io.ReadAll(response.Body)
if response.StatusCode != http.StatusOK {
t.Fatalf("C 端预计到期响应失败:%d %s", response.StatusCode, body)
}
var payload struct {
Data expiryHTTPResult `json:"data"`
}
if err := sonic.Unmarshal(body, &payload); err != nil {
t.Fatalf("解析 C 端预计到期响应失败:%v", err)
}
assertExpiryJSONNullable(t, body, payload.Data.Status == constants.PackageExpiryEstimateStatusExact)
return payload.Data
}
func timePointersEqualRoute(left, right *time.Time) bool {
return left == nil && right == nil || left != nil && right != nil && left.Equal(*right)
}
func intPointersEqualRoute(left, right *int) bool {
return left == nil && right == nil || left != nil && right != nil && *left == *right
}
type routeSQLCounterLogger struct {
logger.Interface
queries []string
}
func (l *routeSQLCounterLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
sql, rows := fc()
l.queries = append(l.queries, sql)
l.Interface.Trace(ctx, begin, func() (string, int64) { return sql, rows }, err)
}
func (l *routeSQLCounterLogger) Reset() {
l.queries = nil
}
func (l *routeSQLCounterLogger) Total() int {
return len(l.queries)
}
func (l *routeSQLCounterLogger) CountContaining(fragment string) int {
count := 0
for _, query := range l.queries {
if strings.Contains(query, fragment) {
count++
}
}
return count
}
// TestAssetListHTTPReturnsPackageExpiryEstimate 验证卡、设备列表在真实 JWT、Redis、Fiber 下批量返回统一预计到期字段。
func TestAssetListHTTPReturnsPackageExpiryEstimate(t *testing.T) {
env := newAssetTraceHTTPEnv(t)
counter := &routeSQLCounterLogger{Interface: env.db.Logger}
db := env.db.Session(&gorm.Session{Logger: counter})
registerPackageExpiryListRoutes(env, db)
fixture := createPackageExpiryListFixtures(t, env)
token := env.newAgentToken(t, 0)
for _, testCase := range []packageExpiryListCase{
{path: "/api/admin/iot-cards/standalone", assetType: constants.AssetTypeIotCard, identifier: fixture.lastCardIdentifier, orderedIDs: fixture.cardIDs, packageQueries: 1},
{path: "/api/admin/devices", assetType: constants.AssetTypeDevice, identifier: fixture.lastDeviceIdentifier, orderedIDs: fixture.deviceIDs, packageQueries: 2},
} {
t.Run(testCase.assetType, func(t *testing.T) {
verifyPackageExpiryList(t, env, counter, fixture, testCase, token)
})
}
}
type packageExpiryListFixture struct {
batchNo string
cardIDs []uint
deviceIDs []uint
statusIDs map[string]map[string]uint
lastCardIdentifier string
lastDeviceIdentifier string
}
type packageExpiryListCase struct {
path string
assetType string
identifier string
orderedIDs []uint
packageQueries int
}
type packageExpiryListItem struct {
ID uint `json:"id"`
ExpiresAt *time.Time `json:"estimated_final_expires_at"`
Days *int `json:"days_until_final_expiry"`
Status string `json:"expiry_estimate_status"`
StatusName string `json:"expiry_estimate_status_name"`
IsExpiring bool `json:"is_expiring"`
fieldsExist bool
fieldsNull bool
}
type packageExpiryListPayload struct {
Data struct {
Items []packageExpiryListItem `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
} `json:"data"`
}
// registerPackageExpiryListRoutes 使用计数数据库会话注册卡和设备列表路由。
func registerPackageExpiryListRoutes(env *assetTraceHTTPEnv, db *gorm.DB) {
shopStore := postgres.NewShopStore(db, env.redis)
cardStore := postgres.NewIotCardStore(db, nil)
deviceStore := postgres.NewDeviceStore(db, nil)
seriesStore := postgres.NewPackageSeriesStore(db)
allocationStore := postgres.NewAssetAllocationRecordStore(db, env.redis)
cardSvc := iotCardService.New(db, cardStore, shopStore, allocationStore, postgres.NewShopPackageAllocationStore(db), postgres.NewShopSeriesAllocationStore(db), seriesStore, nil, zap.NewNop(), nil)
deviceSvc := deviceService.New(db, nil, deviceStore, postgres.NewDeviceSimBindingStore(db, nil), cardStore, shopStore, allocationStore, postgres.NewShopPackageAllocationStore(db), postgres.NewShopSeriesAllocationStore(db), seriesStore, nil, postgres.NewAssetIdentifierStore(db), nil, postgres.NewEnterpriseDeviceAuthorizationStore(db, nil), postgres.NewEnterpriseStore(db, nil))
packageExpiry := packageExpiryQuery.NewQuery(db)
cardSvc.SetPackageExpiryQuery(packageExpiry)
deviceSvc.SetPackageExpiryQuery(packageExpiry)
authMiddleware := pkgMiddleware.Auth(pkgMiddleware.AuthConfig{TokenValidator: func(token string) (*pkgMiddleware.UserContextInfo, error) {
info, err := env.tokenManager.ValidateAccessToken(context.Background(), token)
if err != nil {
return nil, err
}
return &pkgMiddleware.UserContextInfo{UserID: info.UserID, UserType: info.UserType, ShopID: info.ShopID}, nil
}, ShopStore: shopStore})
env.app.Get("/api/admin/iot-cards/standalone", authMiddleware, admin.NewIotCardHandler(cardSvc).ListStandalone)
env.app.Get("/api/admin/devices", authMiddleware, admin.NewDeviceHandler(deviceSvc).List)
}
// createPackageExpiryListFixtures 创建卡和设备各 100 条的四状态列表数据。
func createPackageExpiryListFixtures(t *testing.T, env *assetTraceHTTPEnv) packageExpiryListFixture {
t.Helper()
fixture := packageExpiryListFixture{batchNo: "UR46-LIST-" + strconv.FormatInt(time.Now().UnixNano(), 10), cardIDs: make([]uint, 0, 100), deviceIDs: make([]uint, 0, 100), statusIDs: map[string]map[string]uint{constants.AssetTypeIotCard: {}, constants.AssetTypeDevice: {}}}
now := time.Now()
expiresAt := now.AddDate(0, 0, 5)
for index := 0; index < 100; index++ {
card := env.createCard(t, 1000+index, nil)
device := env.createDevice(t, 1000+index, nil)
if err := env.db.Model(card).Updates(map[string]any{"is_standalone": true, "batch_no": fixture.batchNo}).Error; err != nil {
t.Fatalf("设置卡列表 fixture 失败:%v", err)
}
if err := env.db.Model(device).Update("batch_no", fixture.batchNo).Error; err != nil {
t.Fatalf("设置设备列表 fixture 失败:%v", err)
}
fixture.cardIDs = append(fixture.cardIDs, card.ID)
fixture.deviceIDs = append(fixture.deviceIDs, device.ID)
fixture.lastCardIdentifier, fixture.lastDeviceIdentifier = card.ICCID, device.VirtualNo
createPackageExpiryListUsage(t, env, constants.AssetTypeIotCard, card.ID, index, now, expiresAt, fixture.statusIDs)
createPackageExpiryListUsage(t, env, constants.AssetTypeDevice, device.ID, index, now, expiresAt, fixture.statusIDs)
}
return fixture
}
// createPackageExpiryListUsage 按资产序号分配精确、无套餐、待激活和异常状态。
func createPackageExpiryListUsage(t *testing.T, env *assetTraceHTTPEnv, assetType string, assetID uint, index int, now, expiresAt time.Time, statusIDs map[string]map[string]uint) {
t.Helper()
estimateStatus := constants.PackageExpiryEstimateStatusExact
usageStatus := constants.PackageUsageStatusActive
usageExpiry := &expiresAt
waiting := false
switch index {
case 1:
estimateStatus = constants.PackageExpiryEstimateStatusNone
statusIDs[assetType][estimateStatus] = assetID
return
case 2:
estimateStatus = constants.PackageExpiryEstimateStatusWaitingActivation
usageStatus, usageExpiry, waiting = constants.PackageUsageStatusPending, nil, true
case 3:
estimateStatus = constants.PackageExpiryEstimateStatusInvalidData
usageExpiry = nil
}
statusIDs[assetType][estimateStatus] = assetID
usage := newExpiryHTTPUsage(assetID, assetType, usageStatus, usageExpiry, waiting, now)
if err := createExpiryHTTPUsage(env.db, usage); err != nil {
t.Fatalf("创建列表套餐失败:%v", err)
}
assertPackageUsagePersisted(t, env.db, usage.ID, usageStatus)
}
// verifyPackageExpiryList 验证列表响应、固定查询数、性能、筛选、排序和详情一致性。
func verifyPackageExpiryList(t *testing.T, env *assetTraceHTTPEnv, counter *routeSQLCounterLogger, fixture packageExpiryListFixture, testCase packageExpiryListCase, token string) {
t.Helper()
baseQuery := "?page=1&page_size=100&batch_no=" + fixture.batchNo
counter.Reset()
oneStatus, oneBody := env.request(t, testCase.path+strings.Replace(baseQuery, "page_size=100", "page_size=1", 1), token)
oneAssetQueries := counter.Total()
if oneStatus != http.StatusOK {
t.Fatalf("单资产分页请求失败path=%s status=%d body=%s", testCase.path, oneStatus, oneBody)
}
counter.Reset()
startedAt := time.Now()
status, body := env.request(t, testCase.path+baseQuery, token)
requestDuration := time.Since(startedAt)
hundredAssetQueries := counter.Total()
if status != http.StatusOK || oneAssetQueries != hundredAssetQueries || counter.CountContaining("tb_package_usage") != testCase.packageQueries || requestDuration >= testHTTPPerformanceLimit {
t.Fatalf("列表批量查询或性能错误path=%s status=%d one=%d hundred=%d package_queries=%d duration=%v", testCase.path, status, oneAssetQueries, hundredAssetQueries, counter.CountContaining("tb_package_usage"), requestDuration)
}
payload := decodePackageExpiryList(t, body)
if payload.Data.Total != testListPageSize || payload.Data.Page != 1 || payload.Data.Size != testListPageSize || len(payload.Data.Items) != testListPageSize || payload.Data.Items[0].ID != testCase.orderedIDs[testListPageSize-1] {
t.Fatalf("列表分页或排序错误path=%s payload=%+v", testCase.path, payload.Data)
}
assertPackageExpiryListStatuses(t, payload.Data.Items, fixture.statusIDs[testCase.assetType])
detail := requestExpiryEstimate(t, env, testCase.identifier, token)
first := payload.Data.Items[0]
if first.Status != detail.Status || !timePointersEqualRoute(first.ExpiresAt, detail.ExpiresAt) || !intPointersEqualRoute(first.Days, detail.Days) {
t.Fatalf("列表与详情预计到期不一致list=%+v detail=%+v", first, detail)
}
status, pageTwoBody := env.request(t, testCase.path+"?page=2&page_size=50&batch_no="+fixture.batchNo, token)
pageTwo := decodePackageExpiryList(t, pageTwoBody)
if status != http.StatusOK || pageTwo.Data.Total != 100 || len(pageTwo.Data.Items) != 50 || pageTwo.Data.Items[0].ID != testCase.orderedIDs[49] {
t.Fatalf("列表翻页不稳定path=%s status=%d payload=%+v", testCase.path, status, pageTwo.Data)
}
}
// decodePackageExpiryList 同时解析类型字段和原始 JSON以区分字段缺失与显式 null。
func decodePackageExpiryList(t *testing.T, body []byte) packageExpiryListPayload {
t.Helper()
var payload packageExpiryListPayload
if err := sonic.Unmarshal(body, &payload); err != nil {
t.Fatalf("解析列表分页响应失败:%v", err)
}
var raw struct {
Data struct {
Items []map[string]any `json:"items"`
} `json:"data"`
}
if err := sonic.Unmarshal(body, &raw); err != nil || len(raw.Data.Items) != len(payload.Data.Items) {
t.Fatalf("解析列表原始字段失败:%v", err)
}
for index, item := range raw.Data.Items {
expiresAt, expiresAtExists := item["estimated_final_expires_at"]
days, daysExists := item["days_until_final_expiry"]
payload.Data.Items[index].fieldsExist = expiresAtExists && daysExists
payload.Data.Items[index].fieldsNull = expiresAt == nil && days == nil
}
return payload
}
// assertPackageExpiryListStatuses 逐项验证列表四状态、字段存在性和 nullable 语义。
func assertPackageExpiryListStatuses(t *testing.T, items []packageExpiryListItem, expectedIDs map[string]uint) {
t.Helper()
byID := make(map[uint]packageExpiryListItem, len(items))
for _, item := range items {
byID[item.ID] = item
}
for status, id := range expectedIDs {
item, ok := byID[id]
if !ok || item.Status != status || item.StatusName == "" {
t.Fatalf("列表缺少状态投影status=%s id=%d item=%+v", status, id, item)
}
exact := status == constants.PackageExpiryEstimateStatusExact
if !item.fieldsExist || exact == item.fieldsNull || exact != (item.ExpiresAt != nil && item.Days != nil) || exact != item.IsExpiring {
t.Fatalf("列表 nullable 或临期标记错误status=%s item=%+v", status, item)
}
}
}
func assertHTTPTraceDirections(t *testing.T, status int, body []byte, hasPrevious, hasNext bool) {
t.Helper()
if status != http.StatusOK {
t.Fatalf("资产详情状态错误,实际 %d%s", status, body)
}
var response struct {
Data struct {
ExchangeTrace struct {
PreviousAsset any `json:"previous_asset"`
NextAsset any `json:"next_asset"`
} `json:"exchange_trace"`
} `json:"data"`
}
if err := sonic.Unmarshal(body, &response); err != nil {
t.Fatalf("解析换货方向响应失败:%v", err)
}
if (response.Data.ExchangeTrace.PreviousAsset != nil) != hasPrevious || (response.Data.ExchangeTrace.NextAsset != nil) != hasNext {
t.Fatalf("换货方向空值语义错误previous=%v next=%v body=%s", hasPrevious, hasNext, body)
}
}
type assetTraceHTTPEnv struct {
app *fiber.App
db *gorm.DB
redis *redis.Client
tokenManager *auth.TokenManager
assetService *assetService.Service
}
func newAssetTraceHTTPEnv(t *testing.T) *assetTraceHTTPEnv {
t.Helper()
if os.Getenv("JUNHONG_DATABASE_HOST") == "" || os.Getenv("JUNHONG_REDIS_ADDRESS") == "" {
t.Skip("未加载 .env.local跳过依赖真实 PostgreSQL 和 Redis 的资产换货链 HTTP 集成测试")
}
cfg, err := config.Load()
if err != nil {
t.Fatalf("加载测试配置失败:%v", err)
}
log := zap.NewNop()
db, err := database.InitPostgreSQL(&cfg.Database, log)
if err != nil {
t.Fatalf("连接 PostgreSQL 失败:%v", err)
}
tx := db.Begin()
if tx.Error != nil {
t.Fatalf("开启资产换货链测试事务失败:%v", tx.Error)
}
redisClient, err := database.NewRedisClient(database.RedisConfig{
Address: cfg.Redis.Address + ":" + strconv.Itoa(cfg.Redis.Port), Password: cfg.Redis.Password, DB: cfg.Redis.DB,
PoolSize: cfg.Redis.PoolSize, MinIdleConns: cfg.Redis.MinIdleConns,
DialTimeout: cfg.Redis.DialTimeout, ReadTimeout: cfg.Redis.ReadTimeout, WriteTimeout: cfg.Redis.WriteTimeout,
}, log)
if err != nil {
t.Fatalf("连接 Redis 失败:%v", err)
}
t.Cleanup(func() {
_ = tx.Rollback().Error
_ = redisClient.Close()
if sqlDB, dbErr := db.DB(); dbErr == nil {
_ = sqlDB.Close()
}
})
shopStore := postgres.NewShopStore(tx, redisClient)
deviceStore := postgres.NewDeviceStore(tx, redisClient)
cardStore := postgres.NewIotCardStore(tx, redisClient)
assetSvc := assetService.New(
tx, deviceStore, cardStore,
postgres.NewPackageUsageStore(tx, redisClient), postgres.NewPackageStore(tx), postgres.NewPackageSeriesStore(tx),
postgres.NewDeviceSimBindingStore(tx, redisClient), shopStore, redisClient, nil, nil,
postgres.NewAssetIdentifierStore(tx), postgres.NewOrderStore(tx, redisClient), postgres.NewOrderItemStore(tx, redisClient),
postgres.NewExchangeOrderStore(tx), nil,
)
assetSvc.SetPackageExpiryQuery(packageExpiryQuery.NewQuery(tx))
handler := admin.NewAssetHandler(assetSvc, nil, nil, nil, nil, nil, assetQuery.NewExchangeTraceQuery(tx, log))
tokenManager := auth.NewTokenManager(redisClient, cfg.JWT.AccessTokenTTL, cfg.JWT.RefreshTokenTTL)
authMiddleware := pkgMiddleware.Auth(pkgMiddleware.AuthConfig{
TokenValidator: func(token string) (*pkgMiddleware.UserContextInfo, error) {
info, validateErr := tokenManager.ValidateAccessToken(context.Background(), token)
if validateErr != nil {
return nil, errors.New(errors.CodeInvalidToken, "认证令牌无效或已过期")
}
return &pkgMiddleware.UserContextInfo{UserID: info.UserID, UserType: info.UserType, Username: info.Username, ShopID: info.ShopID}, nil
},
ShopStore: shopStore,
})
app := fiber.New(fiber.Config{JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, ErrorHandler: internalMiddleware.ErrorHandler(log)})
RegisterAdminRoutes(app.Group("/api/admin"), &bootstrap.Handlers{Asset: handler}, &bootstrap.Middlewares{AdminAuth: authMiddleware}, nil, "/api/admin")
return &assetTraceHTTPEnv{app: app, db: tx, redis: redisClient, tokenManager: tokenManager, assetService: assetSvc}
}
func (e *assetTraceHTTPEnv) createShop(t *testing.T, name string, parentID *uint, level int) *model.Shop {
t.Helper()
shop := &model.Shop{ShopName: name, ShopCode: fmt.Sprintf("UR86-%d", time.Now().UnixNano()), ParentID: parentID, Level: level, Status: constants.ShopStatusEnabled}
if err := e.db.Create(shop).Error; err != nil {
t.Fatalf("创建资产换货链测试店铺失败:%v", err)
}
return shop
}
func (e *assetTraceHTTPEnv) createCard(t *testing.T, suffix int, shopID *uint) *model.IotCard {
t.Helper()
iccid := fmt.Sprintf("8986333333333333%04d", suffix)
card := &model.IotCard{ICCID: iccid, ICCID19: iccid[:19], VirtualNo: fmt.Sprintf("UR86-HTTP-CARD-%04d", suffix), ShopID: shopID, Status: constants.IotCardStatusInStock, AssetStatus: constants.AssetStatusInStock}
if err := e.db.Create(card).Error; err != nil {
t.Fatalf("创建资产换货链 HTTP 测试卡失败:%v", err)
}
return card
}
func (e *assetTraceHTTPEnv) createDevice(t *testing.T, suffix int, shopID *uint) *model.Device {
t.Helper()
device := &model.Device{VirtualNo: fmt.Sprintf("UR46-HTTP-DEVICE-%04d-%d", suffix, time.Now().UnixNano()), ShopID: shopID, Status: constants.DeviceStatusInStock, AssetStatus: constants.AssetStatusInStock, MaxSimSlots: 4}
if err := e.db.Create(device).Error; err != nil {
t.Fatalf("创建 UR46 HTTP 测试设备失败:%v", err)
}
return device
}
func (e *assetTraceHTTPEnv) createOrder(t *testing.T, exchangeNo string, oldCard, newCard *model.IotCard, oldSnapshot, newSnapshot string, completedAt time.Time) {
t.Helper()
newID := newCard.ID
order := &model.ExchangeOrder{
ExchangeNo: exchangeNo, FlowType: constants.ExchangeFlowTypeDirect,
OldAssetType: constants.ExchangeAssetTypeIotCard, OldAssetID: oldCard.ID, OldAssetIdentifier: oldSnapshot,
NewAssetType: constants.ExchangeAssetTypeIotCard, NewAssetID: &newID, NewAssetIdentifier: newSnapshot,
ExchangeReason: "UR86 HTTP 测试", Status: constants.ExchangeStatusCompleted, CompletedAt: &completedAt,
}
if err := e.db.Create(order).Error; err != nil {
t.Fatalf("创建资产换货链 HTTP 测试单失败:%v", err)
}
}
func (e *assetTraceHTTPEnv) newAgentToken(t *testing.T, shopID uint) string {
t.Helper()
info := &auth.TokenInfo{UserID: uint(time.Now().UnixNano() % 1_000_000_000), UserType: constants.UserTypeAgent, ShopID: shopID, Username: "UR86测试代理"}
accessToken, refreshToken, err := e.tokenManager.GenerateTokenPair(context.Background(), info)
if err != nil {
t.Fatalf("创建资产换货链测试令牌失败:%v", err)
}
t.Cleanup(func() {
_ = e.tokenManager.RevokeToken(context.Background(), accessToken)
_ = e.tokenManager.RevokeToken(context.Background(), refreshToken)
_ = e.redis.Del(context.Background(), constants.RedisUserTokensKey(info.UserID)).Err()
})
return accessToken
}
func (e *assetTraceHTTPEnv) request(t *testing.T, path, token string) (int, []byte) {
t.Helper()
request, err := http.NewRequest(http.MethodGet, path, nil)
if err != nil {
t.Fatalf("创建资产换货链 HTTP 请求失败:%v", err)
}
request.Header.Set(fiber.HeaderAuthorization, "Bearer "+token)
response, err := e.app.Test(request, -1)
if err != nil {
t.Fatalf("执行资产换货链 HTTP 请求失败:%v", err)
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
t.Fatalf("读取资产换货链 HTTP 响应失败:%v", err)
}
return response.StatusCode, body
}