This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/workflow/routing_test.go
Jeffrey Smith 20572ef665
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-runners (pull_request) Has been skipped
CI/CD / e2e-smoke (pull_request) Has been skipped
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-sqlite (pull_request) Successful in 2m54s
CI/CD / test-go-pg (pull_request) Successful in 2m56s
CI/CD / build-and-deploy (pull_request) Successful in 1m1s
Feat v0.7.6 code hygiene + test coverage
Clean up channels-era dead code, align migrations, add 92 tests,
and fix backup download + admin storage tab bugs before v0.8.x
kernel expansion.

Critical fixes:
- Remove `channels` from allowedViews in db_module.go
- Fix 5 dead API routes in workflow.html → public workflow API
- Fix RenderWorkflow handler to use route param as entry token

Dead code removal:
- Delete SeedTestChannel(), RunContext.ChannelID, webhook.ChannelID
- Fix stale comments in storage.go, prometheus.go, workflow_module.go

Migration hygiene:
- Add SQLite placeholder 013_cluster_registry.sql, renumber to 014
- Compat rename in migrate.go for existing SQLite databases
- Document missing migration 008 in both 009 files

Test coverage:
- 82 workflow routing tests (all operators, branch rules, stage resolution)
- 10 middleware tests (permissions, admin, rate limiter)

Bug fixes:
- Remove dead Admin Storage tab from System category
- Fix backup download: sw.auth.token() → sw.auth._getToken()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:31:46 +00:00

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)
}
})
}
}