feat: 添加应用骨架,配置化 zap 日志贯穿全链路
- cmd/server/main.go: 使用 logger.NewLogger(cfg.Log) 替代 zap.NewProduction() - internal/app: 依赖注入组装 DB/Slurm/Service/Handler,传递 logger - internal/middleware: RequestLogger 请求日志中间件 - internal/server: 统一响应格式和路由注册 - go.mod: module 更名为 gcy_hpc_server,添加 gin/zap/lumberjack/gorm 依赖 - 日志初始化失败时 fail fast (os.Exit(1)) - GormLevel 从配置传递到 NewGormDB,支持 YAML 独立配置 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
159
internal/app/app.go
Normal file
159
internal/app/app.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gcy_hpc_server/internal/config"
|
||||
"gcy_hpc_server/internal/handler"
|
||||
"gcy_hpc_server/internal/server"
|
||||
"gcy_hpc_server/internal/service"
|
||||
"gcy_hpc_server/internal/slurm"
|
||||
"gcy_hpc_server/internal/store"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// App encapsulates the entire application lifecycle.
|
||||
type App struct {
|
||||
cfg *config.Config
|
||||
logger *zap.Logger
|
||||
db *gorm.DB
|
||||
server *http.Server
|
||||
}
|
||||
|
||||
// NewApp initializes all application dependencies: DB, Slurm client, services, handlers, router.
|
||||
func NewApp(cfg *config.Config, logger *zap.Logger) (*App, error) {
|
||||
gormDB, err := initDB(cfg, logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
slurmClient, err := initSlurmClient(cfg)
|
||||
if err != nil {
|
||||
closeDB(gormDB)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
srv := initHTTPServer(cfg, gormDB, slurmClient, logger)
|
||||
|
||||
return &App{
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
db: gormDB,
|
||||
server: srv,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Run starts the HTTP server and blocks until a shutdown signal or server error.
|
||||
func (a *App) Run() error {
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
a.logger.Info("starting server", zap.String("addr", a.server.Addr))
|
||||
if err := a.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
errCh <- fmt.Errorf("server listen: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
// Server crashed before receiving a signal — clean up resources before
|
||||
// returning, because the caller may call os.Exit and skip deferred Close().
|
||||
a.logger.Error("server exited unexpectedly", zap.Error(err))
|
||||
_ = a.Close()
|
||||
return err
|
||||
case sig := <-quit:
|
||||
a.logger.Info("received shutdown signal", zap.String("signal", sig.String()))
|
||||
}
|
||||
|
||||
a.logger.Info("shutting down server...")
|
||||
return a.Close()
|
||||
}
|
||||
|
||||
// Close cleans up all resources: HTTP server and database connections.
|
||||
func (a *App) Close() error {
|
||||
var errs []error
|
||||
|
||||
if a.server != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := a.server.Shutdown(ctx); err != nil && err != http.ErrServerClosed {
|
||||
errs = append(errs, fmt.Errorf("shutdown http server: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
if a.db != nil {
|
||||
sqlDB, err := a.db.DB()
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("get underlying sql.DB: %w", err))
|
||||
} else if err := sqlDB.Close(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("close database: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initialization helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func initDB(cfg *config.Config, logger *zap.Logger) (*gorm.DB, error) {
|
||||
gormDB, err := store.NewGormDB(cfg.MySQLDSN, logger, cfg.Log.GormLevel)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("init database: %w", err)
|
||||
}
|
||||
|
||||
if err := store.AutoMigrate(gormDB); err != nil {
|
||||
closeDB(gormDB)
|
||||
return nil, fmt.Errorf("run migrations: %w", err)
|
||||
}
|
||||
|
||||
return gormDB, nil
|
||||
}
|
||||
|
||||
func closeDB(db *gorm.DB) {
|
||||
if db == nil {
|
||||
return
|
||||
}
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func initSlurmClient(cfg *config.Config) (*slurm.Client, error) {
|
||||
client, err := service.NewSlurmClient(cfg.SlurmAPIURL, cfg.SlurmUserName, cfg.SlurmJWTKeyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("init slurm client: %w", err)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func initHTTPServer(cfg *config.Config, db *gorm.DB, slurmClient *slurm.Client, logger *zap.Logger) *http.Server {
|
||||
jobSvc := service.NewJobService(slurmClient, logger)
|
||||
clusterSvc := service.NewClusterService(slurmClient, logger)
|
||||
templateStore := store.NewTemplateStore(db)
|
||||
|
||||
jobH := handler.NewJobHandler(jobSvc, logger)
|
||||
clusterH := handler.NewClusterHandler(clusterSvc, logger)
|
||||
templateH := handler.NewTemplateHandler(templateStore, logger)
|
||||
|
||||
router := server.NewRouter(jobH, clusterH, templateH, logger)
|
||||
|
||||
addr := ":" + cfg.ServerPort
|
||||
|
||||
return &http.Server{
|
||||
Addr: addr,
|
||||
Handler: router,
|
||||
}
|
||||
}
|
||||
25
internal/app/app_test.go
Normal file
25
internal/app/app_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gcy_hpc_server/internal/config"
|
||||
|
||||
"go.uber.org/zap/zaptest"
|
||||
)
|
||||
|
||||
func TestNewApp_InvalidDB(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ServerPort: "8080",
|
||||
MySQLDSN: "invalid:dsn@tcp(localhost:99999)/nonexistent?parseTime=true",
|
||||
SlurmAPIURL: "http://localhost:6820",
|
||||
SlurmUserName: "root",
|
||||
SlurmJWTKeyPath: "/nonexistent/jwt.key",
|
||||
}
|
||||
logger := zaptest.NewLogger(t)
|
||||
|
||||
_, err := NewApp(cfg, logger)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid DSN, got nil")
|
||||
}
|
||||
}
|
||||
25
internal/middleware/logger.go
Normal file
25
internal/middleware/logger.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// RequestLogger returns a Gin middleware that logs each request using zap.
|
||||
func RequestLogger(logger *zap.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
|
||||
c.Next()
|
||||
|
||||
logger.Info("request",
|
||||
zap.String("method", c.Request.Method),
|
||||
zap.String("path", c.Request.URL.Path),
|
||||
zap.Int("status", c.Writer.Status()),
|
||||
zap.Duration("latency", time.Since(start)),
|
||||
zap.String("client_ip", c.ClientIP()),
|
||||
)
|
||||
}
|
||||
}
|
||||
44
internal/server/response.go
Normal file
44
internal/server/response.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// APIResponse represents the unified JSON structure for all API responses.
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// OK responds with 200 and success data.
|
||||
func OK(c *gin.Context, data interface{}) {
|
||||
c.JSON(http.StatusOK, APIResponse{Success: true, Data: data})
|
||||
}
|
||||
|
||||
// Created responds with 201 and success data.
|
||||
func Created(c *gin.Context, data interface{}) {
|
||||
c.JSON(http.StatusCreated, APIResponse{Success: true, Data: data})
|
||||
}
|
||||
|
||||
// BadRequest responds with 400 and an error message.
|
||||
func BadRequest(c *gin.Context, msg string) {
|
||||
c.JSON(http.StatusBadRequest, APIResponse{Success: false, Error: msg})
|
||||
}
|
||||
|
||||
// NotFound responds with 404 and an error message.
|
||||
func NotFound(c *gin.Context, msg string) {
|
||||
c.JSON(http.StatusNotFound, APIResponse{Success: false, Error: msg})
|
||||
}
|
||||
|
||||
// InternalError responds with 500 and an error message.
|
||||
func InternalError(c *gin.Context, msg string) {
|
||||
c.JSON(http.StatusInternalServerError, APIResponse{Success: false, Error: msg})
|
||||
}
|
||||
|
||||
// ErrorWithStatus responds with a custom status code and an error message.
|
||||
func ErrorWithStatus(c *gin.Context, code int, msg string) {
|
||||
c.JSON(code, APIResponse{Success: false, Error: msg})
|
||||
}
|
||||
116
internal/server/response_test.go
Normal file
116
internal/server/response_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func setupTestContext() (*httptest.ResponseRecorder, *gin.Context) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
return w, c
|
||||
}
|
||||
|
||||
func parseResponse(t *testing.T, w *httptest.ResponseRecorder) APIResponse {
|
||||
t.Helper()
|
||||
var resp APIResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response body: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestOK(t *testing.T) {
|
||||
w, c := setupTestContext()
|
||||
OK(c, map[string]string{"msg": "hello"})
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
resp := parseResponse(t, w)
|
||||
if !resp.Success {
|
||||
t.Fatal("expected success=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreated(t *testing.T) {
|
||||
w, c := setupTestContext()
|
||||
Created(c, map[string]int{"id": 1})
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d", w.Code)
|
||||
}
|
||||
resp := parseResponse(t, w)
|
||||
if !resp.Success {
|
||||
t.Fatal("expected success=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadRequest(t *testing.T) {
|
||||
w, c := setupTestContext()
|
||||
BadRequest(c, "invalid input")
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
resp := parseResponse(t, w)
|
||||
if resp.Success {
|
||||
t.Fatal("expected success=false")
|
||||
}
|
||||
if resp.Error != "invalid input" {
|
||||
t.Fatalf("expected error 'invalid input', got '%s'", resp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotFound(t *testing.T) {
|
||||
w, c := setupTestContext()
|
||||
NotFound(c, "resource missing")
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", w.Code)
|
||||
}
|
||||
resp := parseResponse(t, w)
|
||||
if resp.Success {
|
||||
t.Fatal("expected success=false")
|
||||
}
|
||||
if resp.Error != "resource missing" {
|
||||
t.Fatalf("expected error 'resource missing', got '%s'", resp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalError(t *testing.T) {
|
||||
w, c := setupTestContext()
|
||||
InternalError(c, "something broke")
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d", w.Code)
|
||||
}
|
||||
resp := parseResponse(t, w)
|
||||
if resp.Success {
|
||||
t.Fatal("expected success=false")
|
||||
}
|
||||
if resp.Error != "something broke" {
|
||||
t.Fatalf("expected error 'something broke', got '%s'", resp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorWithStatus(t *testing.T) {
|
||||
w, c := setupTestContext()
|
||||
ErrorWithStatus(c, http.StatusConflict, "already exists")
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("expected 409, got %d", w.Code)
|
||||
}
|
||||
resp := parseResponse(t, w)
|
||||
if resp.Success {
|
||||
t.Fatal("expected success=false")
|
||||
}
|
||||
if resp.Error != "already exists" {
|
||||
t.Fatalf("expected error 'already exists', got '%s'", resp.Error)
|
||||
}
|
||||
}
|
||||
111
internal/server/server.go
Normal file
111
internal/server/server.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gcy_hpc_server/internal/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type JobHandler interface {
|
||||
SubmitJob(c *gin.Context)
|
||||
GetJobs(c *gin.Context)
|
||||
GetJobHistory(c *gin.Context)
|
||||
GetJob(c *gin.Context)
|
||||
CancelJob(c *gin.Context)
|
||||
}
|
||||
|
||||
type ClusterHandler interface {
|
||||
GetNodes(c *gin.Context)
|
||||
GetNode(c *gin.Context)
|
||||
GetPartitions(c *gin.Context)
|
||||
GetPartition(c *gin.Context)
|
||||
GetDiag(c *gin.Context)
|
||||
}
|
||||
|
||||
type TemplateHandler interface {
|
||||
ListTemplates(c *gin.Context)
|
||||
CreateTemplate(c *gin.Context)
|
||||
GetTemplate(c *gin.Context)
|
||||
UpdateTemplate(c *gin.Context)
|
||||
DeleteTemplate(c *gin.Context)
|
||||
}
|
||||
|
||||
// NewRouter creates a Gin engine with all API v1 routes registered with real handlers.
|
||||
func NewRouter(jobH JobHandler, clusterH ClusterHandler, templateH TemplateHandler, logger *zap.Logger) *gin.Engine {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
if logger != nil {
|
||||
r.Use(middleware.RequestLogger(logger))
|
||||
}
|
||||
|
||||
v1 := r.Group("/api/v1")
|
||||
|
||||
jobs := v1.Group("/jobs")
|
||||
jobs.POST("/submit", jobH.SubmitJob)
|
||||
jobs.GET("", jobH.GetJobs)
|
||||
jobs.GET("/history", jobH.GetJobHistory)
|
||||
jobs.GET("/:id", jobH.GetJob)
|
||||
jobs.DELETE("/:id", jobH.CancelJob)
|
||||
|
||||
v1.GET("/nodes", clusterH.GetNodes)
|
||||
v1.GET("/nodes/:name", clusterH.GetNode)
|
||||
|
||||
v1.GET("/partitions", clusterH.GetPartitions)
|
||||
v1.GET("/partitions/:name", clusterH.GetPartition)
|
||||
|
||||
v1.GET("/diag", clusterH.GetDiag)
|
||||
|
||||
templates := v1.Group("/templates")
|
||||
templates.GET("", templateH.ListTemplates)
|
||||
templates.POST("", templateH.CreateTemplate)
|
||||
templates.GET("/:id", templateH.GetTemplate)
|
||||
templates.PUT("/:id", templateH.UpdateTemplate)
|
||||
templates.DELETE("/:id", templateH.DeleteTemplate)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// NewTestRouter creates a router for testing without real handlers.
|
||||
func NewTestRouter() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
v1 := r.Group("/api/v1")
|
||||
registerPlaceholderRoutes(v1)
|
||||
return r
|
||||
}
|
||||
|
||||
func registerPlaceholderRoutes(v1 *gin.RouterGroup) {
|
||||
jobs := v1.Group("/jobs")
|
||||
jobs.POST("/submit", notImplemented)
|
||||
jobs.GET("", notImplemented)
|
||||
jobs.GET("/history", notImplemented)
|
||||
jobs.GET("/:id", notImplemented)
|
||||
jobs.DELETE("/:id", notImplemented)
|
||||
|
||||
v1.GET("/nodes", notImplemented)
|
||||
v1.GET("/nodes/:name", notImplemented)
|
||||
|
||||
v1.GET("/partitions", notImplemented)
|
||||
v1.GET("/partitions/:name", notImplemented)
|
||||
|
||||
v1.GET("/diag", notImplemented)
|
||||
|
||||
templates := v1.Group("/templates")
|
||||
templates.GET("", notImplemented)
|
||||
templates.POST("", notImplemented)
|
||||
templates.GET("/:id", notImplemented)
|
||||
templates.PUT("/:id", notImplemented)
|
||||
templates.DELETE("/:id", notImplemented)
|
||||
}
|
||||
|
||||
func notImplemented(c *gin.Context) {
|
||||
c.JSON(http.StatusNotImplemented, APIResponse{
|
||||
Success: false,
|
||||
Error: "not implemented",
|
||||
})
|
||||
}
|
||||
108
internal/server/server_test.go
Normal file
108
internal/server/server_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestAllRoutesRegistered(t *testing.T) {
|
||||
r := NewTestRouter()
|
||||
routes := r.Routes()
|
||||
|
||||
expected := []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{"POST", "/api/v1/jobs/submit"},
|
||||
{"GET", "/api/v1/jobs"},
|
||||
{"GET", "/api/v1/jobs/history"},
|
||||
{"GET", "/api/v1/jobs/:id"},
|
||||
{"DELETE", "/api/v1/jobs/:id"},
|
||||
{"GET", "/api/v1/nodes"},
|
||||
{"GET", "/api/v1/nodes/:name"},
|
||||
{"GET", "/api/v1/partitions"},
|
||||
{"GET", "/api/v1/partitions/:name"},
|
||||
{"GET", "/api/v1/diag"},
|
||||
{"GET", "/api/v1/templates"},
|
||||
{"POST", "/api/v1/templates"},
|
||||
{"GET", "/api/v1/templates/:id"},
|
||||
{"PUT", "/api/v1/templates/:id"},
|
||||
{"DELETE", "/api/v1/templates/:id"},
|
||||
}
|
||||
|
||||
routeMap := map[string]bool{}
|
||||
for _, route := range routes {
|
||||
key := route.Method + " " + route.Path
|
||||
routeMap[key] = true
|
||||
}
|
||||
|
||||
for _, exp := range expected {
|
||||
key := exp.method + " " + exp.path
|
||||
if !routeMap[key] {
|
||||
t.Errorf("missing route: %s", key)
|
||||
}
|
||||
}
|
||||
|
||||
if len(routes) < len(expected) {
|
||||
t.Errorf("expected at least %d routes, got %d", len(expected), len(routes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnregisteredPathReturns404(t *testing.T) {
|
||||
r := NewTestRouter()
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/v1/nonexistent", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 for unregistered path, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisteredPathReturns501(t *testing.T) {
|
||||
r := NewTestRouter()
|
||||
|
||||
endpoints := []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{"GET", "/api/v1/jobs"},
|
||||
{"GET", "/api/v1/nodes"},
|
||||
{"GET", "/api/v1/partitions"},
|
||||
{"GET", "/api/v1/diag"},
|
||||
{"GET", "/api/v1/templates"},
|
||||
}
|
||||
|
||||
for _, ep := range endpoints {
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(ep.method, ep.path, nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotImplemented {
|
||||
t.Fatalf("%s %s: expected 501, got %d", ep.method, ep.path, w.Code)
|
||||
}
|
||||
|
||||
var resp APIResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if resp.Success {
|
||||
t.Fatal("expected success=false")
|
||||
}
|
||||
if resp.Error != "not implemented" {
|
||||
t.Fatalf("expected error 'not implemented', got '%s'", resp.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterUsesGinMode(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := NewTestRouter()
|
||||
if r == nil {
|
||||
t.Fatal("NewRouter returned nil")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user