package handlers import ( "context" "encoding/json" "strings" "testing" "switchboard-core/database" "switchboard-core/models" "switchboard-core/workflow" ) // testEngine creates a workflow engine with nil bus and runner (safe for tests). func testEngine(t *testing.T) *workflow.Engine { t.Helper() s := testStores(t) return workflow.NewEngine(s, nil, nil) } // seedEngineFixture creates a 3-stage workflow (form → review → form), // publishes it, and returns (workflowID, userID, teamID). func seedEngineFixture(t *testing.T, slug string) (string, string, string) { t.Helper() s := testStores(t) ctx := context.Background() userID := database.SeedTestUser(t, "Eng-"+slug, slug+"@test.com") teamID := database.SeedTestTeam(t, "Team-"+slug, userID) wf := &models.Workflow{ TeamID: &teamID, Name: "Engine " + slug, Slug: slug, EntryMode: "team_only", IsActive: true, CreatedBy: userID, } if err := s.Workflows.Create(ctx, wf); err != nil { t.Fatalf("create workflow: %v", err) } stages := []models.WorkflowStage{ {WorkflowID: wf.ID, Ordinal: 0, Name: "intake", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple}, {WorkflowID: wf.ID, Ordinal: 1, Name: "review", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, AssignmentTeamID: &teamID}, {WorkflowID: wf.ID, Ordinal: 2, Name: "final", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple}, } for i := range stages { if err := s.Workflows.CreateStage(ctx, &stages[i]); err != nil { t.Fatalf("create stage %d: %v", i, err) } } snapshot, _ := json.Marshal(stages) ver := &models.WorkflowVersion{WorkflowID: wf.ID, VersionNumber: 1, Snapshot: snapshot} if err := s.Workflows.Publish(ctx, ver); err != nil { t.Fatalf("publish: %v", err) } return wf.ID, userID, teamID } // ── Full Lifecycle ────────────────────────── func TestEngine_FullLifecycle(t *testing.T) { database.RequireTestDB(t) database.TruncateAll(t) eng := testEngine(t) ctx := context.Background() wfID, userID, _ := seedEngineFixture(t, "lifecycle") // Start inst, err := eng.Start(ctx, wfID, json.RawMessage(`{"name":"Alice"}`), userID) if err != nil { t.Fatalf("start: %v", err) } if inst.Status != models.InstanceStatusActive { t.Fatalf("status = %q, want active", inst.Status) } if inst.CurrentStage != "intake" { t.Fatalf("stage = %q, want intake", inst.CurrentStage) } // Advance intake → review inst, err = eng.Advance(ctx, inst.ID, json.RawMessage(`{"submitted":true}`), userID) if err != nil { t.Fatalf("advance 1: %v", err) } if inst.CurrentStage != "review" { t.Fatalf("stage = %q, want review", inst.CurrentStage) } // Advance review → final inst, err = eng.Advance(ctx, inst.ID, json.RawMessage(`{"reviewed":true}`), userID) if err != nil { t.Fatalf("advance 2: %v", err) } if inst.CurrentStage != "final" { t.Fatalf("stage = %q, want final", inst.CurrentStage) } // Advance final → completed (past last stage) inst, err = eng.Advance(ctx, inst.ID, json.RawMessage(`{}`), userID) if err != nil { t.Fatalf("advance 3: %v", err) } if inst.Status != models.InstanceStatusCompleted { t.Fatalf("status = %q, want completed", inst.Status) } } // ── Branch Routing ────────────────────────── func TestEngine_BranchRouting(t *testing.T) { database.RequireTestDB(t) database.TruncateAll(t) s := testStores(t) eng := workflow.NewEngine(s, nil, nil) ctx := context.Background() userID := database.SeedTestUser(t, "Branch", "branch@test.com") teamID := database.SeedTestTeam(t, "Branch Team", userID) wf := &models.Workflow{ TeamID: &teamID, Name: "Branching WF", Slug: "branch-wf", EntryMode: "team_only", IsActive: true, CreatedBy: userID, } s.Workflows.Create(ctx, wf) branchRules, _ := json.Marshal([]map[string]any{ {"field": "priority", "op": "eq", "value": "high", "target_stage": "escalation"}, }) stages := []models.WorkflowStage{ {WorkflowID: wf.ID, Ordinal: 0, Name: "intake", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, BranchRules: branchRules}, {WorkflowID: wf.ID, Ordinal: 1, Name: "normal-review", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple}, {WorkflowID: wf.ID, Ordinal: 2, Name: "escalation", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple}, } for i := range stages { s.Workflows.CreateStage(ctx, &stages[i]) } snapshot, _ := json.Marshal(stages) s.Workflows.Publish(ctx, &models.WorkflowVersion{WorkflowID: wf.ID, VersionNumber: 1, Snapshot: snapshot}) // Test: priority=high → escalation inst, _ := eng.Start(ctx, wf.ID, json.RawMessage(`{}`), userID) inst, err := eng.Advance(ctx, inst.ID, json.RawMessage(`{"priority":"high"}`), userID) if err != nil { t.Fatalf("advance: %v", err) } if inst.CurrentStage != "escalation" { t.Fatalf("stage = %q, want escalation", inst.CurrentStage) } // Test: no priority → normal-review (ordinal+1) inst2, _ := eng.Start(ctx, wf.ID, json.RawMessage(`{}`), userID) inst2, err = eng.Advance(ctx, inst2.ID, json.RawMessage(`{"priority":"low"}`), userID) if err != nil { t.Fatalf("advance2: %v", err) } if inst2.CurrentStage != "normal-review" { t.Fatalf("stage = %q, want normal-review", inst2.CurrentStage) } } // ── Public Entry ──────────────────────────── func TestEngine_PublicEntry(t *testing.T) { database.RequireTestDB(t) database.TruncateAll(t) s := testStores(t) eng := workflow.NewEngine(s, nil, nil) ctx := context.Background() userID := database.SeedTestUser(t, "PubUser", "pub@test.com") teamID := database.SeedTestTeam(t, "Pub Team", userID) wf := &models.Workflow{ TeamID: &teamID, Name: "Public WF", Slug: "public-wf", EntryMode: "public_link", IsActive: true, CreatedBy: userID, } s.Workflows.Create(ctx, wf) stages := []models.WorkflowStage{ {WorkflowID: wf.ID, Ordinal: 0, Name: "public-form", StageMode: models.StageModeForm, Audience: models.AudiencePublic, StageType: models.StageTypeSimple}, {WorkflowID: wf.ID, Ordinal: 1, Name: "team-review", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple}, } for i := range stages { s.Workflows.CreateStage(ctx, &stages[i]) } snapshot, _ := json.Marshal(stages) s.Workflows.Publish(ctx, &models.WorkflowVersion{WorkflowID: wf.ID, VersionNumber: 1, Snapshot: snapshot}) // StartPublic inst, err := eng.StartPublic(ctx, wf.ID, json.RawMessage(`{"email":"anon@example.com"}`)) if err != nil { t.Fatalf("start public: %v", err) } if inst.EntryToken == nil || *inst.EntryToken == "" { t.Fatal("expected entry_token") } token := *inst.EntryToken // ResumePublic resumed, err := eng.ResumePublic(ctx, token) if err != nil { t.Fatalf("resume: %v", err) } if resumed.ID != inst.ID { t.Fatalf("resumed different instance") } // AdvancePublic on public stage — should succeed inst, err = eng.AdvancePublic(ctx, token, json.RawMessage(`{"submitted":true}`)) if err != nil { t.Fatalf("advance public: %v", err) } if inst.CurrentStage != "team-review" { t.Fatalf("stage = %q, want team-review", inst.CurrentStage) } // AdvancePublic on team stage — should fail _, err = eng.AdvancePublic(ctx, token, json.RawMessage(`{}`)) if err == nil { t.Fatal("expected error for non-public stage") } if !strings.Contains(err.Error(), "authenticated") { t.Fatalf("error = %q, want 'authenticated' mention", err.Error()) } } // ── Signoff Validation Gate ───────────────── func TestEngine_SignoffGate(t *testing.T) { database.RequireTestDB(t) database.TruncateAll(t) s := testStores(t) eng := workflow.NewEngine(s, nil, nil) ctx := context.Background() user1 := database.SeedTestUser(t, "Signer1", "s1@test.com") user2 := database.SeedTestUser(t, "Signer2", "s2@test.com") teamID := database.SeedTestTeam(t, "Gate Team", user1) wf := &models.Workflow{ TeamID: &teamID, Name: "Gate WF", Slug: "gate-wf", EntryMode: "team_only", IsActive: true, CreatedBy: user1, } s.Workflows.Create(ctx, wf) validationCfg, _ := json.Marshal(map[string]any{ "validation": map[string]any{"required_approvals": 2}, }) stages := []models.WorkflowStage{ {WorkflowID: wf.ID, Ordinal: 0, Name: "gated-stage", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, StageConfig: validationCfg}, {WorkflowID: wf.ID, Ordinal: 1, Name: "done", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple}, } for i := range stages { s.Workflows.CreateStage(ctx, &stages[i]) } snapshot, _ := json.Marshal(stages) s.Workflows.Publish(ctx, &models.WorkflowVersion{WorkflowID: wf.ID, VersionNumber: 1, Snapshot: snapshot}) inst, _ := eng.Start(ctx, wf.ID, json.RawMessage(`{}`), user1) // 1 approval — advance should fail eng.SubmitSignoff(ctx, inst.ID, user1, models.SignoffApprove, "ok") _, err := eng.Advance(ctx, inst.ID, json.RawMessage(`{}`), user1) if err == nil { t.Fatal("expected insufficient approvals error") } if !strings.Contains(err.Error(), "insufficient approvals") { t.Fatalf("error = %q, want 'insufficient approvals'", err.Error()) } // 2nd approval — advance should succeed eng.SubmitSignoff(ctx, inst.ID, user2, models.SignoffApprove, "also ok") inst, err = eng.Advance(ctx, inst.ID, json.RawMessage(`{}`), user1) if err != nil { t.Fatalf("advance with 2 approvals: %v", err) } if inst.CurrentStage != "done" { t.Fatalf("stage = %q, want done", inst.CurrentStage) } } // ── Signoff Rejection ─────────────────────── func TestEngine_SignoffRejection(t *testing.T) { database.RequireTestDB(t) database.TruncateAll(t) s := testStores(t) eng := workflow.NewEngine(s, nil, nil) ctx := context.Background() userID := database.SeedTestUser(t, "Rejector", "rej@test.com") teamID := database.SeedTestTeam(t, "Reject Team", userID) wf := &models.Workflow{ TeamID: &teamID, Name: "Reject WF", Slug: "reject-wf", EntryMode: "team_only", IsActive: true, CreatedBy: userID, } s.Workflows.Create(ctx, wf) validationCfg, _ := json.Marshal(map[string]any{ "validation": map[string]any{"required_approvals": 1, "reject_action": "cancel"}, }) stages := []models.WorkflowStage{ {WorkflowID: wf.ID, Ordinal: 0, Name: "review", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, StageConfig: validationCfg}, {WorkflowID: wf.ID, Ordinal: 1, Name: "approved", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple}, } for i := range stages { s.Workflows.CreateStage(ctx, &stages[i]) } snapshot, _ := json.Marshal(stages) s.Workflows.Publish(ctx, &models.WorkflowVersion{WorkflowID: wf.ID, VersionNumber: 1, Snapshot: snapshot}) inst, _ := eng.Start(ctx, wf.ID, json.RawMessage(`{}`), userID) // Submit rejection eng.SubmitSignoff(ctx, inst.ID, userID, models.SignoffReject, "not good") // Advance should cancel the instance inst, err := eng.Advance(ctx, inst.ID, json.RawMessage(`{}`), userID) if err != nil { t.Fatalf("advance after reject: %v", err) } if inst.Status != models.InstanceStatusCancelled { t.Fatalf("status = %q, want cancelled", inst.Status) } } // ── Cancel Clears Assignments ─────────────── func TestEngine_CancelClearsAssignments(t *testing.T) { database.RequireTestDB(t) database.TruncateAll(t) s := testStores(t) eng := workflow.NewEngine(s, nil, nil) ctx := context.Background() wfID, userID, _ := seedEngineFixture(t, "cancel-assign") inst, _ := eng.Start(ctx, wfID, json.RawMessage(`{}`), userID) // Advance to review stage (which has assignment_team_id) inst, _ = eng.Advance(ctx, inst.ID, json.RawMessage(`{}`), userID) if inst.CurrentStage != "review" { t.Fatalf("stage = %q, want review", inst.CurrentStage) } // Verify assignment was created assignments, _ := s.Workflows.ListAssignmentsByInstance(ctx, inst.ID) openCount := 0 for _, a := range assignments { if a.Status == models.AssignmentStatusUnassigned || a.Status == models.AssignmentStatusClaimed { openCount++ } } if openCount == 0 { t.Fatal("expected at least one open assignment") } // Claim one for _, a := range assignments { if a.Status == models.AssignmentStatusUnassigned && a.Stage == "review" { s.Workflows.ClaimAssignment(ctx, a.ID, userID) break } } // Cancel instance if err := eng.Cancel(ctx, inst.ID, userID); err != nil { t.Fatalf("cancel: %v", err) } // Verify all open assignments are cancelled assignments, _ = s.Workflows.ListAssignmentsByInstance(ctx, inst.ID) for _, a := range assignments { if a.Stage == "review" && a.Status != models.AssignmentStatusCancelled { t.Errorf("assignment %s status = %q, want cancelled", a.ID, a.Status) } } // Verify instance is cancelled inst2, _ := s.Workflows.GetInstance(ctx, inst.ID) if inst2.Status != models.InstanceStatusCancelled { t.Fatalf("instance status = %q, want cancelled", inst2.Status) } } // ── Error Cases ───────────────────────────── func TestEngine_ErrorCases(t *testing.T) { database.RequireTestDB(t) database.TruncateAll(t) s := testStores(t) eng := workflow.NewEngine(s, nil, nil) ctx := context.Background() wfID, userID, _ := seedEngineFixture(t, "errors") // Start on inactive workflow inactive := &models.Workflow{ Name: "Inactive", Slug: "inactive-wf", EntryMode: "team_only", IsActive: false, CreatedBy: userID, } s.Workflows.Create(ctx, inactive) _, err := eng.Start(ctx, inactive.ID, json.RawMessage(`{}`), userID) if err == nil { t.Fatal("expected error starting inactive workflow") } // Advance on completed instance inst, _ := eng.Start(ctx, wfID, json.RawMessage(`{}`), userID) s.Workflows.CompleteInstance(ctx, inst.ID) _, err = eng.Advance(ctx, inst.ID, json.RawMessage(`{}`), userID) if err == nil { t.Fatal("expected error advancing completed instance") } if !strings.Contains(err.Error(), "not active") { t.Fatalf("error = %q, want 'not active'", err.Error()) } // Cancel on already-cancelled instance inst2, _ := eng.Start(ctx, wfID, json.RawMessage(`{}`), userID) eng.Cancel(ctx, inst2.ID, userID) err = eng.Cancel(ctx, inst2.ID, userID) if err == nil { t.Fatal("expected error cancelling already-cancelled instance") } // Double claim inst3, _ := eng.Start(ctx, wfID, json.RawMessage(`{}`), userID) eng.Advance(ctx, inst3.ID, json.RawMessage(`{}`), userID) // → review (creates assignment) assignments, _ := s.Workflows.ListAssignmentsByInstance(ctx, inst3.ID) var reviewAssign string for _, a := range assignments { if a.Stage == "review" && a.Status == models.AssignmentStatusUnassigned { reviewAssign = a.ID break } } if reviewAssign != "" { s.Workflows.ClaimAssignment(ctx, reviewAssign, userID) err = s.Workflows.ClaimAssignment(ctx, reviewAssign, userID) if err == nil { t.Fatal("expected error on double claim") } } // Duplicate signoff inst4, _ := eng.Start(ctx, wfID, json.RawMessage(`{}`), userID) so := &models.WorkflowSignoff{InstanceID: inst4.ID, Stage: "intake", UserID: userID, Decision: models.SignoffApprove} s.Workflows.CreateSignoff(ctx, so) dup := &models.WorkflowSignoff{InstanceID: inst4.ID, Stage: "intake", UserID: userID, Decision: models.SignoffApprove} err = s.Workflows.CreateSignoff(ctx, dup) if err == nil { t.Fatal("expected UNIQUE violation for duplicate signoff") } // StartPublic on team_only workflow _, err = eng.StartPublic(ctx, wfID, json.RawMessage(`{}`)) if err == nil { t.Fatal("expected error for StartPublic on team_only workflow") } if !strings.Contains(err.Error(), "public entry") { t.Fatalf("error = %q, want 'public entry'", err.Error()) } } // ── Automated Stage Context (started_by) ──── func TestEngine_AutomatedStageContextIncludesStartedBy(t *testing.T) { database.RequireTestDB(t) database.TruncateAll(t) s := testStores(t) ctx := context.Background() userID := database.SeedTestUser(t, "AutoCtx", "autoctx@test.com") teamID := database.SeedTestTeam(t, "AutoCtx Team", userID) wf := &models.Workflow{ TeamID: &teamID, Name: "AutoCtx WF", Slug: "autoctx-wf", EntryMode: "team_only", IsActive: true, CreatedBy: userID, } s.Workflows.Create(ctx, wf) hook := "test-pkg:on_run" stages := []models.WorkflowStage{ {WorkflowID: wf.ID, Ordinal: 0, Name: "intake", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple}, {WorkflowID: wf.ID, Ordinal: 1, Name: "auto-stage", StageMode: models.StageModeAutomated, Audience: models.AudienceSystem, StageType: models.StageTypeAutomated, AutoTransition: true, StarlarkHook: &hook}, {WorkflowID: wf.ID, Ordinal: 2, Name: "done", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple}, } for i := range stages { s.Workflows.CreateStage(ctx, &stages[i]) } snapshot, _ := json.Marshal(stages) s.Workflows.Publish(ctx, &models.WorkflowVersion{WorkflowID: wf.ID, VersionNumber: 1, Snapshot: snapshot}) // Engine with no runner — automated stage silently skips, instance stays at auto-stage eng := workflow.NewEngine(s, nil, nil) inst, err := eng.Start(ctx, wf.ID, json.RawMessage(`{}`), userID) if err != nil { t.Fatalf("start: %v", err) } // Advance intake → auto-stage inst, err = eng.Advance(ctx, inst.ID, json.RawMessage(`{"submitted":true}`), userID) if err != nil { t.Fatalf("advance to auto: %v", err) } // Verify the instance records started_by got, _ := s.Workflows.GetInstance(ctx, inst.ID) if got.StartedBy != userID { t.Fatalf("started_by = %q, want %q", got.StartedBy, userID) } } // ── SLA Seconds in Package Install ────────── func TestPackageInstall_SLASeconds(t *testing.T) { database.RequireTestDB(t) database.TruncateAll(t) s := testStores(t) ctx := context.Background() userID := database.SeedTestUser(t, "SLAPkg", "slapkg@test.com") teamID := database.SeedTestTeam(t, "SLA Team", userID) // Simulate workflow package install with sla_seconds wf := &models.Workflow{ TeamID: &teamID, Name: "SLA WF", Slug: "sla-wf", EntryMode: "team_only", IsActive: true, CreatedBy: userID, } s.Workflows.Create(ctx, wf) sla := 3600 stage := &models.WorkflowStage{ WorkflowID: wf.ID, Ordinal: 0, Name: "critical-fix", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, SLASeconds: &sla, } if err := s.Workflows.CreateStage(ctx, stage); err != nil { t.Fatalf("create stage: %v", err) } // Read it back stages, err := s.Workflows.ListStages(ctx, wf.ID) if err != nil { t.Fatalf("list stages: %v", err) } if len(stages) != 1 { t.Fatalf("got %d stages, want 1", len(stages)) } if stages[0].SLASeconds == nil || *stages[0].SLASeconds != 3600 { t.Fatalf("sla_seconds = %v, want 3600", stages[0].SLASeconds) } } // ── Workflow Package Manifest Install ──────── func TestPackageInstall_ManifestRoundtrip(t *testing.T) { database.RequireTestDB(t) database.TruncateAll(t) s := testStores(t) ctx := context.Background() userID := database.SeedTestUser(t, "PkgInst", "pkginst@test.com") _ = database.SeedTestTeam(t, "PkgInst Team", userID) // Create a workflow manually matching bug-report-triage structure wf := &models.Workflow{ Name: "Bug Report Triage", Slug: "bug-report-triage-test", EntryMode: "public_link", IsActive: true, CreatedBy: userID, } if err := s.Workflows.Create(ctx, wf); err != nil { t.Fatalf("create workflow: %v", err) } sla := 3600 branchRules, _ := json.Marshal([]map[string]any{ {"field": "severity", "op": "eq", "value": "critical", "target_stage": "fix-critical"}, {"field": "severity", "op": "neq", "value": "critical", "target_stage": "fix-normal"}, }) stages := []models.WorkflowStage{ {WorkflowID: wf.ID, Ordinal: 0, Name: "submit", StageMode: models.StageModeForm, Audience: models.AudiencePublic, StageType: models.StageTypeSimple}, {WorkflowID: wf.ID, Ordinal: 1, Name: "classify", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, BranchRules: branchRules}, {WorkflowID: wf.ID, Ordinal: 2, Name: "fix-critical", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, SLASeconds: &sla}, {WorkflowID: wf.ID, Ordinal: 3, Name: "fix-normal", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple}, {WorkflowID: wf.ID, Ordinal: 4, Name: "verify", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple}, } for i := range stages { if err := s.Workflows.CreateStage(ctx, &stages[i]); err != nil { t.Fatalf("create stage %d: %v", i, err) } } // Verify all stages got, err := s.Workflows.ListStages(ctx, wf.ID) if err != nil { t.Fatalf("list stages: %v", err) } if len(got) != 5 { t.Fatalf("got %d stages, want 5", len(got)) } // Verify branch rules on classify for _, g := range got { if g.Name == "classify" { if len(g.BranchRules) == 0 { t.Fatal("classify missing branch_rules") } if !strings.Contains(string(g.BranchRules), "fix-critical") { t.Fatalf("branch_rules missing fix-critical target: %s", string(g.BranchRules)) } } if g.Name == "fix-critical" { if g.SLASeconds == nil || *g.SLASeconds != 3600 { t.Fatalf("fix-critical sla_seconds = %v, want 3600", g.SLASeconds) } } if g.Name == "submit" && g.Audience != models.AudiencePublic { t.Fatalf("submit audience = %q, want public", g.Audience) } } }