Changeset 0.28.0.1 (#173)
This commit is contained in:
844
server/handlers/workflow_instance_test.go
Normal file
844
server/handlers/workflow_instance_test.go
Normal file
@@ -0,0 +1,844 @@
|
||||
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)
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
// 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()
|
||||
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(nil)
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(middleware.Auth(cfg), 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()
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user