- go.mod module name
- All 714 import references across 289 Go files
- VERSION: 0.1.0
- CI DB names: switchboard_core_{ci,dev,test}
- Docker image: gobha/switchboard-core
- Test fixtures: JWT issuer, repo names
- .env.example, docker-compose container name
- Compiles clean (go build exit 0)
179 lines
6.3 KiB
Go
179 lines
6.3 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"switchboard-core/config"
|
|
authpkg "switchboard-core/auth"
|
|
"switchboard-core/database"
|
|
"switchboard-core/middleware"
|
|
"switchboard-core/pages"
|
|
"switchboard-core/store"
|
|
postgres "switchboard-core/store/postgres"
|
|
sqlite "switchboard-core/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(stores)
|
|
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, 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(stores)
|
|
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())
|
|
}
|
|
}
|