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