This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/workflow_test.go
2026-03-10 13:38:01 +00:00

340 lines
11 KiB
Go

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,
}
}