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