@@ -2,11 +2,14 @@ package routes
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
@@ -20,7 +23,9 @@ import (
"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"
shopService "github.com/break/junhong_cmp_fiber/internal/service/shop"
shopCommissionService "github.com/break/junhong_cmp_fiber/internal/service/shop_commission"
"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"
@@ -50,6 +55,7 @@ type shopTestEnv struct {
tokenManager * auth . TokenManager
}
// TestShopListValidatesAllQueryParameters 验证店铺列表会完整校验所有查询参数。
func TestShopListValidatesAllQueryParameters ( t * testing . T ) {
env := newShopTestEnv ( t )
token := env . newToken ( t , auth . TokenInfo {
@@ -100,6 +106,7 @@ func TestShopListValidatesAllQueryParameters(t *testing.T) {
}
}
// TestShopListUsesNormalizedAndExplicitPagination 验证默认分页归一化且显式分页保持不变。
func TestShopListUsesNormalizedAndExplicitPagination ( t * testing . T ) {
env := newShopTestEnv ( t )
token := env . newToken ( t , auth . TokenInfo {
@@ -114,7 +121,7 @@ func TestShopListUsesNormalizedAndExplicitPagination(t *testing.T) {
expectedPage int
expectedSize int
} {
{ name : "默认分页" , expectedPage : 1 , expectedSize : constants . DefaultPageSize } ,
{ name : "默认分页" , expectedPage : constants . DefaultPage , expectedSize : constants . DefaultPageSize } ,
{ name : "显式分页" , query : "?page=2&page_size=7&shop_name=ur60-no-match" , expectedPage : 2 , expectedSize : 7 } ,
}
@@ -136,6 +143,7 @@ func TestShopListUsesNormalizedAndExplicitPagination(t *testing.T) {
}
}
// TestShopListKeepsAuthenticationContract 验证店铺列表保持既有认证错误契约。
func TestShopListKeepsAuthenticationContract ( t * testing . T ) {
env := newShopTestEnv ( t )
@@ -160,6 +168,7 @@ func TestShopListKeepsAuthenticationContract(t *testing.T) {
}
}
// TestEnterpriseCannotAccessCoreShopManagementRoutes 验证企业账号无法访问五个核心店铺管理入口。
func TestEnterpriseCannotAccessCoreShopManagementRoutes ( t * testing . T ) {
env := newShopTestEnv ( t )
token := env . newToken ( t , auth . TokenInfo {
@@ -195,7 +204,7 @@ func TestEnterpriseCannotAccessCoreShopManagementRoutes(t *testing.T) {
if err := sonic . Unmarshal ( body , & response ) ; err != nil {
t . Fatalf ( "解析响应失败:%v" , err )
}
if response . Code != errors . CodeForbidden || response . Msg != "无权限访问店铺管理功能" || response . Data != nil {
if response . Code != errors . CodeForbidden || response . Msg != constants . ShopManagementForbiddenMessage || response . Data != nil {
t . Fatalf ( "企业账号禁止响应不符合契约:%s" , body )
}
if ! strings . Contains ( string ( body ) , "timestamp" ) {
@@ -205,6 +214,7 @@ func TestEnterpriseCannotAccessCoreShopManagementRoutes(t *testing.T) {
}
}
// TestCoreShopRestrictionDoesNotAffectOtherShopRoutes 验证核心店铺权限限制不影响其他店铺前缀路由。
func TestCoreShopRestrictionDoesNotAffectOtherShopRoutes ( t * testing . T ) {
env := newShopTestEnv ( t )
token := env . newToken ( t , auth . TokenInfo {
@@ -214,12 +224,22 @@ func TestCoreShopRestrictionDoesNotAffectOtherShopRoutes(t *testing.T) {
Username : "ur60-enterprise" ,
} )
status , body := env . request ( t , http . MethodGet , "/api/admin/shops/1/roles" , token )
if status == http . StatusForbidden && strings . Contains ( string ( body ) , "无权限访问店铺管理功能" ) {
t . Fatalf ( "核心店铺管理拦截误伤店铺角色路由:%s" , body )
paths := [ ] string {
"/api/admin/shops/1/roles" ,
"/api/admin/shops/fund-summary" ,
"/api/admin/shops/1/withdrawal-requests" ,
"/api/admin/shops/1/commission-records" ,
"/api/admin/shops/1/main-wallet/transactions" ,
}
for _ , path := range paths {
status , body := env . request ( t , http . MethodGet , path , token )
if status == http . StatusForbidden && strings . Contains ( string ( body ) , constants . ShopManagementForbiddenMessage ) {
t . Fatalf ( "核心店铺管理拦截误伤独立路由 %s: %s" , path , body )
}
}
}
// TestNonEnterpriseAccountsKeepCoreShopListAccess 验证非企业账号保留核心店铺列表访问能力。
func TestNonEnterpriseAccountsKeepCoreShopListAccess ( t * testing . T ) {
env := newShopTestEnv ( t )
testCases := [ ] struct {
@@ -248,6 +268,233 @@ func TestNonEnterpriseAccountsKeepCoreShopListAccess(t *testing.T) {
}
}
// TestShopListFiltersContactPhoneExactly 验证联系电话精确匹配、AND 组合、重复号码、排序及软删除语义。
func TestShopListFiltersContactPhoneExactly ( t * testing . T ) {
env := newShopTestEnv ( t )
phone := "13800000000"
parent := env . createShop ( t , "UR60父店" , phone , nil , 1 , time . Now ( ) . Add ( - 4 * time . Minute ) )
newer := env . createShop ( t , "UR60目标新店" , phone , & parent . ID , 2 , time . Now ( ) . Add ( - time . Minute ) )
older := env . createShop ( t , "UR60目标旧店" , phone , & parent . ID , 2 , time . Now ( ) . Add ( - 2 * time . Minute ) )
env . createShop ( t , "UR60相似号码" , "13800000001" , & parent . ID , 2 , time . Now ( ) )
deleted := env . createShop ( t , "UR60软删除店" , phone , & parent . ID , 2 , time . Now ( ) . Add ( time . Minute ) )
if err := env . db . Delete ( deleted ) . Error ; err != nil {
t . Fatalf ( "软删除测试店铺失败:%v" , err )
}
token := env . newToken ( t , auth . TokenInfo {
UserID : uniqueTestID ( ) ,
UserType : constants . UserTypeSuperAdmin ,
Username : "ur60-super-admin" ,
} )
status , body := env . request ( t , http . MethodGet , "/api/admin/shops?contact_phone=" + phone + "&shop_name=UR60目标&parent_id=" + strconv . FormatUint ( uint64 ( parent . ID ) , 10 ) + "&level=2&status=1" , token )
if status != http . StatusOK {
t . Fatalf ( "联系电话组合查询失败, HTTP %d: %s" , status , body )
}
var response shopTestResponse
if err := sonic . Unmarshal ( body , & response ) ; err != nil {
t . Fatalf ( "解析联系电话查询响应失败:%v" , err )
}
if response . Data . Total != 2 || len ( response . Data . Items ) != 2 {
t . Fatalf ( "联系电话组合查询应返回两条可见记录:%s" , body )
}
if uint ( response . Data . Items [ 0 ] [ "id" ] . ( float64 ) ) != newer . ID || uint ( response . Data . Items [ 1 ] [ "id" ] . ( float64 ) ) != older . ID {
t . Fatalf ( "联系电话查询未保持 created_at DESC: %s" , body )
}
status , body = env . request ( t , http . MethodGet , "/api/admin/shops?contact_phone=13800000001&shop_code=" + newer . ShopCode , token )
if status != http . StatusOK {
t . Fatalf ( "联系电话精确性查询失败, HTTP %d: %s" , status , body )
}
if err := sonic . Unmarshal ( body , & response ) ; err != nil {
t . Fatalf ( "解析联系电话精确性响应失败:%v" , err )
}
if response . Data . Total != 0 {
t . Fatalf ( "联系电话必须与其他条件按 AND 精确匹配:%s" , body )
}
platformToken := env . newToken ( t , auth . TokenInfo {
UserID : uniqueTestID ( ) ,
UserType : constants . UserTypePlatform ,
Username : "ur60-platform" ,
} )
status , body = env . request ( t , http . MethodGet , "/api/admin/shops?contact_phone=" + phone + "&shop_name=UR60目标" , platformToken )
if status != http . StatusOK {
t . Fatalf ( "平台账号联系电话查询失败, HTTP %d: %s" , status , body )
}
if err := sonic . Unmarshal ( body , & response ) ; err != nil {
t . Fatalf ( "解析平台账号联系电话响应失败:%v" , err )
}
if response . Data . Total != 2 {
t . Fatalf ( "平台账号联系电话查询结果不符合契约:%s" , body )
}
}
// TestShopListContactPhoneKeepsEmptyAndPaginationSemantics 验证空电话、空结果和显式分页契约。
func TestShopListContactPhoneKeepsEmptyAndPaginationSemantics ( t * testing . T ) {
env := newShopTestEnv ( t )
token := env . newToken ( t , auth . TokenInfo { UserID : uniqueTestID ( ) , UserType : constants . UserTypeSuperAdmin , Username : "ur60-super-admin" } )
status , body := env . request ( t , http . MethodGet , "/api/admin/shops?contact_phone=&page=2&page_size=3" , token )
if status != http . StatusOK {
t . Fatalf ( "空联系电话不应改变列表语义, HTTP %d: %s" , status , body )
}
var response shopTestResponse
if err := sonic . Unmarshal ( body , & response ) ; err != nil {
t . Fatalf ( "解析空联系电话响应失败:%v" , err )
}
if response . Data . Page != 2 || response . Data . Size != 3 {
t . Fatalf ( "联系电话筛选不应重置显式分页:%s" , body )
}
status , body = env . request ( t , http . MethodGet , "/api/admin/shops?contact_phone=19999999999" , token )
if status != http . StatusOK {
t . Fatalf ( "无匹配联系电话应返回成功空分页, HTTP %d: %s" , status , body )
}
if err := sonic . Unmarshal ( body , & response ) ; err != nil {
t . Fatalf ( "解析空结果响应失败:%v" , err )
}
if response . Data . Total != 0 || len ( response . Data . Items ) != 0 {
t . Fatalf ( "无匹配联系电话响应不符合空分页契约:%s" , body )
}
}
// TestShopListRejectsInvalidContactPhoneBeforeQuery 验证非法联系电话在执行店铺查询前被拒绝。
func TestShopListRejectsInvalidContactPhoneBeforeQuery ( t * testing . T ) {
env := newShopTestEnv ( t )
token := env . newToken ( t , auth . TokenInfo { UserID : uniqueTestID ( ) , UserType : constants . UserTypeSuperAdmin , Username : "ur60-super-admin" } )
var shopQueryCount atomic . Int64
callbackName := fmt . Sprintf ( "ur60:count_shop_queries:%d" , time . Now ( ) . UnixNano ( ) )
if err := env . db . Callback ( ) . Query ( ) . Before ( "gorm:query" ) . Register ( callbackName , func ( db * gorm . DB ) {
if db . Statement != nil && db . Statement . Table == ( model . Shop { } ) . TableName ( ) {
shopQueryCount . Add ( 1 )
}
} ) ; err != nil {
t . Fatalf ( "注册店铺查询计数回调失败:%v" , err )
}
t . Cleanup ( func ( ) { _ = env . db . Callback ( ) . Query ( ) . Remove ( callbackName ) } )
invalidPhones := [ ] string {
"1380000000" , "138000000000" , "1380000000A" , "+8613800000000" ,
"138-0000-0000" , " 13800000000" , "1 3 8 0 0 0 0 0 0 0 0 " ,
}
for _ , phone := range invalidPhones {
shopQueryCount . Store ( 0 )
status , body := env . request ( t , http . MethodGet , "/api/admin/shops?contact_phone=" + url . QueryEscape ( phone ) , token )
if status != http . StatusBadRequest || shopQueryCount . Load ( ) != 0 {
t . Fatalf ( "非法联系电话应在查询前返回 400, phone=%q, queries=%d, body=%s" , phone , shopQueryCount . Load ( ) , body )
}
}
}
// TestAgentContactPhoneSearchKeepsShopScope 验证代理账号无法通过联系电话越过店铺层级范围。
func TestAgentContactPhoneSearchKeepsShopScope ( t * testing . T ) {
env := newShopTestEnv ( t )
phone := "13700000000"
root := env . createShop ( t , "UR60代理根店" , phone , nil , 1 , time . Now ( ) . Add ( - 3 * time . Minute ) )
child := env . createShop ( t , "UR60代理下级" , phone , & root . ID , 2 , time . Now ( ) . Add ( - 2 * time . Minute ) )
env . createShop ( t , "UR60范围外店铺" , phone , nil , 1 , time . Now ( ) . Add ( - time . Minute ) )
token := env . newToken ( t , auth . TokenInfo { UserID : uniqueTestID ( ) , UserType : constants . UserTypeAgent , ShopID : root . ID , Username : "ur60-agent" } )
status , body := env . request ( t , http . MethodGet , "/api/admin/shops?contact_phone=" + phone , token )
if status != http . StatusOK {
t . Fatalf ( "代理联系电话查询失败, HTTP %d: %s" , status , body )
}
var response shopTestResponse
if err := sonic . Unmarshal ( body , & response ) ; err != nil {
t . Fatalf ( "解析代理联系电话响应失败:%v" , err )
}
if response . Data . Total != 2 {
t . Fatalf ( "代理联系电话查询应只返回自身及下级:%s" , body )
}
returned := map [ uint ] bool { }
for _ , item := range response . Data . Items {
returned [ uint ( item [ "id" ] . ( float64 ) ) ] = true
}
if ! returned [ root . ID ] || ! returned [ child . ID ] {
t . Fatalf ( "代理联系电话查询缺少范围内店铺:%s" , body )
}
}
// TestShopListDatabaseFailureIsSanitized 验证数据库错误通过统一 500 响应脱敏。
func TestShopListDatabaseFailureIsSanitized ( t * testing . T ) {
env := newShopTestEnv ( t )
token := env . newToken ( t , auth . TokenInfo { UserID : uniqueTestID ( ) , UserType : constants . UserTypeSuperAdmin , Username : "ur60-super-admin" } )
sqlDB , err := env . db . DB ( )
if err != nil {
t . Fatalf ( "获取测试数据库连接失败:%v" , err )
}
if err := sqlDB . Close ( ) ; err != nil {
t . Fatalf ( "关闭测试数据库连接失败:%v" , err )
}
status , body := env . request ( t , http . MethodGet , "/api/admin/shops?contact_phone=13800000000" , token )
if status != http . StatusInternalServerError {
t . Fatalf ( "数据库失败应返回 HTTP 500, 实际 %d: %s" , status , body )
}
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 != errors . CodeInternalError || response . Msg != "内部服务器错误" || response . Data != nil {
t . Fatalf ( "数据库错误响应不符合脱敏契约:%s" , body )
}
for _ , leaked := range [ ] string { "sql" , "postgres" , "host" , "driver" , "database is closed" } {
if strings . Contains ( strings . ToLower ( string ( body ) ) , leaked ) {
t . Fatalf ( "数据库错误响应泄露底层信息 %q: %s" , leaked , body )
}
}
}
// TestShopContactPhoneIndexDefinition 验证联系电话索引的类型、唯一性和部分条件。
func TestShopContactPhoneIndexDefinition ( t * testing . T ) {
env := newShopTestEnv ( t )
var indexCount int64
if err := env . db . Raw ( `
SELECT COUNT(*)
FROM pg_class
WHERE relname = ?
` , "idx_shop_contact_phone" ) . Scan ( & indexCount ) . Error ; err != nil {
t . Fatalf ( "查询联系电话索引数量失败:%v" , err )
}
if os . Getenv ( "UR60_EXPECT_INDEX_ABSENT" ) == "1" {
if indexCount != 0 {
t . Fatalf ( "回滚后联系电话索引仍然存在" )
}
return
}
if indexCount != 1 {
t . Fatalf ( "联系电话索引数量异常:%d" , indexCount )
}
var index struct {
AccessMethod string ` gorm:"column:access_method" `
IsUnique bool ` gorm:"column:is_unique" `
Definition string ` gorm:"column:definition" `
Predicate string ` gorm:"column:predicate" `
}
err := env . db . Raw ( `
SELECT am.amname AS access_method,
ix.indisunique AS is_unique,
pg_get_indexdef(ix.indexrelid) AS definition,
pg_get_expr(ix.indpred, ix.indrelid) AS predicate
FROM pg_index ix
JOIN pg_class i ON i.oid = ix.indexrelid
JOIN pg_am am ON am.oid = i.relam
WHERE i.relname = ?
` , "idx_shop_contact_phone" ) . Scan ( & index ) . Error
if err != nil {
t . Fatalf ( "查询联系电话索引定义失败:%v" , err )
}
if index . AccessMethod != "btree" || index . IsUnique || ! strings . Contains ( index . Definition , "contact_phone" ) || ! strings . Contains ( index . Predicate , "deleted_at IS NULL" ) {
t . Fatalf ( "联系电话索引定义不符合契约:%+v" , index )
}
}
func newShopTestEnv ( t * testing . T ) * shopTestEnv {
t . Helper ( )
if os . Getenv ( "JUNHONG_DATABASE_HOST" ) == "" || os . Getenv ( "JUNHONG_REDIS_ADDRESS" ) == "" {
@@ -286,6 +533,17 @@ func newShopTestEnv(t *testing.T) *shopTestEnv {
shopStore := postgres . NewShopStore ( db , redisClient )
service := shopService . New ( shopStore , nil , nil , nil , nil , nil )
shopCommission := shopCommissionService . New (
shopStore ,
postgres . NewAccountStore ( db , redisClient ) ,
postgres . NewAgentWalletStore ( db , redisClient ) ,
postgres . NewCommissionWithdrawalRequestStore ( db , redisClient ) ,
postgres . NewCommissionWithdrawalSettingStore ( db , redisClient ) ,
postgres . NewCommissionRecordStore ( db , redisClient ) ,
postgres . NewAgentWalletTransactionStore ( db , redisClient ) ,
db ,
logger ,
)
tokenManager := auth . NewTokenManager ( redisClient , cfg . JWT . AccessTokenTTL , cfg . JWT . RefreshTokenTTL )
authMiddleware := pkgMiddleware . Auth ( pkgMiddleware . AuthConfig {
TokenValidator : func ( token string ) ( * pkgMiddleware . UserContextInfo , error ) {
@@ -310,13 +568,35 @@ func newShopTestEnv(t *testing.T) *shopTestEnv {
ErrorHandler : internalMiddleware . ErrorHandler ( logger ) ,
} )
RegisterAdminRoutes ( app . Group ( "/api/admin" ) , & bootstrap . Handlers {
Shop : admin . NewShopHandler ( service , validator . New ( ) ) ,
ShopRole : admin . NewShopRoleHandler ( service ) ,
Shop : admin . NewShopHandler ( service , validator . New ( ) ) ,
ShopRole : admin . NewShopRoleHandler ( service ) ,
ShopCommission : admin . NewShopCommissionHandler ( shopCommission ) ,
} , & bootstrap . Middlewares { AdminAuth : authMiddleware } , nil , "/api/admin" )
return & shopTestEnv { app : app , db : db , redis : redisClient , tokenManager : tokenManager }
}
func ( e * shopTestEnv ) createShop ( t * testing . T , name , phone string , parentID * uint , level int , createdAt time . Time ) * model . Shop {
t . Helper ( )
shop := & model . Shop {
ShopName : name ,
ShopCode : fmt . Sprintf ( "UR60-%d" , time . Now ( ) . UnixNano ( ) ) ,
ParentID : parentID ,
Level : level ,
ContactPhone : phone ,
Status : constants . ShopStatusEnabled ,
}
shop . CreatedAt = createdAt
shop . UpdatedAt = createdAt
shop . Creator = uniqueTestID ( )
shop . Updater = shop . Creator
if err := e . db . Create ( shop ) . Error ; err != nil {
t . Fatalf ( "创建测试店铺失败:%v" , err )
}
t . Cleanup ( func ( ) { _ = e . db . Unscoped ( ) . Delete ( shop ) . Error } )
return shop
}
func ( e * shopTestEnv ) newToken ( t * testing . T , info auth . TokenInfo ) string {
t . Helper ( )
accessToken , refreshToken , err := e . tokenManager . GenerateTokenPair ( context . Background ( ) , & info )