Files
junhong_cmp_fiber/internal/routes/shop_integration_test.go

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