340 lines
11 KiB
Go
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, 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)
|
|
|
|
// 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,
|
|
}
|
|
}
|