package workflow import ( "encoding/json" "testing" "armature/models" ) // ── helpers ──────────────────────────────────────────── func mkStages(names ...string) []models.WorkflowStage { out := make([]models.WorkflowStage, len(names)) for i, n := range names { out[i] = models.WorkflowStage{ ID: n, WorkflowID: "wf-test", Ordinal: i, Name: n, } } return out } func withBranchRules(stages []models.WorkflowStage, idx int, rules []Condition) []models.WorkflowStage { b, _ := json.Marshal(rules) stages[idx].BranchRules = b return stages } func mustJSON(v any) json.RawMessage { b, _ := json.Marshal(v) return b } // ── ResolveNextStage ─────────────────────────────────── func TestResolveNextStage_NoRules(t *testing.T) { stages := mkStages("intake", "review", "done") got, err := ResolveNextStage(stages, 0, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } if got != 1 { t.Errorf("expected ordinal 1, got %d", got) } } func TestResolveNextStage_EmptyRulesArray(t *testing.T) { stages := mkStages("intake", "review", "done") stages[0].BranchRules = json.RawMessage(`[]`) got, err := ResolveNextStage(stages, 0, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } if got != 1 { t.Errorf("expected ordinal 1, got %d", got) } } func TestResolveNextStage_SingleMatchingRule(t *testing.T) { stages := mkStages("intake", "review", "escalation", "done") rules := []Condition{{Field: "priority", Op: "eq", Value: "high", TargetStage: "escalation"}} withBranchRules(stages, 0, rules) data := mustJSON(map[string]any{"priority": "high"}) got, err := ResolveNextStage(stages, 0, data) if err != nil { t.Fatalf("unexpected error: %v", err) } if got != 2 { t.Errorf("expected ordinal 2 (escalation), got %d", got) } } func TestResolveNextStage_FirstMatchWins(t *testing.T) { stages := mkStages("intake", "fast-track", "escalation", "done") rules := []Condition{ {Field: "priority", Op: "eq", Value: "high", TargetStage: "fast-track"}, {Field: "priority", Op: "eq", Value: "high", TargetStage: "escalation"}, } withBranchRules(stages, 0, rules) data := mustJSON(map[string]any{"priority": "high"}) got, err := ResolveNextStage(stages, 0, data) if err != nil { t.Fatalf("unexpected error: %v", err) } if got != 1 { t.Errorf("expected ordinal 1 (fast-track), got %d", got) } } func TestResolveNextStage_NoMatchFallback(t *testing.T) { stages := mkStages("intake", "review", "done") rules := []Condition{{Field: "priority", Op: "eq", Value: "critical", TargetStage: "done"}} withBranchRules(stages, 0, rules) data := mustJSON(map[string]any{"priority": "low"}) got, err := ResolveNextStage(stages, 0, data) if err != nil { t.Fatalf("unexpected error: %v", err) } if got != 1 { t.Errorf("expected fallback ordinal 1, got %d", got) } } func TestResolveNextStage_OutOfBounds(t *testing.T) { stages := mkStages("intake", "done") tests := []struct { name string current int want int }{ {"negative", -1, 0}, {"beyond end", 5, 6}, {"equal to len", 2, 3}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := ResolveNextStage(stages, tt.current, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } if got != tt.want { t.Errorf("expected %d, got %d", tt.want, got) } }) } } func TestResolveNextStage_NilAndEmptyStageData(t *testing.T) { stages := mkStages("intake", "review", "done") rules := []Condition{{Field: "status", Op: "exists", TargetStage: "done"}} withBranchRules(stages, 0, rules) // nil stageData — "exists" should be false, fallback to ordinal+1 got, err := ResolveNextStage(stages, 0, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } if got != 1 { t.Errorf("nil data: expected 1, got %d", got) } // empty object — "exists" should still be false got, err = ResolveNextStage(stages, 0, json.RawMessage(`{}`)) if err != nil { t.Fatalf("unexpected error: %v", err) } if got != 1 { t.Errorf("empty data: expected 1, got %d", got) } } func TestResolveNextStage_TerminalStage(t *testing.T) { stages := mkStages("intake", "review", "done") // At last stage with no rules, ordinal+1 == len(stages) which is past end got, err := ResolveNextStage(stages, 2, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } if got != 3 { t.Errorf("expected terminal ordinal 3, got %d", got) } } func TestResolveNextStage_TargetByOrdinalString(t *testing.T) { stages := mkStages("intake", "review", "done") rules := []Condition{{Field: "skip", Op: "eq", Value: "yes", TargetStage: "2"}} withBranchRules(stages, 0, rules) data := mustJSON(map[string]any{"skip": "yes"}) got, err := ResolveNextStage(stages, 0, data) if err != nil { t.Fatalf("unexpected error: %v", err) } if got != 2 { t.Errorf("expected ordinal 2, got %d", got) } } func TestResolveNextStage_InvalidTarget(t *testing.T) { stages := mkStages("intake", "review", "done") rules := []Condition{{Field: "go", Op: "eq", Value: "yes", TargetStage: "nonexistent"}} withBranchRules(stages, 0, rules) data := mustJSON(map[string]any{"go": "yes"}) _, err := ResolveNextStage(stages, 0, data) if err == nil { t.Fatalf("expected error for invalid target, got nil") } } // ── ResolveStageByName ───────────────────────────────── func TestResolveStageByName(t *testing.T) { stages := mkStages("Intake", "Review", "Done") tests := []struct { name string input string want int wantErr bool }{ {"exact match", "Intake", 0, false}, {"case insensitive lower", "review", 1, false}, {"case insensitive upper", "DONE", 2, false}, {"mixed case", "rEvIeW", 1, false}, {"leading whitespace", " Intake", 0, false}, {"trailing whitespace", "Done ", 2, false}, {"both whitespace", " Review ", 1, false}, {"not found", "nonexistent", 0, true}, {"empty string", "", 0, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := ResolveStageByName(stages, tt.input) if tt.wantErr { if err == nil { t.Fatalf("expected error, got nil") } return } if err != nil { t.Fatalf("unexpected error: %v", err) } if got != tt.want { t.Errorf("expected ordinal %d, got %d", tt.want, got) } }) } } // ── ParseStageConfig ─────────────────────────────────── func TestParseStageConfig(t *testing.T) { tests := []struct { name string input json.RawMessage checkField string // which field to validate wantStr string wantInt int }{ { name: "valid with auto_assign", input: json.RawMessage(`{"auto_assign":"team-lead","required_role":"manager"}`), checkField: "auto_assign", wantStr: "team-lead", }, { name: "valid with required_role", input: json.RawMessage(`{"required_role":"admin"}`), checkField: "required_role", wantStr: "admin", }, { name: "valid with validation", input: json.RawMessage(`{"validation":{"required_approvals":3}}`), checkField: "validation_approvals", wantInt: 3, }, { name: "empty input", input: nil, checkField: "auto_assign", wantStr: "", }, { name: "null string", input: json.RawMessage(`null`), checkField: "auto_assign", wantStr: "", }, { name: "empty object", input: json.RawMessage(`{}`), checkField: "auto_assign", wantStr: "", }, { name: "invalid json", input: json.RawMessage(`{bad json`), checkField: "auto_assign", wantStr: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { sc := ParseStageConfig(tt.input) switch tt.checkField { case "auto_assign": if sc.AutoAssign != tt.wantStr { t.Errorf("AutoAssign: expected %q, got %q", tt.wantStr, sc.AutoAssign) } case "required_role": if sc.RequiredRole != tt.wantStr { t.Errorf("RequiredRole: expected %q, got %q", tt.wantStr, sc.RequiredRole) } case "validation_approvals": if sc.Validation == nil { t.Fatalf("expected Validation to be set") } if sc.Validation.RequiredApprovals != tt.wantInt { t.Errorf("RequiredApprovals: expected %d, got %d", tt.wantInt, sc.Validation.RequiredApprovals) } } }) } } // ── evaluateCondition ────────────────────────────────── func TestEvaluateCondition_Exists(t *testing.T) { data := map[string]any{"name": "alice", "age": 30} tests := []struct { name string cond Condition want bool }{ {"exists present", Condition{Field: "name", Op: "exists"}, true}, {"exists absent", Condition{Field: "missing", Op: "exists"}, false}, {"not_exists absent", Condition{Field: "missing", Op: "not_exists"}, true}, {"not_exists present", Condition{Field: "name", Op: "not_exists"}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := evaluateCondition(tt.cond, data) if got != tt.want { t.Errorf("expected %v, got %v", tt.want, got) } }) } } func TestEvaluateCondition_EqNeq(t *testing.T) { data := map[string]any{"status": "open", "count": float64(5)} tests := []struct { name string cond Condition want bool }{ {"eq string match", Condition{Field: "status", Op: "eq", Value: "open"}, true}, {"eq string no match", Condition{Field: "status", Op: "eq", Value: "closed"}, false}, {"neq string match", Condition{Field: "status", Op: "neq", Value: "closed"}, true}, {"neq string no match", Condition{Field: "status", Op: "neq", Value: "open"}, false}, {"eq numeric match", Condition{Field: "count", Op: "eq", Value: float64(5)}, true}, {"eq numeric no match", Condition{Field: "count", Op: "eq", Value: float64(10)}, false}, {"neq numeric", Condition{Field: "count", Op: "neq", Value: float64(3)}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := evaluateCondition(tt.cond, data) if got != tt.want { t.Errorf("expected %v, got %v", tt.want, got) } }) } } func TestEvaluateCondition_NumericComparisons(t *testing.T) { data := map[string]any{"score": float64(75)} tests := []struct { name string cond Condition want bool }{ {"gt true", Condition{Field: "score", Op: "gt", Value: float64(50)}, true}, {"gt false equal", Condition{Field: "score", Op: "gt", Value: float64(75)}, false}, {"gt false less", Condition{Field: "score", Op: "gt", Value: float64(100)}, false}, {"lt true", Condition{Field: "score", Op: "lt", Value: float64(100)}, true}, {"lt false equal", Condition{Field: "score", Op: "lt", Value: float64(75)}, false}, {"lt false greater", Condition{Field: "score", Op: "lt", Value: float64(50)}, false}, {"gte true greater", Condition{Field: "score", Op: "gte", Value: float64(50)}, true}, {"gte true equal", Condition{Field: "score", Op: "gte", Value: float64(75)}, true}, {"gte false", Condition{Field: "score", Op: "gte", Value: float64(100)}, false}, {"lte true less", Condition{Field: "score", Op: "lte", Value: float64(100)}, true}, {"lte true equal", Condition{Field: "score", Op: "lte", Value: float64(75)}, true}, {"lte false", Condition{Field: "score", Op: "lte", Value: float64(50)}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := evaluateCondition(tt.cond, data) if got != tt.want { t.Errorf("expected %v, got %v", tt.want, got) } }) } } func TestEvaluateCondition_In(t *testing.T) { data := map[string]any{"role": "admin"} tests := []struct { name string cond Condition want bool }{ {"in list match", Condition{Field: "role", Op: "in", Value: []any{"admin", "manager"}}, true}, {"in list no match", Condition{Field: "role", Op: "in", Value: []any{"user", "guest"}}, false}, {"in empty list", Condition{Field: "role", Op: "in", Value: []any{}}, false}, {"in non-list value", Condition{Field: "role", Op: "in", Value: "admin"}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := evaluateCondition(tt.cond, data) if got != tt.want { t.Errorf("expected %v, got %v", tt.want, got) } }) } } func TestEvaluateCondition_Contains(t *testing.T) { data := map[string]any{"description": "urgent: fix production bug"} tests := []struct { name string cond Condition want bool }{ {"contains match", Condition{Field: "description", Op: "contains", Value: "urgent"}, true}, {"contains substring", Condition{Field: "description", Op: "contains", Value: "production"}, true}, {"contains no match", Condition{Field: "description", Op: "contains", Value: "minor"}, false}, {"contains empty target", Condition{Field: "description", Op: "contains", Value: ""}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := evaluateCondition(tt.cond, data) if got != tt.want { t.Errorf("expected %v, got %v", tt.want, got) } }) } } func TestEvaluateCondition_MissingField(t *testing.T) { data := map[string]any{"present": "yes"} ops := []string{"eq", "neq", "gt", "lt", "gte", "lte", "in", "contains"} for _, op := range ops { t.Run(op, func(t *testing.T) { cond := Condition{Field: "absent", Op: op, Value: "something"} got := evaluateCondition(cond, data) if got != false { t.Errorf("op %q on missing field: expected false, got true", op) } }) } } func TestEvaluateCondition_UnknownOperator(t *testing.T) { data := map[string]any{"field": "value"} cond := Condition{Field: "field", Op: "regex", Value: ".*"} got := evaluateCondition(cond, data) if got != false { t.Errorf("unknown operator: expected false, got true") } } func TestEvaluateCondition_EmptyData(t *testing.T) { data := map[string]any{} cond := Condition{Field: "anything", Op: "eq", Value: "test"} got := evaluateCondition(cond, data) if got != false { t.Errorf("empty data: expected false, got true") } } func TestEvaluateCondition_NumericStringComparison(t *testing.T) { // JSON unmarshalling produces float64 for numbers; test string numeric values too data := map[string]any{"amount": "100.5"} tests := []struct { name string cond Condition want bool }{ {"gt string num", Condition{Field: "amount", Op: "gt", Value: float64(50)}, true}, {"lt string num", Condition{Field: "amount", Op: "lt", Value: float64(200)}, true}, {"eq string num", Condition{Field: "amount", Op: "eq", Value: "100.5"}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := evaluateCondition(tt.cond, data) if got != tt.want { t.Errorf("expected %v, got %v", tt.want, got) } }) } }