- Add internal/workflow/ package for YAML workflow definitions (Load, LoadAll, Validate)
- Add internal/db/item_approvals.go repository (Create, AddSignature, GetWithSignatures, ListByItemWithSignatures, UpdateState, UpdateSignature)
- Add internal/api/approval_handlers.go with 4 endpoints:
- GET /{partNumber}/approvals (list approvals with signatures)
- POST /{partNumber}/approvals (create ECO with workflow + signers)
- POST /{partNumber}/approvals/{id}/sign (approve or reject)
- GET /workflows (list available workflow definitions)
- Rule-driven state transitions: any_reject and all_required_approve
- Pack approvals into silo/approvals.json on .kc checkout
- Add WorkflowsConfig to config, load workflows at startup
- Migration 019: add workflow_name column to item_approvals
- Example workflows: engineering-change.yaml, quick-review.yaml
- 7 workflow tests, all passing
Closes #145
168 lines
3.6 KiB
Go
168 lines
3.6 KiB
Go
package workflow
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func TestLoad_Valid(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test.yaml")
|
|
os.WriteFile(path, []byte(`
|
|
workflow:
|
|
name: test-wf
|
|
version: 1
|
|
description: "Test workflow"
|
|
states: [draft, pending, approved, rejected]
|
|
gates:
|
|
- role: reviewer
|
|
label: "Review"
|
|
required: true
|
|
rules:
|
|
any_reject: rejected
|
|
all_required_approve: approved
|
|
`), 0644)
|
|
|
|
w, err := Load(path)
|
|
if err != nil {
|
|
t.Fatalf("Load() error: %v", err)
|
|
}
|
|
if w.Name != "test-wf" {
|
|
t.Errorf("Name = %q, want %q", w.Name, "test-wf")
|
|
}
|
|
if w.Version != 1 {
|
|
t.Errorf("Version = %d, want 1", w.Version)
|
|
}
|
|
if len(w.Gates) != 1 {
|
|
t.Fatalf("Gates count = %d, want 1", len(w.Gates))
|
|
}
|
|
if w.Gates[0].Role != "reviewer" {
|
|
t.Errorf("Gates[0].Role = %q, want %q", w.Gates[0].Role, "reviewer")
|
|
}
|
|
}
|
|
|
|
func TestLoad_MissingState(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "bad.yaml")
|
|
os.WriteFile(path, []byte(`
|
|
workflow:
|
|
name: bad
|
|
states: [draft, pending]
|
|
gates:
|
|
- role: r
|
|
label: "R"
|
|
required: true
|
|
`), 0644)
|
|
|
|
_, err := Load(path)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing required states")
|
|
}
|
|
}
|
|
|
|
func TestLoad_NoGates(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "no-gates.yaml")
|
|
os.WriteFile(path, []byte(`
|
|
workflow:
|
|
name: no-gates
|
|
states: [draft, pending, approved, rejected]
|
|
gates: []
|
|
`), 0644)
|
|
|
|
_, err := Load(path)
|
|
if err == nil {
|
|
t.Fatal("expected error for no gates")
|
|
}
|
|
}
|
|
|
|
func TestLoadAll(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
os.WriteFile(filepath.Join(dir, "a.yaml"), []byte(`
|
|
workflow:
|
|
name: alpha
|
|
states: [draft, pending, approved, rejected]
|
|
gates:
|
|
- role: r
|
|
label: "R"
|
|
required: true
|
|
rules:
|
|
any_reject: rejected
|
|
all_required_approve: approved
|
|
`), 0644)
|
|
|
|
os.WriteFile(filepath.Join(dir, "b.yml"), []byte(`
|
|
workflow:
|
|
name: beta
|
|
states: [draft, pending, approved, rejected]
|
|
gates:
|
|
- role: r
|
|
label: "R"
|
|
required: true
|
|
rules:
|
|
any_reject: rejected
|
|
`), 0644)
|
|
|
|
// Non-yaml file should be ignored
|
|
os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("ignore me"), 0644)
|
|
|
|
wfs, err := LoadAll(dir)
|
|
if err != nil {
|
|
t.Fatalf("LoadAll() error: %v", err)
|
|
}
|
|
if len(wfs) != 2 {
|
|
t.Fatalf("LoadAll() count = %d, want 2", len(wfs))
|
|
}
|
|
if wfs["alpha"] == nil {
|
|
t.Error("missing workflow 'alpha'")
|
|
}
|
|
if wfs["beta"] == nil {
|
|
t.Error("missing workflow 'beta'")
|
|
}
|
|
}
|
|
|
|
func TestRequiredGates(t *testing.T) {
|
|
w := &Workflow{
|
|
Gates: []Gate{
|
|
{Role: "engineer", Label: "Eng", Required: true},
|
|
{Role: "quality", Label: "QA", Required: false},
|
|
{Role: "manager", Label: "Mgr", Required: true},
|
|
},
|
|
}
|
|
rg := w.RequiredGates()
|
|
if len(rg) != 2 {
|
|
t.Fatalf("RequiredGates() count = %d, want 2", len(rg))
|
|
}
|
|
if rg[0].Role != "engineer" || rg[1].Role != "manager" {
|
|
t.Errorf("RequiredGates() roles = %v, want [engineer, manager]", rg)
|
|
}
|
|
}
|
|
|
|
func TestHasRole(t *testing.T) {
|
|
w := &Workflow{
|
|
Gates: []Gate{
|
|
{Role: "engineer", Label: "Eng", Required: true},
|
|
},
|
|
}
|
|
if !w.HasRole("engineer") {
|
|
t.Error("HasRole(engineer) = false, want true")
|
|
}
|
|
if w.HasRole("manager") {
|
|
t.Error("HasRole(manager) = true, want false")
|
|
}
|
|
}
|
|
|
|
func TestValidate_InvalidRuleState(t *testing.T) {
|
|
w := &Workflow{
|
|
Name: "bad-rule",
|
|
States: []string{"draft", "pending", "approved", "rejected"},
|
|
Gates: []Gate{{Role: "r", Label: "R", Required: true}},
|
|
Rules: Rules{AnyReject: "nonexistent"},
|
|
}
|
|
if err := w.Validate(); err == nil {
|
|
t.Fatal("expected error for invalid rule state reference")
|
|
}
|
|
}
|