Files
junhong_cmp_fiber/internal/routes/asset_exchange_trace_integration_test.go

269 lines
12 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"
"testing"
"time"
"github.com/bytedance/sonic"
"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
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"
assetService "github.com/break/junhong_cmp_fiber/internal/service/asset"
"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"
)
// 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)
}
}
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
}
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,
)
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}
}
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) 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
}