846 lines
34 KiB
Go
846 lines
34 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"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"
|
|
)
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// Extended harness: CRUD + instances + assignments + entry
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
type workflowInstanceHarness struct {
|
|
*testHarness
|
|
stores store.Stores
|
|
adminToken string
|
|
adminID string
|
|
}
|
|
|
|
func setupWorkflowInstanceHarness(t *testing.T) *workflowInstanceHarness {
|
|
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)
|
|
}
|
|
userCache := middleware.NewUserStatusCache()
|
|
|
|
r := gin.New()
|
|
api := r.Group("/api/v1")
|
|
|
|
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, stores.Users, userCache))
|
|
|
|
// 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)
|
|
|
|
// Assignments
|
|
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)
|
|
|
|
// Teams (admin routes for creating teams + adding members)
|
|
teamH := NewTeamHandler(stores, nil)
|
|
admin := api.Group("/admin")
|
|
admin.Use(middleware.Auth(cfg, stores.Users, userCache), middleware.RequireAdmin())
|
|
admin.POST("/teams", teamH.CreateTeam)
|
|
admin.POST("/teams/:id/members", teamH.AddMember)
|
|
|
|
// Team-scoped assignment listing (mirrors main.go teamScoped)
|
|
teamScoped := protected.Group("/teams/:teamId")
|
|
teamAssignH := NewWorkflowAssignmentHandler(stores)
|
|
teamScoped.GET("/assignments", teamAssignH.ListForTeam)
|
|
|
|
// Visitor entry
|
|
wfEntry := NewWorkflowEntryHandler(stores)
|
|
r.POST("/api/v1/workflow-entry/:scope/:slug", wfEntry.StartVisitor)
|
|
|
|
// Seed admin user
|
|
adminID := seedInsertReturningID(t,
|
|
`INSERT INTO users (username, email, password_hash, role, handle, auth_source) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
|
"wfinst-admin", "wfinst-admin@test.com", "$2a$10$test", "admin", "wfinst-admin", "builtin",
|
|
)
|
|
token := makeToken(adminID, "wfinst-admin@test.com", "admin")
|
|
|
|
return &workflowInstanceHarness{
|
|
testHarness: &testHarness{router: r, t: t},
|
|
stores: stores,
|
|
adminToken: token,
|
|
adminID: adminID,
|
|
}
|
|
}
|
|
|
|
// createPublishedWorkflow is a helper that creates a workflow with N stages,
|
|
// activates it, and publishes it. Returns workflow ID + stage IDs.
|
|
func (h *workflowInstanceHarness) createPublishedWorkflow(name string, numStages int) (string, []string) {
|
|
h.t.Helper()
|
|
|
|
resp := h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{
|
|
"name": name,
|
|
"entry_mode": "public_link",
|
|
})
|
|
if resp.Code != http.StatusCreated {
|
|
h.t.Fatalf("create workflow: %d: %s", resp.Code, resp.Body.String())
|
|
}
|
|
var wf struct{ ID string `json:"id"` }
|
|
json.Unmarshal(resp.Body.Bytes(), &wf)
|
|
|
|
stageIDs := make([]string, numStages)
|
|
for i := 0; i < numStages; i++ {
|
|
resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, map[string]interface{}{
|
|
"name": fmt.Sprintf("Stage %d", i),
|
|
"ordinal": i,
|
|
"history_mode": "full",
|
|
})
|
|
if resp.Code != http.StatusCreated {
|
|
h.t.Fatalf("create stage %d: %d: %s", i, resp.Code, resp.Body.String())
|
|
}
|
|
var st struct{ ID string `json:"id"` }
|
|
json.Unmarshal(resp.Body.Bytes(), &st)
|
|
stageIDs[i] = st.ID
|
|
}
|
|
|
|
// Activate + publish
|
|
h.request("PATCH", "/api/v1/workflows/"+wf.ID, h.adminToken, map[string]interface{}{
|
|
"is_active": true,
|
|
})
|
|
resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/publish", h.adminToken, nil)
|
|
if resp.Code != http.StatusCreated {
|
|
h.t.Fatalf("publish: %d: %s", resp.Code, resp.Body.String())
|
|
}
|
|
|
|
return wf.ID, stageIDs
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// #15 — Instance Lifecycle (currently untested)
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
// TestWorkflowInstanceLifecycle exercises: start → status → advance → complete.
|
|
func TestWorkflowInstanceLifecycle(t *testing.T) {
|
|
h := setupWorkflowInstanceHarness(t)
|
|
wfID, _ := h.createPublishedWorkflow("Lifecycle Test", 2)
|
|
|
|
// ── Start ───────────────────────────────
|
|
resp := h.request("POST", "/api/v1/workflows/"+wfID+"/start", h.adminToken, nil)
|
|
if resp.Code != http.StatusCreated {
|
|
t.Fatalf("Start: got %d, body: %s", resp.Code, resp.Body.String())
|
|
}
|
|
var startResp struct {
|
|
ChannelID string `json:"channel_id"`
|
|
WorkflowVersion int `json:"workflow_version"`
|
|
CurrentStage int `json:"current_stage"`
|
|
}
|
|
json.Unmarshal(resp.Body.Bytes(), &startResp)
|
|
if startResp.ChannelID == "" {
|
|
t.Fatal("Start: channel_id empty")
|
|
}
|
|
if startResp.CurrentStage != 0 {
|
|
t.Errorf("Start: current_stage = %d, want 0", startResp.CurrentStage)
|
|
}
|
|
chID := startResp.ChannelID
|
|
|
|
// ── Status ──────────────────────────────
|
|
resp = h.request("GET", "/api/v1/channels/"+chID+"/workflow/status", h.adminToken, nil)
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("Status: got %d, body: %s", resp.Code, resp.Body.String())
|
|
}
|
|
var status struct {
|
|
WorkflowID *string `json:"workflow_id"`
|
|
Status string `json:"status"`
|
|
Stage int `json:"current_stage"`
|
|
StageData json.RawMessage `json:"stage_data"`
|
|
}
|
|
json.Unmarshal(resp.Body.Bytes(), &status)
|
|
if status.Status != "active" {
|
|
t.Errorf("Status: got %q, want \"active\"", status.Status)
|
|
}
|
|
if status.WorkflowID == nil || *status.WorkflowID != wfID {
|
|
t.Errorf("Status: workflow_id mismatch")
|
|
}
|
|
|
|
// ── Advance (stage 0 → 1) ───────────────
|
|
resp = h.request("POST", "/api/v1/channels/"+chID+"/workflow/advance", h.adminToken, map[string]interface{}{
|
|
"data": map[string]string{"name": "Jane", "email": "jane@test.com"},
|
|
})
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("Advance 0→1: got %d, body: %s", resp.Code, resp.Body.String())
|
|
}
|
|
var advResp struct {
|
|
Status string `json:"status"`
|
|
CurrentStage int `json:"current_stage"`
|
|
}
|
|
json.Unmarshal(resp.Body.Bytes(), &advResp)
|
|
if advResp.Status != "active" {
|
|
t.Errorf("Advance 0→1: status = %q, want \"active\"", advResp.Status)
|
|
}
|
|
if advResp.CurrentStage != 1 {
|
|
t.Errorf("Advance 0→1: current_stage = %d, want 1", advResp.CurrentStage)
|
|
}
|
|
|
|
// ── Verify merged data persisted ────────
|
|
resp = h.request("GET", "/api/v1/channels/"+chID+"/workflow/status", h.adminToken, nil)
|
|
json.Unmarshal(resp.Body.Bytes(), &status)
|
|
var data map[string]string
|
|
json.Unmarshal(status.StageData, &data)
|
|
if data["name"] != "Jane" || data["email"] != "jane@test.com" {
|
|
t.Errorf("Stage data after advance: got %v", data)
|
|
}
|
|
|
|
// ── Advance (stage 1 → complete) ────────
|
|
resp = h.request("POST", "/api/v1/channels/"+chID+"/workflow/advance", h.adminToken, map[string]interface{}{
|
|
"data": map[string]string{"resolution": "approved"},
|
|
})
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("Advance 1→complete: got %d, body: %s", resp.Code, resp.Body.String())
|
|
}
|
|
json.Unmarshal(resp.Body.Bytes(), &advResp)
|
|
if advResp.Status != "completed" {
|
|
t.Errorf("Completion: status = %q, want \"completed\"", advResp.Status)
|
|
}
|
|
|
|
// ── Status after completion ──────────────
|
|
resp = h.request("GET", "/api/v1/channels/"+chID+"/workflow/status", h.adminToken, nil)
|
|
json.Unmarshal(resp.Body.Bytes(), &status)
|
|
if status.Status != "completed" {
|
|
t.Errorf("Post-complete status: got %q, want \"completed\"", status.Status)
|
|
}
|
|
|
|
// ── Advance on completed should fail ────
|
|
resp = h.request("POST", "/api/v1/channels/"+chID+"/workflow/advance", h.adminToken, map[string]interface{}{
|
|
"data": map[string]string{"extra": "data"},
|
|
})
|
|
if resp.Code != http.StatusBadRequest {
|
|
t.Errorf("Advance after complete: got %d, want 400", resp.Code)
|
|
}
|
|
}
|
|
|
|
// TestWorkflowInstanceStart_InactiveWorkflow verifies start fails on inactive workflow.
|
|
func TestWorkflowInstanceStart_InactiveWorkflow(t *testing.T) {
|
|
h := setupWorkflowInstanceHarness(t)
|
|
|
|
// Create workflow with a stage but don't activate
|
|
resp := h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{
|
|
"name": "Inactive WF",
|
|
})
|
|
var wf struct{ ID string `json:"id"` }
|
|
json.Unmarshal(resp.Body.Bytes(), &wf)
|
|
|
|
h.request("POST", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, map[string]interface{}{
|
|
"name": "Stage 0", "ordinal": 0, "history_mode": "full",
|
|
})
|
|
|
|
resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/start", h.adminToken, nil)
|
|
if resp.Code != http.StatusBadRequest {
|
|
t.Errorf("Start inactive: got %d, want 400", resp.Code)
|
|
}
|
|
}
|
|
|
|
// TestWorkflowInstanceStart_NoPublishedVersion verifies start fails without a published version.
|
|
func TestWorkflowInstanceStart_NoPublishedVersion(t *testing.T) {
|
|
h := setupWorkflowInstanceHarness(t)
|
|
|
|
resp := h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{
|
|
"name": "Unpublished WF",
|
|
})
|
|
var wf struct{ ID string `json:"id"` }
|
|
json.Unmarshal(resp.Body.Bytes(), &wf)
|
|
|
|
h.request("POST", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, map[string]interface{}{
|
|
"name": "Stage 0", "ordinal": 0, "history_mode": "full",
|
|
})
|
|
// Activate but don't publish
|
|
h.request("PATCH", "/api/v1/workflows/"+wf.ID, h.adminToken, map[string]interface{}{
|
|
"is_active": true,
|
|
})
|
|
|
|
resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/start", h.adminToken, nil)
|
|
if resp.Code != http.StatusBadRequest {
|
|
t.Errorf("Start unpublished: got %d, want 400", resp.Code)
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// #15 — Reject validation
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
// TestWorkflowReject exercises reject validation: reason required, stage-0 guard.
|
|
func TestWorkflowReject(t *testing.T) {
|
|
h := setupWorkflowInstanceHarness(t)
|
|
wfID, _ := h.createPublishedWorkflow("Reject Test", 3)
|
|
|
|
// Start
|
|
resp := h.request("POST", "/api/v1/workflows/"+wfID+"/start", h.adminToken, nil)
|
|
var start struct{ ChannelID string `json:"channel_id"` }
|
|
json.Unmarshal(resp.Body.Bytes(), &start)
|
|
chID := start.ChannelID
|
|
|
|
// ── Reject from stage 0 should fail ─────
|
|
resp = h.request("POST", "/api/v1/channels/"+chID+"/workflow/reject", h.adminToken, map[string]interface{}{
|
|
"reason": "want to go back",
|
|
})
|
|
if resp.Code != http.StatusBadRequest {
|
|
t.Errorf("Reject at stage 0: got %d, want 400", resp.Code)
|
|
}
|
|
|
|
// ── Reject without reason should fail ───
|
|
// Advance to stage 1 first
|
|
h.request("POST", "/api/v1/channels/"+chID+"/workflow/advance", h.adminToken, map[string]interface{}{
|
|
"data": map[string]string{"step": "0"},
|
|
})
|
|
|
|
resp = h.request("POST", "/api/v1/channels/"+chID+"/workflow/reject", h.adminToken, nil)
|
|
if resp.Code != http.StatusBadRequest {
|
|
t.Errorf("Reject no reason: got %d, want 400", resp.Code)
|
|
}
|
|
|
|
// ── Reject with empty reason should fail ─
|
|
resp = h.request("POST", "/api/v1/channels/"+chID+"/workflow/reject", h.adminToken, map[string]interface{}{
|
|
"reason": "",
|
|
})
|
|
if resp.Code != http.StatusBadRequest {
|
|
t.Errorf("Reject empty reason: got %d, want 400", resp.Code)
|
|
}
|
|
|
|
// ── Valid reject ────────────────────────
|
|
resp = h.request("POST", "/api/v1/channels/"+chID+"/workflow/reject", h.adminToken, map[string]interface{}{
|
|
"reason": "Missing email field",
|
|
})
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("Valid reject: got %d, body: %s", resp.Code, resp.Body.String())
|
|
}
|
|
var rejResp struct {
|
|
CurrentStage int `json:"current_stage"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
json.Unmarshal(resp.Body.Bytes(), &rejResp)
|
|
if rejResp.CurrentStage != 0 {
|
|
t.Errorf("After reject: current_stage = %d, want 0", rejResp.CurrentStage)
|
|
}
|
|
if rejResp.Reason != "Missing email field" {
|
|
t.Errorf("Reject reason: got %q", rejResp.Reason)
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// #1 — Advance body field is "data" (not "stage_data")
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
// TestWorkflowAdvance_BodyFieldName verifies the advance endpoint reads "data"
|
|
// from the request body, not "stage_data" (which the old ICD incorrectly documented).
|
|
func TestWorkflowAdvance_BodyFieldName(t *testing.T) {
|
|
h := setupWorkflowInstanceHarness(t)
|
|
wfID, _ := h.createPublishedWorkflow("Field Name Test", 2)
|
|
|
|
resp := h.request("POST", "/api/v1/workflows/"+wfID+"/start", h.adminToken, nil)
|
|
var start struct{ ChannelID string `json:"channel_id"` }
|
|
json.Unmarshal(resp.Body.Bytes(), &start)
|
|
chID := start.ChannelID
|
|
|
|
// Send data under the correct "data" key
|
|
h.request("POST", "/api/v1/channels/"+chID+"/workflow/advance", h.adminToken, map[string]interface{}{
|
|
"data": map[string]string{"collected": "yes"},
|
|
})
|
|
|
|
// Verify data was merged
|
|
resp = h.request("GET", "/api/v1/channels/"+chID+"/workflow/status", h.adminToken, nil)
|
|
var status struct{ StageData json.RawMessage `json:"stage_data"` }
|
|
json.Unmarshal(resp.Body.Bytes(), &status)
|
|
var data map[string]string
|
|
json.Unmarshal(status.StageData, &data)
|
|
if data["collected"] != "yes" {
|
|
t.Errorf("Data sent under 'data' key not merged: got %v", data)
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// #2 — GetStatus response uses "status" (not "workflow_status")
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
// TestWorkflowGetStatus_ResponseShape verifies the exact JSON field names
|
|
// returned by the status endpoint.
|
|
func TestWorkflowGetStatus_ResponseShape(t *testing.T) {
|
|
h := setupWorkflowInstanceHarness(t)
|
|
wfID, _ := h.createPublishedWorkflow("Status Shape", 1)
|
|
|
|
resp := h.request("POST", "/api/v1/workflows/"+wfID+"/start", h.adminToken, nil)
|
|
var start struct{ ChannelID string `json:"channel_id"` }
|
|
json.Unmarshal(resp.Body.Bytes(), &start)
|
|
|
|
resp = h.request("GET", "/api/v1/channels/"+start.ChannelID+"/workflow/status", h.adminToken, nil)
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("GetStatus: %d", resp.Code)
|
|
}
|
|
|
|
// Parse as raw map to check exact field names
|
|
var raw map[string]interface{}
|
|
json.Unmarshal(resp.Body.Bytes(), &raw)
|
|
|
|
if _, ok := raw["status"]; !ok {
|
|
t.Error("Response missing 'status' field — should be 'status', not 'workflow_status'")
|
|
}
|
|
if _, ok := raw["workflow_status"]; ok {
|
|
t.Error("Response has 'workflow_status' — should be 'status' per ICD")
|
|
}
|
|
if _, ok := raw["workflow_id"]; !ok {
|
|
t.Error("Response missing 'workflow_id'")
|
|
}
|
|
if _, ok := raw["current_stage"]; !ok {
|
|
t.Error("Response missing 'current_stage'")
|
|
}
|
|
if _, ok := raw["last_activity_at"]; !ok {
|
|
t.Error("Response missing 'last_activity_at'")
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// #4 — StartVisitor response shape
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
// TestWorkflowVisitorEntry_ResponseShape verifies the visitor start endpoint
|
|
// returns the correct field names (session_id + redirect_to, not session_token).
|
|
func TestWorkflowVisitorEntry_ResponseShape(t *testing.T) {
|
|
h := setupWorkflowInstanceHarness(t)
|
|
wfID, _ := h.createPublishedWorkflow("Visitor Test", 1)
|
|
|
|
// Read slug
|
|
resp := h.request("GET", "/api/v1/workflows/"+wfID, h.adminToken, nil)
|
|
var wf struct{ Slug string `json:"slug"` }
|
|
json.Unmarshal(resp.Body.Bytes(), &wf)
|
|
|
|
// Visitor start (no auth, no body)
|
|
resp = h.request("POST", "/api/v1/workflow-entry/global/"+wf.Slug, "", nil)
|
|
if resp.Code != http.StatusCreated {
|
|
t.Fatalf("Visitor start: got %d, body: %s", resp.Code, resp.Body.String())
|
|
}
|
|
|
|
var raw map[string]interface{}
|
|
json.Unmarshal(resp.Body.Bytes(), &raw)
|
|
|
|
if _, ok := raw["channel_id"]; !ok {
|
|
t.Error("Response missing 'channel_id'")
|
|
}
|
|
if _, ok := raw["session_id"]; !ok {
|
|
t.Error("Response missing 'session_id' — should be 'session_id', not 'session_token'")
|
|
}
|
|
if _, ok := raw["redirect_to"]; !ok {
|
|
t.Error("Response missing 'redirect_to'")
|
|
}
|
|
// These should NOT be present
|
|
if _, ok := raw["session_token"]; ok {
|
|
t.Error("Response has 'session_token' — field was renamed to 'session_id'")
|
|
}
|
|
if _, ok := raw["workflow"]; ok {
|
|
t.Error("Response has 'workflow' object — not in actual response")
|
|
}
|
|
}
|
|
|
|
// TestWorkflowVisitorEntry_TeamOnlyBlocked verifies team_only workflows reject visitors.
|
|
func TestWorkflowVisitorEntry_TeamOnlyBlocked(t *testing.T) {
|
|
h := setupWorkflowInstanceHarness(t)
|
|
|
|
resp := h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{
|
|
"name": "Team Only WF",
|
|
"entry_mode": "team_only",
|
|
})
|
|
var wf struct {
|
|
ID string `json:"id"`
|
|
Slug string `json:"slug"`
|
|
}
|
|
json.Unmarshal(resp.Body.Bytes(), &wf)
|
|
|
|
h.request("POST", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, map[string]interface{}{
|
|
"name": "S0", "ordinal": 0, "history_mode": "full",
|
|
})
|
|
h.request("PATCH", "/api/v1/workflows/"+wf.ID, h.adminToken, map[string]interface{}{
|
|
"is_active": true,
|
|
})
|
|
h.request("POST", "/api/v1/workflows/"+wf.ID+"/publish", h.adminToken, nil)
|
|
|
|
resp = h.request("POST", "/api/v1/workflow-entry/global/"+wf.Slug, "", nil)
|
|
if resp.Code != http.StatusForbidden {
|
|
t.Errorf("Visitor on team_only: got %d, want 403", resp.Code)
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// #6/#7 — webhook_url/webhook_secret round-trip
|
|
// EXPECTED TO FAIL until stores are fixed to SELECT/INSERT these columns.
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
// TestWorkflowWebhookFields verifies webhook_url and webhook_secret survive
|
|
// create → get round-trip. This WILL FAIL until the Postgres and SQLite
|
|
// workflow stores are updated to include these columns in their queries.
|
|
func TestWorkflowWebhookFields(t *testing.T) {
|
|
h := setupWorkflowInstanceHarness(t)
|
|
|
|
resp := h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{
|
|
"name": "Webhook WF",
|
|
"webhook_url": "https://hooks.example.com/wf",
|
|
})
|
|
if resp.Code != http.StatusCreated {
|
|
t.Fatalf("Create with webhook_url: %d: %s", resp.Code, resp.Body.String())
|
|
}
|
|
var wf struct {
|
|
ID string `json:"id"`
|
|
WebhookURL string `json:"webhook_url"`
|
|
}
|
|
json.Unmarshal(resp.Body.Bytes(), &wf)
|
|
|
|
// BUG #6: webhook_url is not included in the store's INSERT/SELECT,
|
|
// so it will be empty on the create response.
|
|
if wf.WebhookURL != "https://hooks.example.com/wf" {
|
|
t.Errorf("Create response webhook_url: got %q, want %q (BUG #6: store doesn't persist webhook_url)",
|
|
wf.WebhookURL, "https://hooks.example.com/wf")
|
|
}
|
|
|
|
// Also verify via GET
|
|
resp = h.request("GET", "/api/v1/workflows/"+wf.ID, h.adminToken, nil)
|
|
json.Unmarshal(resp.Body.Bytes(), &wf)
|
|
if wf.WebhookURL != "https://hooks.example.com/wf" {
|
|
t.Errorf("GET webhook_url: got %q, want %q (BUG #6: store doesn't SELECT webhook_url)",
|
|
wf.WebhookURL, "https://hooks.example.com/wf")
|
|
}
|
|
|
|
// BUG #7: PATCH webhook_url should work
|
|
resp = h.request("PATCH", "/api/v1/workflows/"+wf.ID, h.adminToken, map[string]interface{}{
|
|
"webhook_url": "https://hooks.example.com/updated",
|
|
})
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("PATCH webhook_url: %d: %s", resp.Code, resp.Body.String())
|
|
}
|
|
json.Unmarshal(resp.Body.Bytes(), &wf)
|
|
if wf.WebhookURL != "https://hooks.example.com/updated" {
|
|
t.Errorf("PATCH webhook_url: got %q, want %q (BUG #7: Update() ignores webhook_url)",
|
|
wf.WebhookURL, "https://hooks.example.com/updated")
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// #18 — ListMine should include unassigned-for-my-teams
|
|
// EXPECTED TO FAIL until ListMine query is fixed.
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
// TestWorkflowAssignment_ListMineIncludesTeamUnassigned verifies that
|
|
// GET /workflow-assignments/mine returns both claimed assignments AND
|
|
// unassigned assignments for the user's teams.
|
|
func TestWorkflowAssignment_ListMineIncludesTeamUnassigned(t *testing.T) {
|
|
h := setupWorkflowInstanceHarness(t)
|
|
|
|
// Create a team and add a member
|
|
memberID := seedInsertReturningID(t,
|
|
`INSERT INTO users (username, email, password_hash, role, handle, auth_source) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
|
"wf-member", "wf-member@test.com", "$2a$10$test", "user", "wf-member", "builtin",
|
|
)
|
|
memberToken := makeToken(memberID, "wf-member@test.com", "user")
|
|
|
|
resp := h.request("POST", "/api/v1/admin/teams", h.adminToken, map[string]string{
|
|
"name": "Review Team", "description": "Test team",
|
|
})
|
|
if resp.Code != http.StatusCreated {
|
|
t.Fatalf("Create team: %d: %s", resp.Code, resp.Body.String())
|
|
}
|
|
var team map[string]interface{}
|
|
decode(resp, &team)
|
|
teamID := team["id"].(string)
|
|
|
|
h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), h.adminToken,
|
|
map[string]string{"user_id": memberID, "role": "member"})
|
|
|
|
// Create a workflow with an assignment stage
|
|
wfResp := h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{
|
|
"name": "Assignment WF",
|
|
"entry_mode": "team_only",
|
|
})
|
|
var wf struct{ ID string `json:"id"` }
|
|
json.Unmarshal(wfResp.Body.Bytes(), &wf)
|
|
|
|
h.request("POST", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, map[string]interface{}{
|
|
"name": "Intake", "ordinal": 0, "history_mode": "full",
|
|
})
|
|
h.request("POST", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, map[string]interface{}{
|
|
"name": "Review",
|
|
"ordinal": 1,
|
|
"history_mode": "full",
|
|
"assignment_team_id": teamID,
|
|
})
|
|
h.request("PATCH", "/api/v1/workflows/"+wf.ID, h.adminToken, map[string]interface{}{
|
|
"is_active": true,
|
|
})
|
|
h.request("POST", "/api/v1/workflows/"+wf.ID+"/publish", h.adminToken, nil)
|
|
|
|
// Start instance and advance to stage 1 (creates an assignment)
|
|
resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/start", h.adminToken, nil)
|
|
var start struct{ ChannelID string `json:"channel_id"` }
|
|
json.Unmarshal(resp.Body.Bytes(), &start)
|
|
|
|
h.request("POST", "/api/v1/channels/"+start.ChannelID+"/workflow/advance", h.adminToken, map[string]interface{}{
|
|
"data": map[string]string{"intake": "done"},
|
|
})
|
|
|
|
// BUG #18: ListMine should include the unassigned assignment for
|
|
// the member's team, but currently only returns claimed assignments.
|
|
resp = h.request("GET", "/api/v1/workflow-assignments/mine", memberToken, nil)
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("ListMine: %d: %s", resp.Code, resp.Body.String())
|
|
}
|
|
var listResp struct {
|
|
Data []struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
TeamID string `json:"team_id"`
|
|
To *string `json:"assigned_to"`
|
|
} `json:"data"`
|
|
}
|
|
json.Unmarshal(resp.Body.Bytes(), &listResp)
|
|
|
|
if len(listResp.Data) == 0 {
|
|
t.Error("ListMine returned 0 assignments — BUG #18: should include unassigned assignments for user's teams")
|
|
}
|
|
|
|
// If we do get results, verify the shape
|
|
for _, a := range listResp.Data {
|
|
if a.TeamID != teamID {
|
|
t.Errorf("Assignment team_id: got %q, want %q", a.TeamID, teamID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// Assignment Claim + Complete (basic happy path)
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
// TestWorkflowAssignment_ClaimAndComplete exercises the claim → complete flow.
|
|
func TestWorkflowAssignment_ClaimAndComplete(t *testing.T) {
|
|
h := setupWorkflowInstanceHarness(t)
|
|
|
|
// Seed assignment directly (simulates the advance handler creating one)
|
|
wfID, _ := h.createPublishedWorkflow("Assign WF", 2)
|
|
|
|
// Start + advance to create assignment stage
|
|
resp := h.request("POST", "/api/v1/workflows/"+wfID+"/start", h.adminToken, nil)
|
|
var start struct{ ChannelID string `json:"channel_id"` }
|
|
json.Unmarshal(resp.Body.Bytes(), &start)
|
|
|
|
// Manually insert an assignment (since our stages don't have assignment_team_id)
|
|
teamID := seedInsertReturningID(t,
|
|
`INSERT INTO teams (name, description, created_by) VALUES ($1, $2, $3) RETURNING id`,
|
|
"Claim Team", "Test", h.adminID,
|
|
)
|
|
assignID := seedInsertReturningID(t,
|
|
`INSERT INTO workflow_assignments (channel_id, stage, team_id) VALUES ($1, $2, $3) RETURNING id`,
|
|
start.ChannelID, 0, teamID,
|
|
)
|
|
|
|
// Claim
|
|
resp = h.request("POST", "/api/v1/workflow-assignments/"+assignID+"/claim", h.adminToken, nil)
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("Claim: got %d, body: %s", resp.Code, resp.Body.String())
|
|
}
|
|
var claimResp struct {
|
|
Claimed bool `json:"claimed"`
|
|
AssignmentID string `json:"assignment_id"`
|
|
}
|
|
json.Unmarshal(resp.Body.Bytes(), &claimResp)
|
|
if !claimResp.Claimed {
|
|
t.Error("Claim: claimed should be true")
|
|
}
|
|
|
|
// Double-claim should conflict
|
|
resp = h.request("POST", "/api/v1/workflow-assignments/"+assignID+"/claim", h.adminToken, nil)
|
|
if resp.Code != http.StatusConflict {
|
|
t.Errorf("Double claim: got %d, want 409", resp.Code)
|
|
}
|
|
|
|
// Complete
|
|
resp = h.request("POST", "/api/v1/workflow-assignments/"+assignID+"/complete", h.adminToken, nil)
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("Complete: got %d, body: %s", resp.Code, resp.Body.String())
|
|
}
|
|
|
|
// Double-complete should conflict
|
|
resp = h.request("POST", "/api/v1/workflow-assignments/"+assignID+"/complete", h.adminToken, nil)
|
|
if resp.Code != http.StatusConflict {
|
|
t.Errorf("Double complete: got %d, want 409", resp.Code)
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// #15 — Status on non-workflow channel returns 404
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
func TestWorkflowGetStatus_NonWorkflowChannel(t *testing.T) {
|
|
h := setupWorkflowInstanceHarness(t)
|
|
|
|
resp := h.request("GET", "/api/v1/channels/00000000-0000-0000-0000-000000000000/workflow/status", h.adminToken, nil)
|
|
if resp.Code != http.StatusNotFound {
|
|
t.Errorf("Status on non-existent channel: got %d, want 404", resp.Code)
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// #15 — Data merge across multiple advances
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
// TestWorkflowAdvance_DataMerge verifies that stage data accumulates
|
|
// across multiple advance calls (merge, not replace).
|
|
func TestWorkflowAdvance_DataMerge(t *testing.T) {
|
|
h := setupWorkflowInstanceHarness(t)
|
|
wfID, _ := h.createPublishedWorkflow("Merge Test", 3)
|
|
|
|
resp := h.request("POST", "/api/v1/workflows/"+wfID+"/start", h.adminToken, nil)
|
|
var start struct{ ChannelID string `json:"channel_id"` }
|
|
json.Unmarshal(resp.Body.Bytes(), &start)
|
|
chID := start.ChannelID
|
|
|
|
// Advance stage 0 → 1 with key "a"
|
|
h.request("POST", "/api/v1/channels/"+chID+"/workflow/advance", h.adminToken, map[string]interface{}{
|
|
"data": map[string]string{"a": "1"},
|
|
})
|
|
|
|
// Advance stage 1 → 2 with key "b"
|
|
h.request("POST", "/api/v1/channels/"+chID+"/workflow/advance", h.adminToken, map[string]interface{}{
|
|
"data": map[string]string{"b": "2"},
|
|
})
|
|
|
|
// Check accumulated data contains both keys
|
|
resp = h.request("GET", "/api/v1/channels/"+chID+"/workflow/status", h.adminToken, nil)
|
|
var status struct{ StageData json.RawMessage `json:"stage_data"` }
|
|
json.Unmarshal(resp.Body.Bytes(), &status)
|
|
var merged map[string]string
|
|
json.Unmarshal(status.StageData, &merged)
|
|
|
|
if merged["a"] != "1" {
|
|
t.Errorf("Merged data missing key 'a': got %v", merged)
|
|
}
|
|
if merged["b"] != "2" {
|
|
t.Errorf("Merged data missing key 'b': got %v", merged)
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// #17 — PATCH non-existent workflow
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
// TestWorkflowPatchNonExistent verifies PATCH to a non-existent ID returns 404.
|
|
// Documents the confusing code path: Update affects 0 rows (no error),
|
|
// then GetByID returns sql.ErrNoRows → 404.
|
|
func TestWorkflowPatchNonExistent(t *testing.T) {
|
|
h := setupWorkflowInstanceHarness(t)
|
|
|
|
resp := h.request("PATCH", "/api/v1/workflows/00000000-0000-0000-0000-000000000000", h.adminToken, map[string]interface{}{
|
|
"name": "ghost",
|
|
})
|
|
if resp.Code != http.StatusNotFound {
|
|
t.Errorf("PATCH non-existent: got %d, want 404", resp.Code)
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// #19 — ListForTeam with ?status= filter
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
// TestWorkflowAssignment_ListForTeamStatusFilter verifies the ?status=
|
|
// query param on GET /teams/:teamId/assignments.
|
|
func TestWorkflowAssignment_ListForTeamStatusFilter(t *testing.T) {
|
|
h := setupWorkflowInstanceHarness(t)
|
|
|
|
// Create team
|
|
resp := h.request("POST", "/api/v1/admin/teams", h.adminToken, map[string]string{
|
|
"name": "Filter Team", "description": "Test",
|
|
})
|
|
if resp.Code != http.StatusCreated {
|
|
t.Fatalf("Create team: %d: %s", resp.Code, resp.Body.String())
|
|
}
|
|
var team map[string]interface{}
|
|
decode(resp, &team)
|
|
teamID := team["id"].(string)
|
|
|
|
// Add self as member (needed for access)
|
|
h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), h.adminToken,
|
|
map[string]string{"user_id": h.adminID, "role": "admin"})
|
|
|
|
// Create real channels for FK satisfaction
|
|
ch1ID := database.SeedTestChannel(t, h.adminID, "filter-ch-1")
|
|
ch2ID := database.SeedTestChannel(t, h.adminID, "filter-ch-2")
|
|
|
|
// Seed two assignments: one unassigned, one claimed
|
|
seedExec(t,
|
|
`INSERT INTO workflow_assignments (channel_id, stage, team_id, status) VALUES ($1, $2, $3, $4)`,
|
|
ch1ID, 0, teamID, "unassigned",
|
|
)
|
|
seedExec(t,
|
|
`INSERT INTO workflow_assignments (channel_id, stage, team_id, status, assigned_to) VALUES ($1, $2, $3, $4, $5)`,
|
|
ch2ID, 1, teamID, "claimed", h.adminID,
|
|
)
|
|
|
|
// Default (unassigned)
|
|
resp = h.request("GET", "/api/v1/teams/"+teamID+"/assignments", h.adminToken, nil)
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("ListForTeam default: %d: %s", resp.Code, resp.Body.String())
|
|
}
|
|
var lr struct {
|
|
Data []struct{ Status string `json:"status"` } `json:"data"`
|
|
}
|
|
json.Unmarshal(resp.Body.Bytes(), &lr)
|
|
if len(lr.Data) != 1 {
|
|
t.Errorf("Default filter: expected 1 unassigned, got %d", len(lr.Data))
|
|
} else if lr.Data[0].Status != "unassigned" {
|
|
t.Errorf("Default filter: expected status 'unassigned', got %q", lr.Data[0].Status)
|
|
}
|
|
|
|
// Explicit ?status=claimed
|
|
resp = h.request("GET", "/api/v1/teams/"+teamID+"/assignments?status=claimed", h.adminToken, nil)
|
|
json.Unmarshal(resp.Body.Bytes(), &lr)
|
|
if len(lr.Data) != 1 {
|
|
t.Errorf("?status=claimed: expected 1, got %d", len(lr.Data))
|
|
} else if lr.Data[0].Status != "claimed" {
|
|
t.Errorf("?status=claimed: expected status 'claimed', got %q", lr.Data[0].Status)
|
|
}
|
|
}
|