Files
kandev/apps/backend/internal/workflow/engine/quorum_test.go
T
2026-05-16 20:07:57 +01:00

421 lines
15 KiB
Go

package engine
import (
"context"
"errors"
"testing"
)
// fakeDecisionStore lets tests pre-populate decisions and watch writes.
type fakeDecisionStore struct {
byKey map[string][]DecisionInfo
cleared map[string]int64
recordErr error
clearedErr error
listErr error
}
func newFakeDecisionStore() *fakeDecisionStore {
return &fakeDecisionStore{byKey: map[string][]DecisionInfo{}, cleared: map[string]int64{}}
}
func dkey(taskID, stepID string) string { return taskID + "|" + stepID }
func (f *fakeDecisionStore) ListStepDecisions(_ context.Context, taskID, stepID string) ([]DecisionInfo, error) {
if f.listErr != nil {
return nil, f.listErr
}
return f.byKey[dkey(taskID, stepID)], nil
}
func (f *fakeDecisionStore) RecordStepDecision(_ context.Context, d DecisionInfo) error {
if f.recordErr != nil {
return f.recordErr
}
k := dkey(d.TaskID, d.StepID)
f.byKey[k] = append(f.byKey[k], d)
return nil
}
func (f *fakeDecisionStore) ClearStepDecisions(_ context.Context, taskID, stepID string) (int64, error) {
if f.clearedErr != nil {
return 0, f.clearedErr
}
k := dkey(taskID, stepID)
n := int64(len(f.byKey[k]))
delete(f.byKey, k)
f.cleared[k] += n
return n, nil
}
// stepStoreForQuorum is a TransitionStore where the step holds a single
// transition action gated by a quorum guard.
type stepStoreForQuorum struct {
state MachineState
step StepSpec
next StepSpec
applied map[string]bool
}
func (s *stepStoreForQuorum) LoadState(_ context.Context, _, _ string) (MachineState, error) {
return s.state, nil
}
func (s *stepStoreForQuorum) LoadStep(_ context.Context, _, _ string) (StepSpec, error) {
return s.step, nil
}
func (s *stepStoreForQuorum) LoadNextStep(_ context.Context, _ string, _ int) (StepSpec, error) {
return s.next, nil
}
func (s *stepStoreForQuorum) LoadPreviousStep(_ context.Context, _ string, _ int) (StepSpec, error) {
return StepSpec{}, nil
}
func (s *stepStoreForQuorum) ApplyTransition(_ context.Context, _, _, _, _ string, _ Trigger) error {
return nil
}
func (s *stepStoreForQuorum) PersistData(_ context.Context, _ string, _ map[string]any) error {
return nil
}
func (s *stepStoreForQuorum) IsOperationApplied(_ context.Context, op string) (bool, error) {
return s.applied[op], nil
}
func (s *stepStoreForQuorum) MarkOperationApplied(_ context.Context, op string) error {
s.applied[op] = true
return nil
}
func quorumStore(guard *TransitionGuard) *stepStoreForQuorum {
return &stepStoreForQuorum{
state: MachineState{TaskID: "task-1", SessionID: "sess-1", WorkflowID: "wf", CurrentStepID: "review"},
step: StepSpec{
ID: "review", WorkflowID: "wf", Position: 1,
Events: map[Trigger][]Action{
TriggerOnTurnComplete: {
{Kind: ActionMoveToNext, Guard: guard},
},
},
},
next: StepSpec{ID: "approval", Position: 2},
applied: map[string]bool{},
}
}
func TestApplyThreshold_AllApprove(t *testing.T) {
required := []ParticipantInfo{
{ID: "p1"}, {ID: "p2"},
}
cases := []struct {
name string
decisions []DecisionInfo
want bool
}{
{"all approved", []DecisionInfo{{ParticipantID: "p1", Decision: DecisionApproved}, {ParticipantID: "p2", Decision: DecisionApproved}}, true},
{"partial approved", []DecisionInfo{{ParticipantID: "p1", Decision: DecisionApproved}}, false},
{"none yet", nil, false},
{"approve+reject", []DecisionInfo{{ParticipantID: "p1", Decision: DecisionApproved}, {ParticipantID: "p2", Decision: DecisionRejected}}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := applyThreshold(QuorumAllApprove, required, tc.decisions); got != tc.want {
t.Fatalf("applyThreshold all_approve = %v, want %v", got, tc.want)
}
})
}
}
func TestApplyThreshold_AllDecide(t *testing.T) {
required := []ParticipantInfo{{ID: "p1"}, {ID: "p2"}}
d1 := []DecisionInfo{{ParticipantID: "p1", Decision: "approved"}, {ParticipantID: "p2", Decision: "rejected"}}
if !applyThreshold(QuorumAllDecide, required, d1) {
t.Fatalf("all_decide should be true when both decided")
}
d2 := []DecisionInfo{{ParticipantID: "p1", Decision: "approved"}}
if applyThreshold(QuorumAllDecide, required, d2) {
t.Fatalf("all_decide should be false when only one decided")
}
}
func TestApplyThreshold_AnyReject(t *testing.T) {
required := []ParticipantInfo{{ID: "p1"}, {ID: "p2"}}
d := []DecisionInfo{{ParticipantID: "p1", Decision: DecisionApproved}, {ParticipantID: "p2", Decision: DecisionRejected}}
if !applyThreshold(QuorumAnyReject, required, d) {
t.Fatalf("any_reject should be true when one rejected")
}
if applyThreshold(QuorumAnyReject, required, []DecisionInfo{{ParticipantID: "p1", Decision: DecisionApproved}}) {
t.Fatalf("any_reject should be false when no rejection")
}
}
func TestApplyThreshold_MajorityApprove(t *testing.T) {
required := []ParticipantInfo{{ID: "p1"}, {ID: "p2"}, {ID: "p3"}}
// 2/3 approve => majority
d := []DecisionInfo{{ParticipantID: "p1", Decision: DecisionApproved}, {ParticipantID: "p2", Decision: DecisionApproved}}
if !applyThreshold(QuorumMajorityApprove, required, d) {
t.Fatalf("majority_approve true expected for 2/3 approves")
}
// 1/3 approve, 1 reject => not majority
d2 := []DecisionInfo{{ParticipantID: "p1", Decision: DecisionApproved}, {ParticipantID: "p2", Decision: DecisionRejected}}
if applyThreshold(QuorumMajorityApprove, required, d2) {
t.Fatalf("majority_approve false expected when not strictly more than half")
}
}
func TestApplyThreshold_NApprove(t *testing.T) {
required := []ParticipantInfo{{ID: "p1"}, {ID: "p2"}, {ID: "p3"}}
d := []DecisionInfo{{ParticipantID: "p1", Decision: DecisionApproved}, {ParticipantID: "p2", Decision: DecisionApproved}}
if !applyThreshold("n_approve:2", required, d) {
t.Fatalf("n_approve:2 expected true")
}
if applyThreshold("n_approve:3", required, d) {
t.Fatalf("n_approve:3 expected false")
}
if applyThreshold("n_approve:notanint", required, d) {
t.Fatalf("malformed n_approve threshold should fail closed")
}
if applyThreshold("n_approve:0", required, d) {
t.Fatalf("n_approve:0 should fail closed")
}
}
func TestApplyThreshold_RemovedParticipantDropped(t *testing.T) {
// p1 was required and approved, then removed. p2 still required and approved.
required := []ParticipantInfo{{ID: "p2"}}
decisions := []DecisionInfo{
{ParticipantID: "p1", Decision: DecisionApproved},
{ParticipantID: "p2", Decision: DecisionApproved},
}
if !applyThreshold(QuorumAllApprove, required, decisions) {
t.Fatalf("all_approve should be true when removed participant's decision is ignored")
}
}
func TestApplyThreshold_LatestDecisionPerParticipant(t *testing.T) {
required := []ParticipantInfo{{ID: "p1"}}
// p1 approved then rejected
decisions := []DecisionInfo{
{ParticipantID: "p1", Decision: DecisionApproved},
{ParticipantID: "p1", Decision: DecisionRejected},
}
if applyThreshold(QuorumAllApprove, required, decisions) {
t.Fatalf("expected latest reject to override earlier approve")
}
if !applyThreshold(QuorumAnyReject, required, decisions) {
t.Fatalf("any_reject should be true given latest rejection")
}
}
func TestApplyThreshold_NoRequiredParticipants_FailsClosedHandledByCaller(t *testing.T) {
// applyThreshold itself, with empty required and a non-N threshold,
// returns true for all_approve (vacuously true). Engine wraps this and
// fails closed when len(required) == 0 — pinned in TestWaitForQuorum_*.
if !applyThreshold(QuorumAllApprove, nil, nil) {
t.Fatalf("documented behavior: applyThreshold returns true for vacuous all_approve")
}
}
func TestEngine_WaitForQuorum_BlocksUntilSatisfied(t *testing.T) {
store := quorumStore(&TransitionGuard{
WaitForQuorum: &WaitForQuorumGuard{Role: "reviewer", Threshold: QuorumAllApprove},
})
decisions := newFakeDecisionStore()
parts := fakeParticipants{list: []ParticipantInfo{
{ID: "p1", Role: "reviewer", DecisionRequired: true, AgentProfileID: "rev-A"},
{ID: "p2", Role: "reviewer", DecisionRequired: true, AgentProfileID: "rev-B"},
}}
eng := New(store, MapRegistry{}, WithDecisionStore(decisions), WithParticipantStore(parts))
res, err := eng.HandleTrigger(context.Background(), HandleInput{
TaskID: "task-1", SessionID: "sess-1", Trigger: TriggerOnTurnComplete,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.Transitioned {
t.Fatalf("expected guard to block transition with no decisions")
}
// Record one approval - still not enough for all_approve.
if err := decisions.RecordStepDecision(context.Background(), DecisionInfo{
TaskID: "task-1", StepID: "review", ParticipantID: "p1", Decision: DecisionApproved,
}); err != nil {
t.Fatalf("record: %v", err)
}
res, err = eng.HandleTrigger(context.Background(), HandleInput{
TaskID: "task-1", SessionID: "sess-1", Trigger: TriggerOnTurnComplete,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.Transitioned {
t.Fatalf("expected guard to block transition with partial decisions")
}
// Record second approval - quorum reached.
if err := decisions.RecordStepDecision(context.Background(), DecisionInfo{
TaskID: "task-1", StepID: "review", ParticipantID: "p2", Decision: DecisionApproved,
}); err != nil {
t.Fatalf("record: %v", err)
}
res, err = eng.HandleTrigger(context.Background(), HandleInput{
TaskID: "task-1", SessionID: "sess-1", Trigger: TriggerOnTurnComplete,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !res.Transitioned {
t.Fatalf("expected transition once quorum satisfied")
}
if res.ToStepID != "approval" {
t.Fatalf("expected target = approval, got %q", res.ToStepID)
}
}
func TestEngine_WaitForQuorum_NoRequiredParticipants_FailsClosed(t *testing.T) {
store := quorumStore(&TransitionGuard{
WaitForQuorum: &WaitForQuorumGuard{Role: "reviewer", Threshold: QuorumAllApprove},
})
decisions := newFakeDecisionStore()
// All participants present but DecisionRequired=false.
parts := fakeParticipants{list: []ParticipantInfo{
{ID: "p1", Role: "reviewer", DecisionRequired: false, AgentProfileID: "rev-A"},
}}
eng := New(store, MapRegistry{}, WithDecisionStore(decisions), WithParticipantStore(parts))
res, err := eng.HandleTrigger(context.Background(), HandleInput{
TaskID: "task-1", SessionID: "sess-1", Trigger: TriggerOnTurnComplete,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.Transitioned {
t.Fatalf("expected guard to fail closed when no required participants exist")
}
}
func TestEngine_WaitForQuorum_NoStoresWired_FailsClosed(t *testing.T) {
store := quorumStore(&TransitionGuard{
WaitForQuorum: &WaitForQuorumGuard{Role: "reviewer", Threshold: QuorumAllApprove},
})
eng := New(store, MapRegistry{}) // no DecisionStore / ParticipantStore
res, err := eng.HandleTrigger(context.Background(), HandleInput{
TaskID: "task-1", SessionID: "sess-1", Trigger: TriggerOnTurnComplete,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.Transitioned {
t.Fatalf("expected guard to fail closed when stores not wired")
}
}
func TestClearDecisionsCallback_DeletesRows(t *testing.T) {
decisions := newFakeDecisionStore()
_ = decisions.RecordStepDecision(context.Background(), DecisionInfo{TaskID: "t", StepID: "s", ParticipantID: "p", Decision: "approved"})
cb := ClearDecisionsCallback{Decisions: decisions}
_, err := cb.Execute(context.Background(), ActionInput{
State: MachineState{TaskID: "t"},
Step: StepSpec{ID: "s"},
Action: Action{Kind: ActionClearDecisions, ClearDecisions: &ClearDecisionsAction{}},
})
if err != nil {
t.Fatalf("clear: %v", err)
}
if got, _ := decisions.ListStepDecisions(context.Background(), "t", "s"); len(got) != 0 {
t.Fatalf("expected decisions cleared, got %d", len(got))
}
if decisions.cleared[dkey("t", "s")] != 1 {
t.Fatalf("expected cleared count for (t,s) to be 1, got %d", decisions.cleared[dkey("t", "s")])
}
}
func TestClearDecisionsCallback_NoStore_Errors(t *testing.T) {
cb := ClearDecisionsCallback{}
_, err := cb.Execute(context.Background(), ActionInput{
Action: Action{Kind: ActionClearDecisions, ClearDecisions: &ClearDecisionsAction{}},
})
if err == nil || !errors.Is(err, ErrActionNotYetWired) {
t.Fatalf("expected ErrActionNotYetWired, got %v", err)
}
}
func TestEngine_RecordParticipantDecision_PersistsAndReevaluates(t *testing.T) {
store := quorumStore(&TransitionGuard{
WaitForQuorum: &WaitForQuorumGuard{Role: "reviewer", Threshold: QuorumAllApprove},
})
decisions := newFakeDecisionStore()
parts := fakeParticipants{list: []ParticipantInfo{
{ID: "p1", Role: "reviewer", DecisionRequired: true, AgentProfileID: "rev-A"},
}}
eng := New(store, MapRegistry{}, WithDecisionStore(decisions), WithParticipantStore(parts))
if err := eng.RecordParticipantDecision(context.Background(), "task-1", "sess-1", "review", "p1", DecisionApproved, "lgtm"); err != nil {
t.Fatalf("RecordParticipantDecision: %v", err)
}
got, _ := decisions.ListStepDecisions(context.Background(), "task-1", "review")
if len(got) != 1 {
t.Fatalf("expected 1 recorded decision, got %d", len(got))
}
if got[0].Decision != DecisionApproved || got[0].Note != "lgtm" {
t.Fatalf("unexpected decision shape: %#v", got[0])
}
}
func TestEngine_RecordParticipantDecision_RequiresStore(t *testing.T) {
store := quorumStore(nil)
eng := New(store, MapRegistry{}) // no DecisionStore wired
err := eng.RecordParticipantDecision(context.Background(), "task-1", "sess-1", "review", "p1", DecisionApproved, "")
if err == nil {
t.Fatalf("expected error when DecisionStore missing")
}
}
func TestEngine_RecordParticipantDecision_RequiresIDs(t *testing.T) {
decisions := newFakeDecisionStore()
eng := New(quorumStore(nil), MapRegistry{}, WithDecisionStore(decisions))
cases := []struct {
name string
task, step, participant string
decision string
expectErr bool
}{
{"missing task", "", "step", "p", "approved", true},
{"missing step", "task", "", "p", "approved", true},
{"missing participant", "task", "step", "", "approved", true},
{"missing decision", "task", "step", "p", "", true},
{"valid (no session => skip reeval)", "task", "step", "p", "approved", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Signature: (ctx, taskID, sessionID, stepID, participantID, decision, note)
err := eng.RecordParticipantDecision(context.Background(), tc.task, "", tc.step, tc.participant, tc.decision, "")
if (err != nil) != tc.expectErr {
t.Fatalf("err=%v, expectErr=%v", err, tc.expectErr)
}
})
}
}
func TestConfigTransitionGuard_ParsesWaitForQuorum(t *testing.T) {
cfg := map[string]any{
"wait_for_quorum": map[string]any{
"role": "reviewer",
"threshold": "all_approve",
},
}
g := ConfigTransitionGuard(cfg)
if g == nil || g.WaitForQuorum == nil {
t.Fatalf("expected guard, got %#v", g)
}
if g.WaitForQuorum.Role != "reviewer" || g.WaitForQuorum.Threshold != "all_approve" {
t.Fatalf("unexpected guard: %#v", g.WaitForQuorum)
}
if got := ConfigTransitionGuard(nil); got != nil {
t.Fatalf("expected nil for nil config")
}
if got := ConfigTransitionGuard(map[string]any{}); got != nil {
t.Fatalf("expected nil when key absent")
}
// Missing fields => no guard.
if got := ConfigTransitionGuard(map[string]any{"wait_for_quorum": map[string]any{"role": ""}}); got != nil {
t.Fatalf("expected nil for malformed guard")
}
}