Files
2026-07-14 10:24:10 +08:00

362 lines
11 KiB
Go
Raw Permalink 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 xlgo_test
import (
"context"
"errors"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/router"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func testConfig(port int) *config.Config {
return &config.Config{
App: config.AppConfig{Env: "test"},
Server: config.ServerConfig{Port: port, Mode: "development"},
Log: config.LogConfig{Dir: filepath.Join(os.TempDir(), "xlgo_app_test_logs"), MaxSize: 1, MaxBackups: 1, MaxAge: 1},
}
}
func writeConfig(t *testing.T, name string, port int) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, name)
content := "app:\n env: test\nserver:\n port: " + strconv.Itoa(port) + "\n mode: development\nlog:\n dir: " + filepath.ToSlash(filepath.Join(dir, "logs")) + "\n max_size: 1\n max_backups: 1\n max_age: 1\n"
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("write config: %v", err)
}
return path
}
func TestAppWithConfigPath(t *testing.T) {
path := writeConfig(t, "config.yaml", 18081)
app := xlgo.New(xlgo.WithConfigPath(path))
if err := app.Init(); err != nil {
t.Fatalf("Init error: %v", err)
}
if app.GetServer() != nil {
t.Fatal("Init should not start server")
}
}
func TestAppWithConfigNoDefaultRoutes(t *testing.T) {
app := xlgo.New(xlgo.WithConfig(testConfig(18082)))
if err := app.Init(); err != nil {
t.Fatalf("Init error: %v", err)
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/health", nil)
app.GetRouter().ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected /health 404 without health routes, got %d", w.Code)
}
}
func TestAppWithHealthRoutes(t *testing.T) {
app := xlgo.New(xlgo.WithConfig(testConfig(18083)), xlgo.WithHealthRoutes())
if err := app.Init(); err != nil {
t.Fatalf("Init error: %v", err)
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/health", nil)
app.GetRouter().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected /health 200, got %d", w.Code)
}
}
func TestAppWithHealthCheckFailure(t *testing.T) {
app := xlgo.New(
xlgo.WithConfig(testConfig(18084)),
xlgo.WithHealthRoutes(),
xlgo.WithHealthCheck("custom", func(_ context.Context) error { return errors.New("down") }),
)
if err := app.Init(); err != nil {
t.Fatalf("Init error: %v", err)
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/health", nil)
app.GetRouter().ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected /health 503, got %d", w.Code)
}
}
func TestAppMigratorRequiresMySQL(t *testing.T) {
app := xlgo.New(
xlgo.WithConfig(testConfig(18085)),
xlgo.WithMigrator(func(_ *gorm.DB) error { return nil }),
)
err := app.Init()
if err == nil || !strings.Contains(err.Error(), "未启用 MySQL") {
t.Fatalf("expected mysql disabled migrator error, got %v", err)
}
}
func TestAppRoutesModule(t *testing.T) {
app := xlgo.New(
xlgo.WithConfig(testConfig(18086)),
xlgo.WithModules(router.ModuleFunc(func(r *gin.RouterGroup) {
r.GET("/hello", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"hello": "world"}) })
})),
)
if err := app.Init(); err != nil {
t.Fatalf("Init error: %v", err)
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/hello", nil)
app.GetRouter().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected module route 200, got %d", w.Code)
}
}
func TestAppWithoutDefaultRoutesOverrides(t *testing.T) {
app := xlgo.New(
xlgo.WithConfig(testConfig(18087)),
xlgo.WithDefaultRoutes(),
xlgo.WithoutDefaultRoutes(),
)
if err := app.Init(); err != nil {
t.Fatalf("Init error: %v", err)
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/health", nil)
app.GetRouter().ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected /health 404 after WithoutDefaultRoutes, got %d", w.Code)
}
}
// TestAppWithSwaggerRoutesOnly 验证可以单独启用 Swagger 而不开启 health。
func TestAppWithSwaggerRoutesOnly(t *testing.T) {
app := xlgo.New(
xlgo.WithConfig(testConfig(18088)),
xlgo.WithSwaggerRoutes(),
)
if err := app.Init(); err != nil {
t.Fatalf("Init error: %v", err)
}
// /health 不应注册
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/health", nil)
app.GetRouter().ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected /health 404 when only Swagger enabled, got %d", w.Code)
}
// /swagger/* 应注册(具体子路径返回什么取决于 swag 文档是否生成,这里只验证未 404)
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/swagger/index.html", nil)
app.GetRouter().ServeHTTP(w, req)
if w.Code == http.StatusNotFound {
t.Fatalf("expected /swagger/index.html to be registered, got 404")
}
}
// TestAppWithoutAutoMigrateOverride 验证 WithoutAutoMigrate 能覆盖
// 之前通过 WithMigrator 隐式开启的迁移,不再要求 MySQL 已启用。
func TestAppWithoutAutoMigrateOverride(t *testing.T) {
migratorCalled := false
app := xlgo.New(
xlgo.WithConfig(testConfig(18089)),
xlgo.WithMigrator(func(_ *gorm.DB) error {
migratorCalled = true
return nil
}),
xlgo.WithoutAutoMigrate(),
)
if err := app.Init(); err != nil {
t.Fatalf("Init should succeed when AutoMigrate is disabled, got %v", err)
}
if migratorCalled {
t.Fatal("migrator must not be called when WithoutAutoMigrate is applied")
}
}
// TestAppDefaultsLightweight 验证 v1.0.2 起 xlgo.New 默认是轻量应用:
// 不启 MySQL/Redis/Storage、不注册 health/swagger 路由。
func TestAppDefaultsLightweight(t *testing.T) {
app := xlgo.New(xlgo.WithConfig(testConfig(18090)))
if err := app.Init(); err != nil {
t.Fatalf("Init error: %v", err)
}
r := app.GetRouter()
for _, path := range []string{"/health", "/swagger/index.html"} {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected %s 404 by default, got %d", path, w.Code)
}
}
// 默认未注册 MySQL/Redis 健康检查项,故 healthChecks 应为空
// 这里通过启用 health 路由后只返回 status:ok 来间接验证
app2 := xlgo.New(xlgo.WithConfig(testConfig(18091)), xlgo.WithHealthRoutes())
if err := app2.Init(); err != nil {
t.Fatalf("Init error: %v", err)
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/health", nil)
app2.GetRouter().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected /health 200 with no checks, got %d", w.Code)
}
}
// TestAppWithConfigPathDrivesManager 验证 WithConfigPath 真正驱动 App 自己的
// config.Manager,并把它推到全局 default,使 config.Get 能取到加载后的配置。
func TestAppWithConfigPathDrivesManager(t *testing.T) {
path := writeConfig(t, "config_drive.yaml", 18092)
app := xlgo.New(xlgo.WithConfigPath(path))
if err := app.Init(); err != nil {
t.Fatalf("Init error: %v", err)
}
cfg := config.Get()
if cfg == nil {
t.Fatal("config.Get returned nil after WithConfigPath; manager not promoted to default")
}
if cfg.Server.Port != 18092 {
t.Fatalf("expected port 18092 from loaded config, got %d", cfg.Server.Port)
}
if config.GetInt("server.port") != 18092 {
t.Fatalf("expected GetInt(server.port)=18092, got %d", config.GetInt("server.port"))
}
}
// TestAppMetricsInstrumentsRegistryRoutes_H8c 端到端验证 H8c:经注册中心注册的业务路由
// 被指标中间件采集,不依赖 RegisterMetricsRoute 的调用顺序。修复前 RegisterMetricsRoute
// 用 r.Use 仅采集其后注册的路由;修复后采集中间件在 Apply 内作首个全局中间件装入。
func TestAppMetricsInstrumentsRegistryRoutes_H8c(t *testing.T) {
app := xlgo.New(
xlgo.WithConfig(testConfig(18093)),
xlgo.WithMetricsRoute(),
xlgo.WithModules(router.ModuleFunc(func(r *gin.RouterGroup) {
r.GET("/h8c-biz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) })
})),
)
if err := app.Init(); err != nil {
t.Fatalf("Init error: %v", err)
}
r := app.GetRouter()
for i := 0; i < 2; i++ {
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/h8c-biz", nil))
if w.Code != http.StatusOK {
t.Fatalf("/h8c-biz status = %d, want 200", w.Code)
}
}
// 拉取 /metrics,断言 /h8c-biz 路由被采集(route 标签存在)。
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/metrics", nil))
if w.Code != http.StatusOK {
t.Fatalf("/metrics status = %d, want 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, `route="/h8c-biz"`) {
t.Fatalf("metrics output should contain route=\"/h8c-biz\" series, got:\n%s", body)
}
}
// freePort 返回一个当前空闲的 TCP 端口。
func freePort(t *testing.T) int {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("freePort: %v", err)
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port
}
// TestAppOnReadyFailureReleasesPort H-1 回归:OnReady 失败时 Run 应返回错误,
// 且 HTTP 端口被释放(监听 goroutine 不泄漏)。
// 修复前 OnReady 失败直接 return,监听 goroutine 仍阻塞在 ListenAndServe
// 端口不释放、goroutine 泄漏。
func TestAppOnReadyFailureReleasesPort(t *testing.T) {
port := freePort(t)
app := xlgo.New(
xlgo.WithConfig(testConfig(port)),
xlgo.WithHook(xlgo.Hook{
Name: "fail-on-ready",
OnReady: func(*xlgo.App) error { return errors.New("boom") },
}),
)
errCh := make(chan error, 1)
go func() { errCh <- app.Run() }()
select {
case err := <-errCh:
if err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("expected OnReady failure error, got %v", err)
}
case <-time.After(10 * time.Second):
t.Fatal("Run did not return after OnReady failure (server goroutine leaked?)")
}
// 端口应已释放:能重新 bind。容忍 TIME_WAIT 短暂残留。
deadline := time.Now().Add(3 * time.Second)
for {
l, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(port))
if err == nil {
l.Close()
break
}
if time.Now().After(deadline) {
t.Fatalf("port %d not released 3s after Run returned: %v (server goroutine leaked)", port, err)
}
time.Sleep(50 * time.Millisecond)
}
}
// TestAppInitFailureCancelsGoGoroutine H-2 回归:App.Go 启动的 goroutine 在 Init 失败后
// 应因 rootCtx cancel 而退出,不泄漏。
// 修复前 Init 失败不 cancel rootCtxgoroutine 永久阻塞在 ctx.Done()。
func TestAppInitFailureCancelsGoGoroutine(t *testing.T) {
app := xlgo.New(
xlgo.WithConfig(testConfig(18087)),
// WithMigrator 无 WithMySQL → Init 确定性失败("未启用 MySQL"
xlgo.WithMigrator(func(_ *gorm.DB) error { return nil }),
)
ctxDone := make(chan struct{})
app.Go(func(ctx context.Context) {
<-ctx.Done()
close(ctxDone)
})
if err := app.Init(); err == nil {
t.Fatal("expected Init error")
}
select {
case <-ctxDone:
// 好:goroutine 在 Init 失败后退出
case <-time.After(5 * time.Second):
t.Fatal("App.Go goroutine did not exit after Init failure (rootCtx not cancelled, leaked)")
}
}