补齐UR46后端验收测试
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m55s

This commit is contained in:
2026-07-23 15:28:06 +09:00
parent cde5369b30
commit f7c42252c0
6 changed files with 971 additions and 220 deletions

View File

@@ -1,13 +1,13 @@
package routes
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"testing"
"time"
@@ -16,6 +16,7 @@ import (
"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"
@@ -37,6 +38,13 @@ import (
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)
@@ -125,9 +133,10 @@ func TestAssetResolveHTTPReturnsPackageExpiryEstimate(t *testing.T) {
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 := env.db.Create(usage).Error; err != nil {
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)
@@ -147,43 +156,369 @@ func TestAssetResolveHTTPReturnsPackageExpiryEstimate(t *testing.T) {
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)
}
// TestClientAssetInfoHTTPReturnsPackageExpiryEstimate 验证 C 端经真实 JWT、Redis 和 Fiber 返回与后台一致的预计到期字段
func TestClientAssetInfoHTTPReturnsPackageExpiryEstimate(t *testing.T) {
// TestAssetResolveHTTPReturnsAllPackageExpiryStatuses 验证后台详情完整返回四种状态及 nullable 语义
func TestAssetResolveHTTPReturnsAllPackageExpiryStatuses(t *testing.T) {
env := newAssetTraceHTTPEnv(t)
card := env.createCard(t, 47, nil)
shop := env.createShop(t, "UR46四状态店铺", nil, 1)
token := env.newAgentToken(t, 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-CLIENT", 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 := env.db.Create(usage).Error; err != nil {
t.Fatalf("创建 C 端套餐使用记录失败:%v", err)
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)
})
}
}
customer := &model.PersonalCustomer{WxOpenID: "ur46-client-openid-" + strconv.FormatInt(time.Now().UnixNano(), 10), WxUnionID: "ur46-client-union", Status: constants.StatusEnabled}
}
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)
}
if err := env.db.Create(&model.PersonalCustomerDevice{CustomerID: customer.ID, VirtualNo: card.VirtualNo, BindAt: now, Status: constants.StatusEnabled}).Error; err != nil {
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-test-secret", time.Hour)
token, err := jwtManager.GeneratePersonalCustomerToken(customer.ID, "13800000000", constants.AssetTypeIotCard, card.ID)
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 端 Redis Token 失败:%v", err)
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)
clientHandler := 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())
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())})
personalAuth := internalMiddleware.NewPersonalAuthMiddleware(jwtManager, env.redis, zap.NewNop())
app.Get("/api/c/v1/asset/info", personalAuth.Authenticate(), clientHandler.GetAssetInfo)
request, err := http.NewRequest(http.MethodGet, "/api/c/v1/asset/info?identifier="+card.ICCID, nil)
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)
}
@@ -194,22 +529,121 @@ func TestClientAssetInfoHTTPReturnsPackageExpiryEstimate(t *testing.T) {
}
defer response.Body.Close()
body, _ := io.ReadAll(response.Body)
if response.StatusCode != http.StatusOK || !bytes.Contains(body, []byte(`"expiry_estimate_status":"exact"`)) {
t.Fatalf("C 端预计到期响应错误%d %s", response.StatusCode, 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)
shopStore := postgres.NewShopStore(env.db, env.redis)
cardStore := postgres.NewIotCardStore(env.db, env.redis)
deviceStore := postgres.NewDeviceStore(env.db, env.redis)
seriesStore := postgres.NewPackageSeriesStore(env.db)
allocationStore := postgres.NewAssetAllocationRecordStore(env.db, env.redis)
cardSvc := iotCardService.New(env.db, cardStore, shopStore, allocationStore, postgres.NewShopPackageAllocationStore(env.db), postgres.NewShopSeriesAllocationStore(env.db), seriesStore, nil, zap.NewNop(), nil)
deviceSvc := deviceService.New(env.db, env.redis, deviceStore, postgres.NewDeviceSimBindingStore(env.db, env.redis), cardStore, shopStore, allocationStore, postgres.NewShopPackageAllocationStore(env.db), postgres.NewShopSeriesAllocationStore(env.db), seriesStore, nil, postgres.NewAssetIdentifierStore(env.db), nil, postgres.NewEnterpriseDeviceAuthorizationStore(env.db, env.redis), postgres.NewEnterpriseStore(env.db, env.redis))
packageExpiry := packageExpiryQuery.NewQuery(env.db)
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) {
@@ -221,27 +655,133 @@ func TestAssetListHTTPReturnsPackageExpiryEstimate(t *testing.T) {
}, 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)
card := env.createCard(t, 48, nil)
card.IsStandalone = true
if err := env.db.Model(card).Update("is_standalone", true).Error; err != nil {
t.Fatalf("设置独立卡测试状态失败:%v", err)
}
device := &model.Device{VirtualNo: "UR46-LIST-DEVICE-" + strconv.FormatInt(time.Now().UnixNano(), 10), Status: constants.DeviceStatusInStock, AssetStatus: constants.AssetStatusInStock}
if err := env.db.Create(device).Error; err != nil {
t.Fatalf("创建设备失败:%v", err)
}
}
// 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 _, usage := range []*model.PackageUsage{{OrderID: card.ID, OrderNo: "UR46-LIST-CARD", 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}, {OrderID: device.ID, OrderNo: "UR46-LIST-DEVICE", PackageID: device.ID, UsageType: constants.AssetTypeDevice, DeviceID: device.ID, DataLimitMB: 1, Status: constants.PackageUsageStatusActive, Priority: 1, ActivatedAt: &now, ExpiresAt: &expiresAt, ExpiryBaseSnapshot: constants.PackageExpiryBaseFromActivation, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: 30}} {
if err := env.db.Create(usage).Error; err != nil {
t.Fatalf("创建列表套餐失败:%v", err)
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)
}
token := env.newAgentToken(t, 0)
for _, path := range []string{"/api/admin/iot-cards/standalone?page=1&page_size=100", "/api/admin/devices?page=1&page_size=100"} {
status, body := env.request(t, path, token)
if status != http.StatusOK || !bytes.Contains(body, []byte(`"expiry_estimate_status":"exact"`)) {
t.Fatalf("列表预计到期响应错误path=%s status=%d body=%s", path, status, body)
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)
}
}
}
@@ -356,6 +896,15 @@ func (e *assetTraceHTTPEnv) createCard(t *testing.T, suffix int, shopID *uint) *
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