重置项目上下文与规范文档

This commit is contained in:
2026-08-07 16:18:07 +08:00
parent 6611ca5226
commit 79e2d9ff92
1900 changed files with 1552 additions and 348365 deletions

View File

@@ -1,252 +0,0 @@
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)
}
}

View File

@@ -1,373 +0,0 @@
package admin
import (
"bytes"
"context"
"io"
"net/http"
"strconv"
"testing"
"time"
internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/model"
batchAllocationService "github.com/break/junhong_cmp_fiber/internal/service/shop_package_batch_allocation"
grantService "github.com/break/junhong_cmp_fiber/internal/service/shop_series_grant"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"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/break/junhong_cmp_fiber/pkg/middleware"
"github.com/bytedance/sonic"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
"gorm.io/gorm"
)
// testIDMask 用于将纳秒时间戳截断为合法的 uint 主键范围,仅限测试用。
const testIDMask = 0x7fffffff
// TestBatchAllocatePackagesHTTPRequiresExplicitExpiryBase 验证批量分配显式选择并固化到全部记录。
func TestBatchAllocatePackagesHTTPRequiresExplicitExpiryBase(t *testing.T) {
testCases := []struct {
name string
field string
expected *string
}{
{name: "跟随默认", field: "null"},
{name: "购买即生效", field: `"from_purchase"`, expected: testutil.StringPointer(constants.PackageExpiryBaseFromPurchase)},
{name: "实名即生效", field: `"from_activation"`, expected: testutil.StringPointer(constants.PackageExpiryBaseFromActivation)},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
tx, fixture := newExpiryBaseHTTPFixture(t)
app := fixture.newApp(constants.UserTypeSuperAdmin, 0)
body := `{"shop_id":` + strconv.FormatUint(uint64(fixture.shop.ID), 10) + `,"series_id":` + strconv.FormatUint(uint64(fixture.series.ID), 10) + `,"expiry_base_override":` + testCase.field + `}`
status, responseBody := expiryBaseHTTPRequest(t, app, http.MethodPost, "/api/admin/shop-package-allocations/batch", body)
if status != http.StatusOK {
t.Fatalf("批量分配失败status=%d body=%s", status, responseBody)
}
var allocations []model.ShopPackageAllocation
if err := tx.Where("shop_id = ?", fixture.shop.ID).Order("package_id").Find(&allocations).Error; err != nil {
t.Fatalf("查询套餐分配失败:%v", err)
}
if len(allocations) != len(fixture.packages) {
t.Fatalf("期望创建 %d 条分配,实际 %d", len(fixture.packages), len(allocations))
}
for _, allocation := range allocations {
if !nullableStringEqual(allocation.ExpiryBaseOverride, testCase.expected) {
t.Fatalf("分配 %d 的覆盖值错误:%v", allocation.ID, allocation.ExpiryBaseOverride)
}
}
})
}
}
// TestBatchAllocatePackagesHTTPRejectsMissingAndInvalidExpiryBase 验证字段缺失与非法枚举统一拒绝且不写入。
func TestBatchAllocatePackagesHTTPRejectsMissingAndInvalidExpiryBase(t *testing.T) {
for _, bodySuffix := range []string{"", `,"expiry_base_override":"invalid"`} {
t.Run(bodySuffix, func(t *testing.T) {
tx, fixture := newExpiryBaseHTTPFixture(t)
app := fixture.newApp(constants.UserTypeSuperAdmin, 0)
body := `{"shop_id":` + strconv.FormatUint(uint64(fixture.shop.ID), 10) + `,"series_id":` + strconv.FormatUint(uint64(fixture.series.ID), 10) + bodySuffix + `}`
status, responseBody := expiryBaseHTTPRequest(t, app, http.MethodPost, "/api/admin/shop-package-allocations/batch", body)
if status != http.StatusBadRequest {
t.Fatalf("期望参数错误,实际 status=%d body=%s", status, responseBody)
}
var count int64
if err := tx.Model(&model.ShopPackageAllocation{}).Where("shop_id = ?", fixture.shop.ID).Count(&count).Error; err != nil || count != 0 {
t.Fatalf("失败请求不应留下套餐分配count=%d err=%v", count, err)
}
})
}
}
// TestUpdateAllocationExpiryBaseHTTP 验证 PATCH 的恢复默认、幂等、权限和历史快照隔离。
func TestUpdateAllocationExpiryBaseHTTP(t *testing.T) {
tx, fixture := newExpiryBaseHTTPFixture(t)
override := constants.PackageExpiryBaseFromPurchase
allocation := &model.ShopPackageAllocation{ShopID: fixture.shop.ID, PackageID: fixture.packages[0].ID, AllocatorShopID: 101, CostPrice: 1, RetailPrice: 2, ExpiryBaseOverride: &override, Status: constants.StatusEnabled, ShelfStatus: 1}
if err := tx.Create(allocation).Error; err != nil {
t.Fatalf("创建套餐分配失败:%v", err)
}
usage := completeExpiryBaseTestUsage(fixture.packages[0].ID)
if err := tx.Create(usage).Error; err != nil {
t.Fatalf("创建既有购买记录失败:%v", err)
}
app := fixture.newApp(constants.UserTypeSuperAdmin, 0)
path := "/api/admin/shop-package-allocations/" + strconv.FormatUint(uint64(allocation.ID), 10) + "/expiry-base"
// 首次 PATCH 验证响应体包含 spec 要求的所有生效条件字段
{
status, respBody := expiryBaseHTTPRequest(t, app, http.MethodPatch, path, `{"expiry_base_override":"from_activation"}`)
if status != http.StatusOK {
t.Fatalf("首次 PATCH 失败status=%d body=%s", status, respBody)
}
var resp struct {
Data struct {
DefaultExpiryBase string `json:"default_expiry_base"`
DefaultExpiryBaseName string `json:"default_expiry_base_name"`
ExpiryBaseOverride *string `json:"expiry_base_override"`
ExpiryBaseOverrideName string `json:"expiry_base_override_name"`
EffectiveExpiryBase string `json:"effective_expiry_base"`
EffectiveExpiryBaseName string `json:"effective_expiry_base_name"`
} `json:"data"`
}
if err := sonic.Unmarshal(respBody, &resp); err != nil {
t.Fatalf("解析 PATCH 响应失败:%v", err)
}
d := resp.Data
if d.DefaultExpiryBase == "" || d.DefaultExpiryBaseName == "" ||
d.ExpiryBaseOverride == nil || d.ExpiryBaseOverrideName == "" ||
d.EffectiveExpiryBase == "" || d.EffectiveExpiryBaseName == "" {
t.Fatalf("PATCH 响应缺少 spec 要求的生效条件字段:%+v", d)
}
}
// 幂等 + 恢复默认
for _, body := range []string{`{"expiry_base_override":"from_activation"}`, `{"expiry_base_override":null}`} {
status, responseBody := expiryBaseHTTPRequest(t, app, http.MethodPatch, path, body)
if status != http.StatusOK {
t.Fatalf("修改覆盖值失败status=%d body=%s", status, responseBody)
}
}
var refreshed model.ShopPackageAllocation
if err := tx.First(&refreshed, allocation.ID).Error; err != nil || refreshed.ExpiryBaseOverride != nil {
t.Fatalf("显式 null 应恢复跟随默认allocation=%+v err=%v", refreshed, err)
}
var refreshedUsage model.PackageUsage
if err := tx.First(&refreshedUsage, usage.ID).Error; err != nil {
t.Fatalf("查询既有购买记录失败:%v", err)
}
if refreshedUsage.ExpiryBaseSnapshot != usage.ExpiryBaseSnapshot || refreshedUsage.CalendarTypeSnapshot != usage.CalendarTypeSnapshot || refreshedUsage.DurationDaysSnapshot != usage.DurationDaysSnapshot {
t.Fatalf("修改分配不应改变既有购买快照before=%+v after=%+v", usage, refreshedUsage)
}
for _, body := range []string{`{}`, `{"expiry_base_override":"invalid"}`} {
status, _ := expiryBaseHTTPRequest(t, app, http.MethodPatch, path, body)
if status != http.StatusBadRequest {
t.Fatalf("字段缺失或非法值应返回 400body=%s status=%d", body, status)
}
}
forbiddenApp := fixture.newApp(constants.UserTypeAgent, 202)
status, _ := expiryBaseHTTPRequest(t, forbiddenApp, http.MethodPatch, path, `{"expiry_base_override":null}`)
if status != http.StatusForbidden {
t.Fatalf("越权修改应返回 403实际 %d", status)
}
if err := tx.Delete(&refreshed).Error; err != nil {
t.Fatalf("软删除分配失败:%v", err)
}
status, _ = expiryBaseHTTPRequest(t, app, http.MethodPatch, path, `{"expiry_base_override":null}`)
if status != http.StatusForbidden {
t.Fatalf("软删除分配应使用统一 403 语义,实际 %d", status)
}
}
type expiryBaseHTTPFixture struct {
tx *gorm.DB
shop *model.Shop
series *model.PackageSeries
packages []*model.Package
}
func newExpiryBaseHTTPFixture(t *testing.T) (*gorm.DB, *expiryBaseHTTPFixture) {
t.Helper()
tx := testutil.NewPostgresTransaction(t)
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
shop := &model.Shop{ShopName: "UR55测试店铺", ShopCode: "UR55-SHOP-" + suffix, Status: constants.StatusEnabled}
series := &model.PackageSeries{SeriesCode: "UR55-SERIES-" + suffix, SeriesName: "UR55测试系列", Status: constants.StatusEnabled}
if err := tx.Create(shop).Error; err != nil {
t.Fatalf("创建测试店铺失败:%v", err)
}
if err := tx.Create(series).Error; err != nil {
t.Fatalf("创建测试系列失败:%v", err)
}
packages := make([]*model.Package, 0, 2)
for index, expiryBase := range []string{constants.PackageExpiryBaseFromActivation, constants.PackageExpiryBaseFromPurchase} {
pkg := &model.Package{PackageCode: "UR55-PKG-" + suffix + "-" + strconv.Itoa(index), PackageName: "UR55测试套餐", SeriesID: series.ID, PackageType: constants.PackageTypeFormal, DurationMonths: 1, DurationDays: 30, CalendarType: constants.PackageCalendarTypeByDay, ExpiryBase: expiryBase, Status: constants.StatusEnabled, ShelfStatus: 1, PriceConfigStatus: 2}
if err := tx.Create(pkg).Error; err != nil {
t.Fatalf("创建测试套餐失败:%v", err)
}
packages = append(packages, pkg)
}
seriesAllocation := &model.ShopSeriesAllocation{ShopID: shop.ID, SeriesID: series.ID, Status: constants.StatusEnabled}
if err := tx.Create(seriesAllocation).Error; err != nil {
t.Fatalf("创建系列授权失败:%v", err)
}
return tx, &expiryBaseHTTPFixture{tx: tx, shop: shop, series: series, packages: packages}
}
func (f *expiryBaseHTTPFixture) newApp(userType int, shopID uint) *fiber.App {
service := batchAllocationService.New(f.tx, postgres.NewPackageStore(f.tx), postgres.NewShopPackageAllocationStore(f.tx), postgres.NewShopSeriesAllocationStore(f.tx), postgres.NewShopStore(f.tx, nil), nil)
handler := NewShopPackageBatchAllocationHandler(service)
app := fiber.New(fiber.Config{JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, ErrorHandler: internalMiddleware.ErrorHandler(zap.NewNop())})
setContext := func(c *fiber.Ctx) error {
ctx := middleware.SetUserContext(c.UserContext(), &middleware.UserContextInfo{UserID: 9001, UserType: userType, Username: "UR55测试账号", ShopID: shopID})
c.SetUserContext(ctx)
return c.Next()
}
app.Post("/api/admin/shop-package-allocations/batch", setContext, handler.BatchAllocate)
app.Patch("/api/admin/shop-package-allocations/:id/expiry-base", setContext, handler.UpdateExpiryBase)
return app
}
func expiryBaseHTTPRequest(t *testing.T, app *fiber.App, method, path, body string) (int, []byte) {
t.Helper()
request, err := http.NewRequestWithContext(context.Background(), method, path, bytes.NewBufferString(body))
if err != nil {
t.Fatalf("创建 HTTP 请求失败:%v", err)
}
request.Header.Set("Content-Type", "application/json")
response, err := app.Test(request, -1)
if err != nil {
t.Fatalf("执行 HTTP 请求失败:%v", err)
}
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
if err != nil {
t.Fatalf("读取 HTTP 响应失败:%v", err)
}
return response.StatusCode, responseBody
}
func completeExpiryBaseTestUsage(packageID uint) *model.PackageUsage {
unique := uint(time.Now().UnixNano() & testIDMask)
return &model.PackageUsage{OrderID: unique, OrderNo: "UR55-PATCH-USAGE", PackageID: packageID, UsageType: constants.AssetWalletResourceTypeIotCard, IotCardID: unique, DataLimitMB: 1, Status: constants.PackageUsageStatusPending, Priority: 1, PackageName: "UR55测试套餐", Generation: 1, ExpiryBaseSnapshot: constants.PackageExpiryBaseFromPurchase, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: 30}
}
func nullableStringEqual(left, right *string) bool {
return left == nil && right == nil || left != nil && right != nil && *left == *right
}
// TestSeriesGrantCreateHTTPRequiresExplicitExpiryBase 验证系列首次授权(含套餐)固化生效条件到分配记录。
func TestSeriesGrantCreateHTTPRequiresExplicitExpiryBase(t *testing.T) {
testCases := []struct {
name string
field string
expected *string
}{
{name: "跟随默认", field: "null"},
{name: "购买即生效", field: `"from_purchase"`, expected: testutil.StringPointer(constants.PackageExpiryBaseFromPurchase)},
{name: "实名即生效", field: `"from_activation"`, expected: testutil.StringPointer(constants.PackageExpiryBaseFromActivation)},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
tx, fixture := newExpiryBaseHTTPFixture(t)
targetShop := &model.Shop{ShopName: "UR55授权目标店铺", ShopCode: "UR55-TARGET-" + strconv.FormatInt(time.Now().UnixNano(), 10), Status: constants.StatusEnabled}
if err := tx.Create(targetShop).Error; err != nil {
t.Fatalf("创建目标店铺失败:%v", err)
}
app := fixture.newGrantApp(constants.UserTypeSuperAdmin, 0, tx)
pkg := fixture.packages[0]
body := `{"shop_id":` + strconv.FormatUint(uint64(targetShop.ID), 10) +
`,"series_id":` + strconv.FormatUint(uint64(fixture.series.ID), 10) +
`,"packages":[{"package_id":` + strconv.FormatUint(uint64(pkg.ID), 10) + `,"cost_price":100}]` +
`,"expiry_base_override":` + testCase.field + `}`
status, responseBody := expiryBaseHTTPRequest(t, app, http.MethodPost, "/api/admin/shop-series-grants", body)
if status != http.StatusOK {
t.Fatalf("系列授权创建失败status=%d body=%s", status, responseBody)
}
var alloc model.ShopPackageAllocation
if err := tx.Where("shop_id = ? AND package_id = ?", targetShop.ID, pkg.ID).First(&alloc).Error; err != nil {
t.Fatalf("查询套餐分配失败:%v", err)
}
if !nullableStringEqual(alloc.ExpiryBaseOverride, testCase.expected) {
t.Fatalf("系列授权覆盖值错误got=%v want=%v", alloc.ExpiryBaseOverride, testCase.expected)
}
})
}
}
// TestSeriesGrantCreateHTTPRejectsMissingAndInvalidExpiryBase 验证字段缺失与非法枚举拒绝系列授权创建。
func TestSeriesGrantCreateHTTPRejectsMissingAndInvalidExpiryBase(t *testing.T) {
for _, bodySuffix := range []string{"", `,"expiry_base_override":"invalid"`} {
t.Run(bodySuffix, func(t *testing.T) {
tx, fixture := newExpiryBaseHTTPFixture(t)
targetShop := &model.Shop{ShopName: "UR55授权目标店铺", ShopCode: "UR55-TARGET-" + strconv.FormatInt(time.Now().UnixNano(), 10), Status: constants.StatusEnabled}
if err := tx.Create(targetShop).Error; err != nil {
t.Fatalf("创建目标店铺失败:%v", err)
}
app := fixture.newGrantApp(constants.UserTypeSuperAdmin, 0, tx)
pkg := fixture.packages[0]
body := `{"shop_id":` + strconv.FormatUint(uint64(targetShop.ID), 10) +
`,"series_id":` + strconv.FormatUint(uint64(fixture.series.ID), 10) +
`,"packages":[{"package_id":` + strconv.FormatUint(uint64(pkg.ID), 10) + `,"cost_price":100}]` +
bodySuffix + `}`
status, responseBody := expiryBaseHTTPRequest(t, app, http.MethodPost, "/api/admin/shop-series-grants", body)
if status != http.StatusBadRequest {
t.Fatalf("期望 400实际 status=%d body=%s", status, responseBody)
}
var count int64
if err := tx.Model(&model.ShopPackageAllocation{}).Where("shop_id = ?", targetShop.ID).Count(&count).Error; err != nil || count != 0 {
t.Fatalf("失败请求不应留下套餐分配count=%d err=%v", count, err)
}
})
}
}
// TestSeriesGrantManagePackagesHTTPRequiresExplicitExpiryBase 验证后续追加套餐固化生效条件到新分配记录。
func TestSeriesGrantManagePackagesHTTPRequiresExplicitExpiryBase(t *testing.T) {
tx, fixture := newExpiryBaseHTTPFixture(t)
targetShop := &model.Shop{ShopName: "UR55追加目标店铺", ShopCode: "UR55-MANAGE-" + strconv.FormatInt(time.Now().UnixNano(), 10), Status: constants.StatusEnabled}
if err := tx.Create(targetShop).Error; err != nil {
t.Fatalf("创建目标店铺失败:%v", err)
}
// 先创建不含套餐的系列授权
seriesAlloc := &model.ShopSeriesAllocation{ShopID: targetShop.ID, SeriesID: fixture.series.ID, AllocatorShopID: 0, Status: constants.StatusEnabled}
if err := tx.Create(seriesAlloc).Error; err != nil {
t.Fatalf("创建系列授权失败:%v", err)
}
app := fixture.newGrantApp(constants.UserTypeSuperAdmin, 0, tx)
path := "/api/admin/shop-series-grants/" + strconv.FormatUint(uint64(seriesAlloc.ID), 10) + "/packages"
pkg := fixture.packages[0]
// 有效追加from_purchase
body := `{"packages":[{"package_id":` + strconv.FormatUint(uint64(pkg.ID), 10) + `,"cost_price":50}],"expiry_base_override":"from_purchase"}`
status, responseBody := expiryBaseHTTPRequest(t, app, http.MethodPut, path, body)
if status != http.StatusOK {
t.Fatalf("追加套餐失败status=%d body=%s", status, responseBody)
}
var alloc model.ShopPackageAllocation
if err := tx.Where("shop_id = ? AND package_id = ?", targetShop.ID, pkg.ID).First(&alloc).Error; err != nil {
t.Fatalf("查询新建套餐分配失败:%v", err)
}
expected := constants.PackageExpiryBaseFromPurchase
if !nullableStringEqual(alloc.ExpiryBaseOverride, &expected) {
t.Fatalf("追加套餐覆盖值错误:%v", alloc.ExpiryBaseOverride)
}
// 缺失 expiry_base_override 字段应返回 400
for _, badBody := range []string{
`{"packages":[{"package_id":` + strconv.FormatUint(uint64(fixture.packages[1].ID), 10) + `,"cost_price":50}]}`,
`{"packages":[{"package_id":` + strconv.FormatUint(uint64(fixture.packages[1].ID), 10) + `,"cost_price":50}],"expiry_base_override":"invalid"}`,
} {
s, _ := expiryBaseHTTPRequest(t, app, http.MethodPut, path, badBody)
if s != http.StatusBadRequest {
t.Fatalf("缺失/非法 expiry_base_override 应返回 400实际 %d", s)
}
}
}
func (f *expiryBaseHTTPFixture) newGrantApp(userType int, shopID uint, tx *gorm.DB) *fiber.App {
svc := grantService.New(
tx,
postgres.NewShopSeriesAllocationStore(tx),
postgres.NewShopPackageAllocationStore(tx),
postgres.NewShopPackageAllocationPriceHistoryStore(tx),
postgres.NewShopStore(tx, nil),
postgres.NewPackageStore(tx),
postgres.NewPackageSeriesStore(tx),
zap.NewNop(),
)
handler := NewShopSeriesGrantHandler(svc)
app := fiber.New(fiber.Config{JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, ErrorHandler: internalMiddleware.ErrorHandler(zap.NewNop())})
setContext := func(c *fiber.Ctx) error {
ctx := middleware.SetUserContext(c.UserContext(), &middleware.UserContextInfo{UserID: 9001, UserType: userType, Username: "UR55测试账号", ShopID: shopID})
c.SetUserContext(ctx)
return c.Next()
}
app.Post("/api/admin/shop-series-grants", setContext, handler.Create)
app.Put("/api/admin/shop-series-grants/:id/packages", setContext, handler.ManagePackages)
return app
}
var _ = errors.CodeSuccess