All checks were successful
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m48s
CI/CD / build-and-deploy (push) Successful in 25s
CI/CD / test-sqlite (push) Successful in 2m55s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
498 lines
15 KiB
Go
498 lines
15 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|