package handlers import ( "encoding/json" "net/http" "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/store" postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite" ) // TestWorkflowCRUD exercises the full workflow lifecycle: // create → add stages → publish → start instance → advance → complete. func TestWorkflowCRUD(t *testing.T) { h := setupWorkflowHarness(t) // ── Create workflow ───────────────────── resp := h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{ "name": "Customer Intake", "slug": "customer-intake", "description": "Collect customer info and route to support", "entry_mode": "public_link", }) if resp.Code != http.StatusCreated { t.Fatalf("Create workflow: got %d, body: %s", resp.Code, resp.Body.String()) } var wf struct { ID string `json:"id"` Slug string `json:"slug"` Version int `json:"version"` } json.Unmarshal(resp.Body.Bytes(), &wf) if wf.ID == "" { t.Fatal("workflow ID is empty") } if wf.Slug != "customer-intake" { t.Errorf("slug: got %q, want %q", wf.Slug, "customer-intake") } if wf.Version != 1 { t.Errorf("version: got %d, want 1", wf.Version) } // ── Get workflow ──────────────────────── resp = h.request("GET", "/api/v1/workflows/"+wf.ID, h.adminToken, nil) if resp.Code != http.StatusOK { t.Fatalf("Get workflow: got %d", resp.Code) } // ── List workflows ────────────────────── resp = h.request("GET", "/api/v1/workflows", h.adminToken, nil) if resp.Code != http.StatusOK { t.Fatalf("List workflows: got %d", resp.Code) } var listResp struct { Data []struct{ ID string } `json:"data"` } json.Unmarshal(resp.Body.Bytes(), &listResp) if len(listResp.Data) == 0 { t.Error("List workflows returned empty") } // ── Add stages ────────────────────────── resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, map[string]interface{}{ "name": "Collect Info", "history_mode": "full", "ordinal": 0, }) if resp.Code != http.StatusCreated { t.Fatalf("Create stage 0: got %d, body: %s", resp.Code, resp.Body.String()) } var stage0 struct{ ID string `json:"id"` } json.Unmarshal(resp.Body.Bytes(), &stage0) resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, map[string]interface{}{ "name": "Review", "history_mode": "fresh", "ordinal": 1, }) if resp.Code != http.StatusCreated { t.Fatalf("Create stage 1: got %d, body: %s", resp.Code, resp.Body.String()) } var stage1 struct{ ID string `json:"id"` } json.Unmarshal(resp.Body.Bytes(), &stage1) // ── List stages ───────────────────────── resp = h.request("GET", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, nil) if resp.Code != http.StatusOK { t.Fatalf("List stages: got %d", resp.Code) } var stagesResp struct { Data []struct { ID string `json:"id"` Ordinal int `json:"ordinal"` } `json:"data"` } json.Unmarshal(resp.Body.Bytes(), &stagesResp) if len(stagesResp.Data) != 2 { t.Fatalf("List stages: got %d, want 2", len(stagesResp.Data)) } // ── Reorder stages ────────────────────── resp = h.request("PATCH", "/api/v1/workflows/"+wf.ID+"/stages/reorder", h.adminToken, map[string]interface{}{ "ordered_ids": []string{stage1.ID, stage0.ID}, }) if resp.Code != http.StatusOK { t.Fatalf("Reorder stages: got %d, body: %s", resp.Code, resp.Body.String()) } // ── Update workflow ───────────────────── resp = h.request("PATCH", "/api/v1/workflows/"+wf.ID, h.adminToken, map[string]interface{}{ "is_active": true, }) if resp.Code != http.StatusOK { t.Fatalf("Update workflow: got %d, body: %s", resp.Code, resp.Body.String()) } // Re-read to get incremented version resp = h.request("GET", "/api/v1/workflows/"+wf.ID, h.adminToken, nil) json.Unmarshal(resp.Body.Bytes(), &wf) if wf.Version != 2 { t.Errorf("version after update: got %d, want 2", wf.Version) } // ── Publish ───────────────────────────── resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/publish", h.adminToken, nil) if resp.Code != http.StatusCreated { t.Fatalf("Publish: got %d, body: %s", resp.Code, resp.Body.String()) } var ver struct { VersionNumber int `json:"version_number"` Snapshot json.RawMessage `json:"snapshot"` } json.Unmarshal(resp.Body.Bytes(), &ver) if ver.VersionNumber != 2 { t.Errorf("published version: got %d, want 2", ver.VersionNumber) } if len(ver.Snapshot) == 0 { t.Error("snapshot is empty") } // ── Duplicate publish should conflict ─── resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/publish", h.adminToken, nil) if resp.Code != http.StatusConflict { t.Errorf("Duplicate publish: got %d, want 409", resp.Code) } // ── Get version ───────────────────────── resp = h.request("GET", "/api/v1/workflows/"+wf.ID+"/versions/2", h.adminToken, nil) if resp.Code != http.StatusOK { t.Fatalf("Get version: got %d", resp.Code) } // ── Duplicate slug should conflict ────── resp = h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{ "name": "Another Intake", "slug": "customer-intake", }) if resp.Code != http.StatusConflict { t.Errorf("Duplicate slug: got %d, want 409", resp.Code) } // ── Auto-slug from name ───────────────── resp = h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{ "name": "Bug Report Triage", }) if resp.Code != http.StatusCreated { t.Fatalf("Auto-slug: got %d, body: %s", resp.Code, resp.Body.String()) } var wf2 struct{ Slug string `json:"slug"` } json.Unmarshal(resp.Body.Bytes(), &wf2) if wf2.Slug != "bug-report-triage" { t.Errorf("auto-slug: got %q, want %q", wf2.Slug, "bug-report-triage") } // ── Delete stage ──────────────────────── resp = h.request("DELETE", "/api/v1/workflows/"+wf.ID+"/stages/"+stage1.ID, h.adminToken, nil) if resp.Code != http.StatusOK { t.Fatalf("Delete stage: got %d", resp.Code) } // ── Delete workflow (cascades) ────────── resp = h.request("DELETE", "/api/v1/workflows/"+wf.ID, h.adminToken, nil) if resp.Code != http.StatusOK { t.Fatalf("Delete workflow: got %d", resp.Code) } // Confirm gone resp = h.request("GET", "/api/v1/workflows/"+wf.ID, h.adminToken, nil) if resp.Code != http.StatusNotFound { t.Errorf("After delete: got %d, want 404", resp.Code) } } // TestWorkflowValidation tests input validation on workflow endpoints. func TestWorkflowValidation(t *testing.T) { h := setupWorkflowHarness(t) // Missing name resp := h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{ "slug": "test", }) if resp.Code != http.StatusBadRequest { t.Errorf("Missing name: got %d, want 400", resp.Code) } // Invalid slug resp = h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{ "name": "Test", "slug": "A", }) if resp.Code != http.StatusBadRequest { t.Errorf("Short slug: got %d, want 400", resp.Code) } // Invalid entry_mode resp = h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{ "name": "Test", "entry_mode": "invalid", }) if resp.Code != http.StatusBadRequest { t.Errorf("Bad entry_mode: got %d, want 400", resp.Code) } // Invalid history_mode on stage resp = h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{ "name": "For Stage Test", }) var stageWf struct{ ID string `json:"id"` } json.Unmarshal(resp.Body.Bytes(), &stageWf) resp = h.request("POST", "/api/v1/workflows/"+stageWf.ID+"/stages", h.adminToken, map[string]interface{}{ "name": "Bad Mode", "history_mode": "invalid", }) if resp.Code != http.StatusBadRequest { t.Errorf("Bad history_mode: got %d, want 400", resp.Code) } } // ── Workflow Test Harness ─────────────────── type workflowHarness struct { *testHarness adminToken string adminID string } func setupWorkflowHarness(t *testing.T) *workflowHarness { t.Helper() database.RequireTestDB(t) database.TruncateAll(t) cfg := &config.Config{ JWTSecret: testJWTSecret, BasePath: "", } var stores store.Stores if database.IsSQLite() { stores = sqlite.NewStores(database.TestDB) } else { stores = postgres.NewStores(database.TestDB) } r := gin.New() api := r.Group("/api/v1") // Auth (for token generation) auth := NewAuthHandler(cfg, stores, nil, authpkg.NewBuiltinProvider()) api.POST("/auth/login", auth.Login) api.POST("/auth/register", auth.Register) protected := api.Group("") protected.Use(middleware.Auth(cfg)) // 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) // Channels (needed for workflow instance creation) channels := NewChannelHandler() protected.GET("/channels", channels.ListChannels) protected.POST("/channels", channels.CreateChannel) protected.GET("/channels/:id", channels.GetChannel) // Seed an admin user (handle + auth_source required since v0.24.0) adminID := seedInsertReturningID(t, `INSERT INTO users (username, email, password_hash, role, handle, auth_source) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, "wf-admin", "wf-admin@test.com", "$2a$10$test", "admin", "wf-admin", "builtin", ) token := makeToken(adminID, "wf-admin@test.com", "admin") return &workflowHarness{ testHarness: &testHarness{router: r, t: t}, adminToken: token, adminID: adminID, } }