Changeset 0.26.0 (#165)
This commit is contained in:
339
server/handlers/workflow_test.go
Normal file
339
server/handlers/workflow_test.go
Normal file
@@ -0,0 +1,339 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user