638 lines
23 KiB
Go
638 lines
23 KiB
Go
package routes
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
"sync/atomic"
|
||
"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"
|
||
"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"
|
||
"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
|
||
}
|
||
|
||
// TestShopListValidatesAllQueryParameters 验证店铺列表会完整校验所有查询参数。
|
||
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)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestShopListUsesNormalizedAndExplicitPagination 验证默认分页归一化且显式分页保持不变。
|
||
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: constants.DefaultPage, 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)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestShopListKeepsAuthenticationContract 验证店铺列表保持既有认证错误契约。
|
||
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)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestEnterpriseCannotAccessCoreShopManagementRoutes 验证企业账号无法访问五个核心店铺管理入口。
|
||
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 != constants.ShopManagementForbiddenMessage || response.Data != nil {
|
||
t.Fatalf("企业账号禁止响应不符合契约:%s", body)
|
||
}
|
||
if !strings.Contains(string(body), "timestamp") {
|
||
t.Fatalf("企业账号禁止响应缺少时间戳:%s", body)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestCoreShopRestrictionDoesNotAffectOtherShopRoutes 验证核心店铺权限限制不影响其他店铺前缀路由。
|
||
func TestCoreShopRestrictionDoesNotAffectOtherShopRoutes(t *testing.T) {
|
||
env := newShopTestEnv(t)
|
||
token := env.newToken(t, auth.TokenInfo{
|
||
UserID: uniqueTestID(),
|
||
UserType: constants.UserTypeEnterprise,
|
||
EnterpriseID: uniqueTestID(),
|
||
Username: "ur60-enterprise",
|
||
})
|
||
|
||
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 {
|
||
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)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// 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", "13800000000",
|
||
}
|
||
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") == "" {
|
||
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)
|
||
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) {
|
||
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),
|
||
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)
|
||
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)
|
||
}
|