package router import ( "bytes" "log" "net/http/httptest" "strings" "testing" "affiliate_dash/internal/handler" "affiliate_dash/internal/model" "affiliate_dash/internal/pkg/applog" "affiliate_dash/internal/pkg/jwt" "affiliate_dash/internal/pkg/openlog" "affiliate_dash/internal/service" "affiliate_dash/internal/testdb" "github.com/gin-gonic/gin" ) // newTestRouter 构建一个接好全部依赖的测试路由。 func newTestRouter(t *testing.T) (*gin.Engine, *jwt.Manager) { t.Helper() gin.SetMode(gin.TestMode) db := testdb.New(t) codec, err := service.NewSecretCodec("test-master-key") if err != nil { t.Fatalf("codec: %v", err) } jm := jwt.NewManager("test-jwt") tenantSvc := service.NewTenantService(db) authSvc := service.NewAuthService(db, jm, tenantSvc) userSvc := service.NewUserService(db, tenantSvc) callbackSvc := service.NewCallbackService(db, codec) fulfillmentSvc := service.NewFulfillmentService(db, callbackSvc) merchantSvc := service.NewMerchantService(db, codec, tenantSvc) rechargeSvc := service.NewRechargeService(db, fulfillmentSvc, "") deliverySvc := service.NewDeliveryService(fulfillmentSvc, "", "", "", "", 0) engine := Setup(&Handlers{ Auth: handler.NewAuthHandler(authSvc), Dashboard: handler.NewDashboardHandler(fulfillmentSvc), User: handler.NewUserHandler(userSvc), Open: handler.NewOpenV1Handler(merchantSvc, fulfillmentSvc, deliverySvc), SourceOpen: handler.NewOpenHandler(fulfillmentSvc), Merchant: handler.NewMerchantHandler(merchantSvc, fulfillmentSvc, callbackSvc, deliverySvc, rechargeSvc), Recharge: handler.NewRechargeHandler(rechargeSvc), JWT: jm, Tenant: tenantSvc, OpenDB: db, SecretCodec: codec, OpenAPIKey: "source-key", OpenAPISecret: "source-secret", OpenSignSkew: 300, }) return engine, jm } func TestSetupDoesNotPanic(t *testing.T) { defer func() { if recovered := recover(); recovered != nil { t.Fatalf("setup should not panic: %v", recovered) } }() _, _ = newTestRouter(t) } // TestPlatformRoutesIgnoreStaleMerchantContext 卡住一个会把整个管理端锁死的场景: // 平台路由一旦挂上 Tenant,管理员 localStorage 里残留一个已不存在/已禁用的商户 ID 时, // 所有 /api/platform/* 请求都会被 403,连"去改回来"的页面都打不开。 // 因此平台分组只挂 Auth + RequireRole,不解析商户上下文。 func TestPlatformRoutesIgnoreStaleMerchantContext(t *testing.T) { engine, jm := newTestRouter(t) token, err := jm.Generate(1, "admin", model.RoleAdmin) if err != nil { t.Fatalf("generate token: %v", err) } cases := []struct { name string merchantHeader string }{ {name: "无商户上下文", merchantHeader: ""}, {name: "残留不存在的商户", merchantHeader: "999999"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest("GET", "/api/platform/merchants", nil) req.Header.Set("Authorization", "Bearer "+token) if tc.merchantHeader != "" { req.Header.Set("X-Merchant-ID", tc.merchantHeader) } w := httptest.NewRecorder() engine.ServeHTTP(w, req) if w.Code != 200 { t.Fatalf("平台路由必须不受残留商户上下文影响,期望 200,实际 %d:%s", w.Code, w.Body.String()) } }) } } // TestMerchantRoutesStillRejectStaleContext 反向卡住商户侧: // 摘掉 Tenant 只针对平台分组,商户后台仍必须校验商户上下文。 func TestMerchantRoutesStillRejectStaleContext(t *testing.T) { engine, jm := newTestRouter(t) token, err := jm.Generate(1, "admin", model.RoleAdmin) if err != nil { t.Fatalf("generate token: %v", err) } req := httptest.NewRequest("GET", "/api/merchant", nil) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("X-Merchant-ID", "999999") w := httptest.NewRecorder() engine.ServeHTTP(w, req) if w.Code != 403 { t.Fatalf("商户路由必须拒绝无效商户上下文,期望 403,实际 %d:%s", w.Code, w.Body.String()) } } func TestIsPollingEndpoint(t *testing.T) { tests := []struct { method string path string expected bool }{ {"GET", "/health", true}, {"GET", "/api/delivery/v1/orders/FO123/bind-result", true}, {"GET", "/api/delivery/v1/orders/FO123", true}, {"GET", "/api/client/v1/orders/FO123", true}, {"GET", "/api/client/v1/wallet", true}, {"GET", "/api/open/v1/orders/FO123", true}, {"POST", "/api/client/v1/orders", false}, {"POST", "/api/delivery/v1/orders/FO123/submit", false}, {"POST", "/api/client/v1/orders/FO123/cancel", false}, {"GET", "/api/dashboard", false}, } for _, tc := range tests { got := isPollingEndpoint(tc.method, tc.path) if got != tc.expected { t.Errorf("isPollingEndpoint(%s, %s) = %v, expected %v", tc.method, tc.path, got, tc.expected) } } } func TestRequestLoggerGeneratesAndPassesReqID(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() r.Use(requestLogger()) r.GET("/test", func(c *gin.Context) { reqID := openlog.GetReqID(c) if reqID == "" || reqID == "-" { t.Errorf("expected req_id in context, got empty") } c.String(200, "ok") }) req := httptest.NewRequest("GET", "/test", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) respReqID := w.Header().Get("X-Request-ID") if respReqID == "" { t.Errorf("expected X-Request-ID in response header") } } func TestRequestLoggerSuppressesPollingOnInfo(t *testing.T) { gin.SetMode(gin.TestMode) var buf bytes.Buffer log.SetOutput(&buf) defer log.SetOutput(log.Writer()) applog.SetLevel("info") r := gin.New() r.Use(requestLogger()) r.GET("/health", func(c *gin.Context) { c.String(200, "ok") }) r.GET("/api/client/v1/orders/:id", func(c *gin.Context) { c.String(200, "ok") }) // 1. In INFO level, polling requests with 200 should be silenced buf.Reset() req := httptest.NewRequest("GET", "/health", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) if buf.Len() > 0 { t.Errorf("expected /health 200 to be silenced in INFO mode, got: %s", buf.String()) } buf.Reset() req = httptest.NewRequest("GET", "/api/client/v1/orders/FO12345", nil) w = httptest.NewRecorder() r.ServeHTTP(w, req) if buf.Len() > 0 { t.Errorf("expected GET order 200 to be silenced in INFO mode, got: %s", buf.String()) } // 2. In DEBUG level, polling requests should appear as [DEBUG] applog.SetLevel("debug") buf.Reset() req = httptest.NewRequest("GET", "/api/client/v1/orders/FO12345", nil) w = httptest.NewRecorder() r.ServeHTTP(w, req) if !strings.Contains(buf.String(), "[DEBUG] [HTTP] 200 GET /api/client/v1/orders/FO12345") { t.Errorf("expected GET order to appear as [DEBUG] in DEBUG mode, got: %s", buf.String()) } }