package handlers import ( "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" "git.gobha.me/xcaliber/chat-switchboard/config" authpkg "git.gobha.me/xcaliber/chat-switchboard/auth" "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/middleware" "git.gobha.me/xcaliber/chat-switchboard/pages" "git.gobha.me/xcaliber/chat-switchboard/store" postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite" ) // TestRouteRegistration verifies that ALL production routes can be // registered on a single Gin engine without panicking. This catches // wildcard parameter name conflicts (e.g. /w/:id vs /w/:scope/:slug) // which cause runtime panics during startup. // // The test mirrors the route structure in main.go and pages.go. // Any new route group should be added here. func TestRouteRegistration(t *testing.T) { database.RequireTestDB(t) cfg := &config.Config{ JWTSecret: testJWTSecret, BasePath: "/test", Port: "0", } var stores store.Stores if database.IsSQLite() { stores = sqlite.NewStores(database.TestDB) } else { stores = postgres.NewStores(database.TestDB) } userCache := middleware.NewUserStatusCache() // This must not panic. If it does, there's a wildcard conflict. r := gin.New() base := r.Group(cfg.BasePath) // ── API routes (mirrors main.go) ──────── api := base.Group("/api/v1") // Auth (public) auth := NewAuthHandler(cfg, stores, nil, authpkg.NewBuiltinProvider()) api.POST("/auth/login", auth.Login) api.POST("/auth/register", auth.Register) // Protected API routes protected := api.Group("") protected.Use(middleware.Auth(cfg, stores.Users, userCache)) // Channels (the route group that conflicts with workflow) channels := NewChannelHandler() protected.GET("/channels", channels.ListChannels) protected.POST("/channels", channels.CreateChannel) protected.GET("/channels/:id", channels.GetChannel) protected.DELETE("/channels/:id", channels.DeleteChannel) // Messages msgs := NewMessageHandler(nil, stores, nil, nil) protected.GET("/channels/:id/messages", msgs.ListMessages) protected.POST("/channels/:id/messages", msgs.CreateMessage) // Workflow CRUD wfH := NewWorkflowHandler(stores) protected.GET("/workflows", wfH.List) protected.POST("/workflows", wfH.Create) protected.GET("/workflows/:id", wfH.Get) protected.PATCH("/workflows/:id", wfH.Update) protected.DELETE("/workflows/:id", wfH.Delete) protected.GET("/workflows/:id/stages", wfH.ListStages) protected.POST("/workflows/:id/stages", wfH.CreateStage) protected.PUT("/workflows/:id/stages/:sid", wfH.UpdateStage) protected.DELETE("/workflows/:id/stages/:sid", wfH.DeleteStage) protected.PATCH("/workflows/:id/stages/reorder", wfH.ReorderStages) protected.POST("/workflows/:id/publish", wfH.Publish) protected.GET("/workflows/:id/versions/:version", wfH.GetVersion) // Workflow instances wfInstH := NewWorkflowInstanceHandler(stores, nil, nil) protected.POST("/workflows/:id/start", wfInstH.Start) protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus) protected.POST("/channels/:id/workflow/advance", wfInstH.Advance) protected.POST("/channels/:id/workflow/reject", wfInstH.Reject) // Workflow assignments (v0.26.4) wfAssignH := NewWorkflowAssignmentHandler() protected.GET("/workflow-assignments/mine", wfAssignH.ListMine) protected.POST("/workflow-assignments/:id/claim", wfAssignH.Claim) protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete) // Session API (the /w/:id group) wfAPI := base.Group("/api/v1/w") wfAPI.Use(middleware.AuthOrSession(cfg, stores, userCache)) wfAPI.POST("/:id/messages", msgs.CreateMessage) wfAPI.GET("/:id/messages", msgs.ListMessages) // Visitor entry (separate namespace to avoid /w/:id collision) wfEntry := NewWorkflowEntryHandler(stores) base.POST("/api/v1/workflow-entry/:scope/:slug", wfEntry.StartVisitor) // ── Page routes (mirrors pages.go surface registration) ──── pageEngine := pages.New(cfg, stores) pageEngine.RegisterPageRoutes(base, pages.PageRouteMiddleware{ Authenticated: middleware.AuthOrRedirect(cfg, stores.Users, userCache), Admin: []gin.HandlerFunc{middleware.AuthOrRedirect(cfg, stores.Users, userCache), middleware.RequireAdminPage()}, Session: middleware.AuthOrSession(cfg, stores, userCache), }) // ── Verify routes actually resolve ──── // Health-check style: send a request and confirm we get a response // (not a panic). We don't care about the status code — just that // the router doesn't blow up. paths := []struct { method string path string }{ {"GET", "/test/api/v1/workflows"}, {"GET", "/test/api/v1/channels"}, {"GET", "/test/w/some-channel-id"}, // workflow chat surface {"GET", "/test/w/some-scope/some-slug"}, // workflow landing surface {"POST", "/test/api/v1/workflow-entry/global/test-slug"}, } for _, p := range paths { req := httptest.NewRequest(p.method, p.path, nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) // We just want to confirm the router didn't panic and returned // something (even 401/404 is fine — means routing worked). if w.Code == 0 { t.Errorf("%s %s: got status 0 (router failed to match)", p.method, p.path) } } } // TestWorkflowPageRoutesNoConflict specifically tests the /w/ namespace // to ensure different-depth routes with the same param name work. func TestWorkflowPageRoutesNoConflict(t *testing.T) { r := gin.New() // These two routes MUST use the same wildcard name at position 1. // /w/:id (2 segments) = workflow chat // /w/:id/:slug (3 segments) = workflow landing // Using different names (e.g. :scope) at the same position panics. r.GET("/w/:id", func(c *gin.Context) { c.String(http.StatusOK, "chat:"+c.Param("id")) }) r.GET("/w/:id/:slug", func(c *gin.Context) { c.String(http.StatusOK, "landing:"+c.Param("id")+"/"+c.Param("slug")) }) // 2-segment path → chat w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest("GET", "/w/channel-123", nil)) if w.Code != 200 || w.Body.String() != "chat:channel-123" { t.Errorf("2-segment: got %d %q", w.Code, w.Body.String()) } // 3-segment path → landing w = httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest("GET", "/w/my-team/intake-form", nil)) if w.Code != 200 || w.Body.String() != "landing:my-team/intake-form" { t.Errorf("3-segment: got %d %q", w.Code, w.Body.String()) } }