Changeset 0.26.0 (#165)
This commit is contained in:
177
server/handlers/route_test.go
Normal file
177
server/handlers/route_test.go
Normal file
@@ -0,0 +1,177 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// 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))
|
||||
|
||||
// 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)
|
||||
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))
|
||||
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),
|
||||
Admin: []gin.HandlerFunc{middleware.AuthOrRedirect(cfg), middleware.RequireAdminPage()},
|
||||
Session: middleware.AuthOrSession(cfg, stores),
|
||||
})
|
||||
|
||||
// ── 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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user