package routes import ( "context" "io" "net/http" "os" "strconv" "strings" "testing" "time" "github.com/bytedance/sonic" "github.com/go-playground/validator/v10" "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" shopService "github.com/break/junhong_cmp_fiber/internal/service/shop" "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" ) type shopTestResponse struct { Code int `json:"code"` Msg string `json:"msg"` Data shopTestPageData `json:"data"` } type shopTestPageData struct { Items []map[string]any `json:"items"` Total int64 `json:"total"` Page int `json:"page"` Size int `json:"size"` } type shopTestEnv struct { app *fiber.App db *gorm.DB redis *redis.Client tokenManager *auth.TokenManager } func TestShopListValidatesAllQueryParameters(t *testing.T) { env := newShopTestEnv(t) token := env.newToken(t, auth.TokenInfo{ UserID: uniqueTestID(), UserType: constants.UserTypeSuperAdmin, Username: "ur60-super-admin", }) testCases := []struct { name string query string }{ {name: "页码小于一", query: "page=0"}, {name: "每页数量小于一", query: "page_size=0"}, {name: "每页数量超过上限", query: "page_size=101"}, {name: "店铺层级非法", query: "level=8"}, {name: "店铺状态非法", query: "status=2"}, {name: "店铺名称过长", query: "shop_name=" + strings.Repeat("店", 101)}, {name: "店铺编号过长", query: "shop_code=" + strings.Repeat("A", 51)}, {name: "参数解析失败", query: "parent_id=invalid"}, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { status, body := env.request(t, http.MethodGet, "/api/admin/shops?"+testCase.query, token) if status != http.StatusBadRequest { t.Fatalf("期望 HTTP 400,实际为 %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.CodeInvalidParam || response.Msg != "参数验证失败" || response.Data != nil { t.Fatalf("参数错误响应不符合契约:%s", body) } if !strings.Contains(string(body), "timestamp") { t.Fatalf("参数错误响应缺少时间戳:%s", body) } if strings.Contains(string(body), "validation") || strings.Contains(string(body), "strconv") { t.Fatalf("参数错误响应泄露底层细节:%s", body) } }) } } func TestShopListUsesNormalizedAndExplicitPagination(t *testing.T) { env := newShopTestEnv(t) token := env.newToken(t, auth.TokenInfo{ UserID: uniqueTestID(), UserType: constants.UserTypeSuperAdmin, Username: "ur60-super-admin", }) testCases := []struct { name string query string expectedPage int expectedSize int }{ {name: "默认分页", expectedPage: 1, expectedSize: constants.DefaultPageSize}, {name: "显式分页", query: "?page=2&page_size=7&shop_name=ur60-no-match", expectedPage: 2, expectedSize: 7}, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { status, body := env.request(t, http.MethodGet, "/api/admin/shops"+testCase.query, token) if status != http.StatusOK { t.Fatalf("期望 HTTP 200,实际为 %d,响应:%s", status, body) } var response shopTestResponse if err := sonic.Unmarshal(body, &response); err != nil { t.Fatalf("解析响应失败:%v", err) } if response.Code != errors.CodeSuccess || response.Data.Page != testCase.expectedPage || response.Data.Size != testCase.expectedSize { t.Fatalf("分页响应不符合契约:%s", body) } }) } } func TestShopListKeepsAuthenticationContract(t *testing.T) { env := newShopTestEnv(t) testCases := []struct { name string token string }{ {name: "未认证"}, {name: "无效令牌", token: "ur60-invalid-token"}, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { status, body := env.request(t, http.MethodGet, "/api/admin/shops", testCase.token) if status != http.StatusUnauthorized { t.Fatalf("期望 HTTP 401,实际为 %d,响应:%s", status, body) } if !strings.Contains(string(body), "timestamp") { t.Fatalf("认证错误响应缺少时间戳:%s", body) } }) } } func TestEnterpriseCannotAccessCoreShopManagementRoutes(t *testing.T) { env := newShopTestEnv(t) token := env.newToken(t, auth.TokenInfo{ UserID: uniqueTestID(), UserType: constants.UserTypeEnterprise, EnterpriseID: uniqueTestID(), Username: "ur60-enterprise", }) testCases := []struct { name string method string path string }{ {name: "店铺列表", method: http.MethodGet, path: "/api/admin/shops"}, {name: "创建店铺", method: http.MethodPost, path: "/api/admin/shops"}, {name: "更新店铺", method: http.MethodPut, path: "/api/admin/shops/1"}, {name: "删除店铺", method: http.MethodDelete, path: "/api/admin/shops/1"}, {name: "联级查询", method: http.MethodGet, path: "/api/admin/shops/cascade"}, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { status, body := env.request(t, testCase.method, testCase.path, token) if status != http.StatusForbidden { t.Fatalf("期望 HTTP 403,实际为 %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.CodeForbidden || response.Msg != "无权限访问店铺管理功能" || response.Data != nil { t.Fatalf("企业账号禁止响应不符合契约:%s", body) } if !strings.Contains(string(body), "timestamp") { t.Fatalf("企业账号禁止响应缺少时间戳:%s", body) } }) } } func TestCoreShopRestrictionDoesNotAffectOtherShopRoutes(t *testing.T) { env := newShopTestEnv(t) token := env.newToken(t, auth.TokenInfo{ UserID: uniqueTestID(), UserType: constants.UserTypeEnterprise, EnterpriseID: uniqueTestID(), 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) } } func TestNonEnterpriseAccountsKeepCoreShopListAccess(t *testing.T) { env := newShopTestEnv(t) testCases := []struct { name string userType int shopID uint }{ {name: "超级管理员", userType: constants.UserTypeSuperAdmin}, {name: "平台账号", userType: constants.UserTypePlatform}, {name: "代理账号", userType: constants.UserTypeAgent, shopID: 1}, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { token := env.newToken(t, auth.TokenInfo{ UserID: uniqueTestID(), UserType: testCase.userType, ShopID: testCase.shopID, Username: "ur60-allowed-user", }) status, body := env.request(t, http.MethodGet, "/api/admin/shops?page=1&page_size=1", token) if status != http.StatusOK { t.Fatalf("期望保留列表访问能力,实际 HTTP %d,响应:%s", status, body) } }) } } func newShopTestEnv(t *testing.T) *shopTestEnv { 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) } logger := zap.NewNop() db, err := database.InitPostgreSQL(&cfg.Database, logger) if err != nil { t.Fatalf("连接 PostgreSQL 失败:%v", err) } 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, }, logger) if err != nil { t.Fatalf("连接 Redis 失败:%v", err) } t.Cleanup(func() { _ = redisClient.Close() if sqlDB, dbErr := db.DB(); dbErr == nil { _ = sqlDB.Close() } }) shopStore := postgres.NewShopStore(db, redisClient) service := shopService.New(shopStore, nil, nil, nil, nil, nil) 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, EnterpriseID: info.EnterpriseID, }, nil }, ShopStore: shopStore, }) app := fiber.New(fiber.Config{ JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, ErrorHandler: internalMiddleware.ErrorHandler(logger), }) RegisterAdminRoutes(app.Group("/api/admin"), &bootstrap.Handlers{ Shop: admin.NewShopHandler(service, validator.New()), ShopRole: admin.NewShopRoleHandler(service), }, &bootstrap.Middlewares{AdminAuth: authMiddleware}, nil, "/api/admin") return &shopTestEnv{app: app, db: db, redis: redisClient, tokenManager: tokenManager} } func (e *shopTestEnv) newToken(t *testing.T, info auth.TokenInfo) string { t.Helper() 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 *shopTestEnv) request(t *testing.T, method, path, token string) (int, []byte) { t.Helper() request, err := http.NewRequest(method, path, nil) if err != nil { t.Fatalf("创建 HTTP 请求失败:%v", err) } if token != "" { 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 } func uniqueTestID() uint { return uint(time.Now().UnixNano() % 1_000_000_000) }