Feat v0.7.6 code hygiene (#60)
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
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>
This commit was merged in pull request #60.
This commit is contained in:
497
server/workflow/routing_test.go
Normal file
497
server/workflow/routing_test.go
Normal file
@@ -0,0 +1,497 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user