Feat v0.3.5 settings audit, ICD, clone + v0.3.6 roadmap
Some checks failed
CI/CD / detect-changes (pull_request) Successful in 20s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Failing after 2m42s
CI/CD / test-sqlite (pull_request) Successful in 3m0s
CI/CD / build-and-deploy (pull_request) Has been skipped
Some checks failed
CI/CD / detect-changes (pull_request) Successful in 20s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Failing after 2m42s
CI/CD / test-sqlite (pull_request) Successful in 3m0s
CI/CD / build-and-deploy (pull_request) Has been skipped
v0.3.5 deliverables: - Clone endpoint (POST /workflows/:id/clone) with deep stage copy - 7 engine integration tests (28 total): lifecycle, branch routing, public entry, signoff gate, rejection, cancel, error cases - Settings audit: staleness_timeout_hours + branch_rules UI in team-admin workflow editor - ICD: fixed stale schemas, added ~20 new endpoint paths for instances, assignments, signoffs, public entry, clone, team roles Roadmap additions (v0.3.6–v0.3.8): - v0.3.6: 4 example workflow packages + demo surface proving public entry, branch rules, Starlark automation, signoff gates, HTTP webhooks end-to-end - v0.3.7: package audit for non-chat packages - v0.3.8: builder image + bundled packages for zero-config first run Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
470
server/handlers/workflow_engine_test.go
Normal file
470
server/handlers/workflow_engine_test.go
Normal file
@@ -0,0 +1,470 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user