xlgo-core
272 行
9.5 KiB
Go
272 行
9.5 KiB
Go
package router
|
||
|
||
import (
|
||
"context"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
|
||
"github.com/EthanCodeCraft/xlgo-core/middleware"
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
// TestApplyIdempotent_H8b 复现 H8b:修复前二次 Apply 会重复 engine.Use 并触发
|
||
// Gin 重复路由 panic;修复后二次 Apply 直接返回,无 panic、无重复中间件。
|
||
func TestApplyIdempotent_H8b(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
engine := gin.New()
|
||
r := NewRegistry(engine)
|
||
|
||
runs := 0
|
||
r.Use(func(c *gin.Context) { runs++; c.Next() })
|
||
r.RegisterModuleFunc("test", func(g *gin.RouterGroup) {
|
||
g.GET("/h8b", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||
})
|
||
|
||
r.Apply()
|
||
r.Apply() // 二次 Apply 必须无 panic
|
||
r.Apply() // 三次同样
|
||
|
||
// 幂等性由下方 runs==1 断言验证(P1 #13:applied bool 已改为 sync.Once,
|
||
// 无导出标志可查;若 Apply 非幂等则中间件会被重复装入致 runs>1)。
|
||
|
||
// 中间件只应被装入一次:请求一次,runs 应为 1(若重复装入则 >1)。
|
||
w := httptest.NewRecorder()
|
||
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/h8b", nil))
|
||
if w.Code != 200 {
|
||
t.Fatalf("status = %d, want 200", w.Code)
|
||
}
|
||
if runs != 1 {
|
||
t.Fatalf("global middleware ran %d times, want 1 (Apply not idempotent)", runs)
|
||
}
|
||
}
|
||
|
||
// TestApplyConcurrent_P1_13 验证并发 Apply 无 data race 且仅生效一次(sync.Once)。
|
||
// 修复前 applied 为裸 bool,多 goroutine 并发 Apply 竞态可重复 engine.Use/重复注册致 panic。
|
||
// 须配合 -race 运行。
|
||
func TestApplyConcurrent_P1_13(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
engine := gin.New()
|
||
r := NewRegistry(engine)
|
||
|
||
runs := 0
|
||
r.Use(func(c *gin.Context) { runs++; c.Next() })
|
||
r.RegisterModuleFunc("p113", func(g *gin.RouterGroup) {
|
||
g.GET("/p113", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||
})
|
||
|
||
var wg sync.WaitGroup
|
||
for i := 0; i < 20; i++ {
|
||
wg.Add(1)
|
||
go func() { defer wg.Done(); r.Apply() }()
|
||
}
|
||
wg.Wait()
|
||
|
||
w := httptest.NewRecorder()
|
||
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/p113", nil))
|
||
if w.Code != 200 {
|
||
t.Fatalf("status = %d, want 200", w.Code)
|
||
}
|
||
if runs != 1 {
|
||
t.Fatalf("global middleware ran %d times, want 1 (concurrent Apply not once)", runs)
|
||
}
|
||
}
|
||
|
||
// TestMetricsMiddlewareFirstInApply_H8c 验证 metrics 中间件经 SetMetricsMiddleware
|
||
// 在 Apply 内装入,覆盖所有经注册中心注册的路由,且不依赖 RegisterMetricsRoute
|
||
// 的调用顺序。修复前 RegisterMetricsRoute 用 r.Use,先注册的路由不被采集。
|
||
func TestMetricsMiddlewareFirstInApply_H8c(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
engine := gin.New()
|
||
r := NewRegistry(engine)
|
||
|
||
// 用一个计数中间件模拟 metrics 采集。
|
||
hits := 0
|
||
r.SetMetricsMiddleware(func(c *gin.Context) {
|
||
hits++
|
||
c.Next()
|
||
})
|
||
|
||
r.RegisterModuleFunc("biz", func(g *gin.RouterGroup) {
|
||
g.GET("/biz", func(c *gin.Context) { c.JSON(200, gin.H{}) })
|
||
})
|
||
// 注意:不调用 RegisterMetricsRoute,仅靠 SetMetricsMiddleware + Apply 装入。
|
||
r.Apply()
|
||
|
||
w := httptest.NewRecorder()
|
||
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/biz", nil))
|
||
if w.Code != 200 {
|
||
t.Fatalf("biz status = %d, want 200", w.Code)
|
||
}
|
||
if hits != 1 {
|
||
t.Fatalf("metrics middleware hits = %d, want 1 (route not instrumented)", hits)
|
||
}
|
||
}
|
||
|
||
// TestMetricsMiddlewareNilSkipped_H8c:未设置 metrics 中间件时 Apply 不应装入空壳。
|
||
func TestMetricsMiddlewareNilSkipped_H8c(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
engine := gin.New()
|
||
r := NewRegistry(engine)
|
||
if r.metricsMiddleware != nil {
|
||
t.Fatal("default metricsMiddleware should be nil")
|
||
}
|
||
r.Apply() // 不应 panic
|
||
}
|
||
|
||
// TestEnsureRegistryPanicsBeforeInit_H8a 复现 H8a:修复前 Init 之前调全局 helper
|
||
// 触发 nil 解引用 panic;修复后为带明确信息的 panic。
|
||
func TestEnsureRegistryPanicsBeforeInit_H8a(t *testing.T) {
|
||
// 保存并清空全局注册中心,测试后恢复,避免污染其它测试。
|
||
prev := globalRegistry.Load()
|
||
globalRegistry.Store(nil)
|
||
t.Cleanup(func() { globalRegistry.Store(prev) })
|
||
|
||
var got any
|
||
func() {
|
||
defer func() { got = recover() }()
|
||
Apply()
|
||
}()
|
||
if got == nil {
|
||
t.Fatal("Apply before Init should panic, got nil")
|
||
}
|
||
msg, ok := got.(string)
|
||
if !ok {
|
||
t.Fatalf("panic value should be string, got %T: %v", got, got)
|
||
}
|
||
if !strings.Contains(msg, "router.Init") {
|
||
t.Fatalf("panic message should mention router.Init, got %q", msg)
|
||
}
|
||
}
|
||
|
||
// TestGlobalRegistryAtomicConcurrent_H8a:并发 Init/GetRegistry 不触发 data race
|
||
// (atomic.Pointer 保护)。须配合 -race 运行。
|
||
func TestGlobalRegistryAtomicConcurrent_H8a(t *testing.T) {
|
||
prev := globalRegistry.Load()
|
||
t.Cleanup(func() { globalRegistry.Store(prev) })
|
||
|
||
var wg sync.WaitGroup
|
||
for i := 0; i < 50; i++ {
|
||
wg.Add(2)
|
||
go func() { defer wg.Done(); Init(gin.New()) }()
|
||
go func() { defer wg.Done(); _ = GetRegistry() }()
|
||
}
|
||
wg.Wait()
|
||
}
|
||
|
||
// TestHealthHandlerConvergedSchema_H8d 验证 defaultModule / RegisterHealthRoute(无 checks)
|
||
// 与 handler 风格的 /health 同 schema:200 + {"status":"ok"}。
|
||
func TestHealthHandlerConvergedSchema_H8d(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
engine := gin.New()
|
||
r := NewRegistry(engine)
|
||
r.RegisterModule(&defaultModule{})
|
||
r.Apply()
|
||
|
||
w := httptest.NewRecorder()
|
||
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil))
|
||
if w.Code != 200 {
|
||
t.Fatalf("defaultModule /health status = %d, want 200", w.Code)
|
||
}
|
||
body := w.Body.String()
|
||
if !strings.Contains(body, `"status":"ok"`) {
|
||
t.Fatalf("defaultModule /health body = %s, want {\"status\":\"ok\"}", body)
|
||
}
|
||
// 不应携带 response 业务信封字段。
|
||
if strings.Contains(body, `"code"`) || strings.Contains(body, `"data"`) {
|
||
t.Fatalf("defaultModule /health should not use response envelope, got %s", body)
|
||
}
|
||
}
|
||
|
||
// 编译期保证 middleware 包仍可独立使用(H8c 回归用)。
|
||
var _ gin.HandlerFunc = middleware.Metrics()
|
||
|
||
// TestDefaultModuleAndRegisterHealthRouteCoexist_H8dfootgun 复现 H8d 收尾 footgun:
|
||
// 修复前 WithDefaultRoutes()+WithModules(DefaultModule) 并存会触发 Gin 重复路由 panic;
|
||
// 修复后 registerGETOnce 使二者幂等共存,/health 与 /swagger 均可访问。
|
||
// 此测试模拟 app.go 的真实顺序:Register* 先注册(带 checks),defaultModule 经 Apply 后注册。
|
||
func TestDefaultModuleAndRegisterHealthRouteCoexist_H8dfootgun(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
engine := gin.New()
|
||
|
||
// 先经 Register* 注册(等价 app.go Init 中 enableHealth/enableSwagger 段)
|
||
RegisterHealthRoute(engine, HealthCheck{Name: "mysql", Check: func(context.Context) error { return nil }})
|
||
RegisterSwaggerRoutes(engine)
|
||
// 再经注册中心注册 DefaultModule(等价 app.go registry.Apply())
|
||
r := NewRegistry(engine)
|
||
r.RegisterModule(&defaultModule{})
|
||
r.Apply() // 修复前在此 panic: handlers are already registered for path '/health'
|
||
|
||
// /health 仍可访问,且首次注册(带 checks)胜出——响应含 checks 字段。
|
||
w := httptest.NewRecorder()
|
||
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil))
|
||
if w.Code != 200 {
|
||
t.Fatalf("/health status = %d, want 200", w.Code)
|
||
}
|
||
if !strings.Contains(w.Body.String(), `"mysql":"ok"`) {
|
||
t.Fatalf("first registration (with checks) should win, got %s", w.Body.String())
|
||
}
|
||
|
||
// /swagger/*any 注册存在(非 404 即说明路由已注册)
|
||
w2 := httptest.NewRecorder()
|
||
engine.ServeHTTP(w2, httptest.NewRequest(http.MethodGet, "/swagger/index.html", nil))
|
||
if w2.Code == http.StatusNotFound {
|
||
t.Fatal("/swagger/*any should be registered, got 404")
|
||
}
|
||
}
|
||
|
||
// TestRegisterHealthRouteIdempotent_H8dfootgun:RegisterHealthRoute 重复调用不 panic。
|
||
func TestRegisterHealthRouteIdempotent_H8dfootgun(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
engine := gin.New()
|
||
RegisterHealthRoute(engine, HealthCheck{Name: "a", Check: func(context.Context) error { return nil }})
|
||
RegisterHealthRoute(engine) // 重复,不 panic
|
||
RegisterHealthRoute(engine) // 三次,不 panic
|
||
|
||
w := httptest.NewRecorder()
|
||
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil))
|
||
if w.Code != 200 {
|
||
t.Fatalf("/health status = %d, want 200", w.Code)
|
||
}
|
||
}
|
||
|
||
// TestDefaultModuleOnly_H8dfootgun:仅用 defaultModule(不预先 Register*)时,
|
||
// /health 与 /swagger 仍正常注册(recover 兜底路径不影响首次注册)。
|
||
func TestDefaultModuleOnly_H8dfootgun(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
engine := gin.New()
|
||
r := NewRegistry(engine)
|
||
r.RegisterModule(&defaultModule{})
|
||
r.Apply()
|
||
|
||
w := httptest.NewRecorder()
|
||
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil))
|
||
if w.Code != 200 {
|
||
t.Fatalf("/health status = %d, want 200", w.Code)
|
||
}
|
||
if !strings.Contains(w.Body.String(), `"status":"ok"`) {
|
||
t.Fatalf("/health body = %s", w.Body.String())
|
||
}
|
||
}
|
||
|
||
// TestRegisterGETOnceEngineDoesNotSwallowRealConflict_H8dfootgun:Engine 路径经 Routes()
|
||
// 精确预检,未命中即直接注册(无 recover)。真正不同的路由冲突(如 /foo/:id 已存在再注册
|
||
// /foo/*any)仍按 gin 原语义 panic,不被掩盖——证明幂等只吞"同一 path 重复",不掩盖真实冲突。
|
||
func TestRegisterGETOnceEngineDoesNotSwallowRealConflict_H8dfootgun(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
engine := gin.New()
|
||
engine.GET("/foo/:id", func(c *gin.Context) {})
|
||
|
||
var got any
|
||
func() {
|
||
defer func() { got = recover() }()
|
||
registerGETOnce(engine, "/foo/*any", func(c *gin.Context) {}) // 与 :id 真实冲突
|
||
}()
|
||
if got == nil {
|
||
t.Fatal("registerGETOnce (Engine) should panic on real (different-path) conflict, got nil")
|
||
}
|
||
}
|
||
|