Files
junhong_cmp_fiber/internal/handler/admin/exchange_test.go

253 lines
11 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 admin
import (
"context"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"testing"
"time"
internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange"
"github.com/break/junhong_cmp_fiber/internal/testutil"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/bytedance/sonic"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
"gorm.io/gorm"
)
// TestExchangeListValidatesCompleteRequest 验证列表 Handler 对全部查询字段执行统一校验。
func TestExchangeListValidatesCompleteRequest(t *testing.T) {
testCases := []string{
"page=0", "page_size=0", "page_size=101", "status=0", "status=6", "flow_type=invalid",
"old_asset_keyword=" + url.QueryEscape(strings.Repeat("旧", 101)),
"new_asset_keyword=" + url.QueryEscape(strings.Repeat("新", 101)),
"created_at_start=invalid",
}
for _, query := range testCases {
t.Run(query, func(t *testing.T) {
app, _ := newExchangeListTestApp(&exchangeListStub{})
status, body := exchangeListRequest(t, app, "/api/admin/exchanges?"+query)
if status != http.StatusBadRequest {
t.Fatalf("期望 HTTP 400实际 %d响应%s", status, body)
}
assertExchangeErrorResponse(t, body, errors.CodeInvalidParam, "参数验证失败")
})
}
}
// TestExchangeListPassesIndependentKeywordsAndReturnsUnifiedResponse 验证新旧关键词独立传入读取用例。
func TestExchangeListPassesIndependentKeywordsAndReturnsUnifiedResponse(t *testing.T) {
stub := &exchangeListStub{response: &dto.ExchangeListResponse{List: []*dto.ExchangeOrderResponse{}, Total: 0, Page: 2, PageSize: 7}}
app, _ := newExchangeListTestApp(stub)
status, body := exchangeListRequest(t, app, "/api/admin/exchanges?page=2&page_size=7&old_asset_keyword=old&new_asset_keyword=new")
if status != http.StatusOK {
t.Fatalf("期望 HTTP 200实际 %d响应%s", status, body)
}
if stub.request == nil || stub.request.OldAssetKeyword != "old" || stub.request.NewAssetKeyword != "new" {
t.Fatalf("读取用例未收到独立关键词:%+v", stub.request)
}
var response struct {
Code int `json:"code"`
Data struct {
Items []any `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
} `json:"data"`
}
if err := sonic.Unmarshal(body, &response); err != nil {
t.Fatalf("解析响应失败:%v", err)
}
if response.Code != errors.CodeSuccess || response.Data.Page != 2 || response.Data.Size != 7 || response.Data.Items == nil {
t.Fatalf("统一分页响应不符合契约:%s", body)
}
}
// TestExchangeListSanitizesDatabaseErrors 验证数据库故障返回脱敏统一 500。
func TestExchangeListSanitizesDatabaseErrors(t *testing.T) {
stub := &exchangeListStub{err: errors.Wrap(errors.CodeDatabaseError, context.Canceled, "查询换货单数量失败")}
app, _ := newExchangeListTestApp(stub)
status, body := exchangeListRequest(t, app, "/api/admin/exchanges?old_asset_keyword=UR45")
if status != http.StatusInternalServerError {
t.Fatalf("期望 HTTP 500实际 %d响应%s", status, body)
}
assertExchangeErrorResponse(t, body, errors.CodeDatabaseError, "数据库错误")
if strings.Contains(string(body), "context canceled") || strings.Contains(string(body), "查询换货单数量失败") {
t.Fatalf("数据库错误响应泄露内部细节:%s", body)
}
}
// TestExchangeListHTTPIntegratesSearchAndAccountScopes 验证真实 Query 的 HTTP 搜索组合和三类账号范围。
func TestExchangeListHTTPIntegratesSearchAndAccountScopes(t *testing.T) {
tx := testutil.NewPostgresTransaction(t)
shopOne, shopTwo := uint(45201), uint(45202)
oldCard := createExchangeHTTPCard(t, tx, 1, shopOne)
newCard := createExchangeHTTPCard(t, tx, 2, shopOne)
otherCard := createExchangeHTTPCard(t, tx, 3, shopTwo)
createdAt := time.Now().Add(-time.Hour).Truncate(time.Second)
visible := createExchangeHTTPOrder(t, tx, "UR45-HTTP-VISIBLE", oldCard, newCard, shopOne, createdAt)
createExchangeHTTPOrder(t, tx, "UR45-HTTP-HIDDEN", otherCard, otherCard, shopTwo, createdAt.Add(time.Minute))
handler := NewExchangeHandler(nil, exchangeQuery.NewListQuery(tx), validator.New())
app := fiber.New(fiber.Config{JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, ErrorHandler: internalMiddleware.ErrorHandler(zap.NewNop())})
app.Get("/api/admin/exchanges", func(c *fiber.Ctx) error {
ctx := c.UserContext()
if c.Get("X-UR45-Account") == "agent" {
ctx = context.WithValue(ctx, constants.ContextKeySubordinateShopIDs, []uint{shopOne})
}
c.SetUserContext(ctx)
return handler.List(c)
})
start, end := createdAt.Add(-time.Minute).Format(time.RFC3339), createdAt.Add(time.Minute).Format(time.RFC3339)
query := "old_asset_keyword=" + url.QueryEscape(oldCard.MSISDN) + "&new_asset_keyword=" + url.QueryEscape(newCard.VirtualNo) + "&status=4&flow_type=direct&created_at_start=" + url.QueryEscape(start) + "&created_at_end=" + url.QueryEscape(end)
for _, account := range []string{"super_admin", "platform", "agent"} {
t.Run(account, func(t *testing.T) {
status, body := exchangeListRequestWithAccount(t, app, "/api/admin/exchanges?"+query, account)
page := decodeExchangeHTTPPage(t, body)
if status != http.StatusOK || page.Total != 1 || len(page.Items) != 1 || uint(page.Items[0]["id"].(float64)) != visible.ID {
t.Fatalf("账号范围或组合搜索错误status=%d body=%s", status, body)
}
})
}
status, body := exchangeListRequestWithAccount(t, app, "/api/admin/exchanges?old_asset_keyword="+url.QueryEscape(oldCard.MSISDN), "platform")
page := decodeExchangeHTTPPage(t, body)
if status != http.StatusOK || page.Total != 1 || page.Items[0]["old_asset_identifier"] != "历史旧快照" {
t.Fatalf("仅旧关键词或历史旧快照响应错误status=%d body=%s", status, body)
}
status, body = exchangeListRequestWithAccount(t, app, "/api/admin/exchanges?new_asset_keyword="+url.QueryEscape(newCard.VirtualNo), "platform")
page = decodeExchangeHTTPPage(t, body)
if status != http.StatusOK || page.Total != 1 || page.Items[0]["new_asset_identifier"] != "历史新快照" {
t.Fatalf("仅新关键词或历史新快照响应错误status=%d body=%s", status, body)
}
status, body = exchangeListRequestWithAccount(t, app, "/api/admin/exchanges", "platform")
page = decodeExchangeHTTPPage(t, body)
if status != http.StatusOK || !exchangeHTTPPageContains(page, "UR45-HTTP-VISIBLE") || !exchangeHTTPPageContains(page, "UR45-HTTP-HIDDEN") {
t.Fatalf("空参数列表响应错误status=%d body=%s", status, body)
}
status, body = exchangeListRequestWithAccount(t, app, "/api/admin/exchanges?old_asset_keyword="+url.QueryEscape(otherCard.ICCID), "agent")
if page := decodeExchangeHTTPPage(t, body); status != http.StatusOK || page.Total != 0 || len(page.Items) != 0 {
t.Fatalf("代理关键词绕过店铺范围status=%d body=%s", status, body)
}
status, body = exchangeListRequestWithAccount(t, app, "/api/admin/exchanges?new_asset_keyword=不存在", "platform")
if page := decodeExchangeHTTPPage(t, body); status != http.StatusOK || page.Total != 0 || len(page.Items) != 0 {
t.Fatalf("无匹配应返回成功空分页status=%d body=%s", status, body)
}
}
type exchangeListStub struct {
request *dto.ExchangeListRequest
response *dto.ExchangeListResponse
err error
}
func (s *exchangeListStub) List(_ context.Context, req *dto.ExchangeListRequest) (*dto.ExchangeListResponse, error) {
s.request = req
if s.response == nil && s.err == nil {
s.response = &dto.ExchangeListResponse{List: []*dto.ExchangeOrderResponse{}, Page: constants.DefaultPage, PageSize: constants.DefaultPageSize}
}
return s.response, s.err
}
func newExchangeListTestApp(stub ExchangeLister) (*fiber.App, *ExchangeHandler) {
handler := NewExchangeHandler(nil, stub, validator.New())
app := fiber.New(fiber.Config{JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, ErrorHandler: internalMiddleware.ErrorHandler(zap.NewNop())})
app.Get("/api/admin/exchanges", handler.List)
return app, handler
}
func exchangeListRequest(t *testing.T, app *fiber.App, path string) (int, []byte) {
return exchangeListRequestWithAccount(t, app, path, "")
}
func exchangeListRequestWithAccount(t *testing.T, app *fiber.App, path, account string) (int, []byte) {
t.Helper()
request, err := http.NewRequest(http.MethodGet, path, nil)
if err != nil {
t.Fatalf("创建请求失败:%v", err)
}
if account != "" {
request.Header.Set("X-UR45-Account", account)
}
response, err := app.Test(request, -1)
if err != nil {
t.Fatalf("执行请求失败:%v", err)
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
t.Fatalf("读取响应失败:%v", err)
}
return response.StatusCode, body
}
type exchangeHTTPPage struct {
Items []map[string]any `json:"items"`
Total int64 `json:"total"`
}
func decodeExchangeHTTPPage(t *testing.T, body []byte) exchangeHTTPPage {
t.Helper()
var response struct {
Data exchangeHTTPPage `json:"data"`
}
if err := sonic.Unmarshal(body, &response); err != nil {
t.Fatalf("解析分页响应失败:%v", err)
}
return response.Data
}
func exchangeHTTPPageContains(page exchangeHTTPPage, exchangeNo string) bool {
for _, item := range page.Items {
if item["exchange_no"] == exchangeNo {
return true
}
}
return false
}
func createExchangeHTTPCard(t *testing.T, tx *gorm.DB, suffix int, shopID uint) *model.IotCard {
t.Helper()
iccid := "8986222222222222000" + strconv.Itoa(suffix)
card := &model.IotCard{ICCID: iccid, ICCID19: iccid[:19], MSISDN: "1360000000" + strconv.Itoa(suffix), VirtualNo: "UR45-HTTP-CARD-" + strconv.Itoa(suffix), ShopID: &shopID, AssetStatus: constants.AssetStatusInStock}
if err := tx.Create(card).Error; err != nil {
t.Fatalf("创建 HTTP 测试卡失败:%v", err)
}
return card
}
func createExchangeHTTPOrder(t *testing.T, tx *gorm.DB, exchangeNo string, oldCard, newCard *model.IotCard, shopID uint, createdAt time.Time) *model.ExchangeOrder {
t.Helper()
newID := newCard.ID
order := &model.ExchangeOrder{ExchangeNo: exchangeNo, FlowType: constants.ExchangeFlowTypeDirect, OldAssetType: constants.ExchangeAssetTypeIotCard, OldAssetID: oldCard.ID, OldAssetIdentifier: "历史旧快照", NewAssetType: constants.ExchangeAssetTypeIotCard, NewAssetID: &newID, NewAssetIdentifier: "历史新快照", ExchangeReason: "UR45 HTTP 测试", Status: constants.ExchangeStatusCompleted, ShopID: &shopID}
order.CreatedAt, order.UpdatedAt = createdAt, createdAt
if err := tx.Create(order).Error; err != nil {
t.Fatalf("创建 HTTP 测试换货单失败:%v", err)
}
return order
}
func assertExchangeErrorResponse(t *testing.T, body []byte, expectedCode int, expectedMessage string) {
t.Helper()
var response struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data any `json:"data"`
}
if err := sonic.Unmarshal(body, &response); err != nil {
t.Fatalf("解析错误响应失败:%v", err)
}
if response.Code != expectedCode || response.Msg != expectedMessage || response.Data != nil || !strings.Contains(string(body), "timestamp") {
t.Fatalf("错误响应不符合契约:%s", body)
}
}