tiki/service/trigger_engine_test.go
2026-04-07 23:51:50 -04:00

505 lines
19 KiB
Go

package service
import (
"context"
"runtime"
"strings"
"testing"
"time"
"github.com/boolean-maybe/tiki/ruki"
"github.com/boolean-maybe/tiki/store"
"github.com/boolean-maybe/tiki/task"
)
// testTriggerSchema implements ruki.Schema for trigger engine tests.
type testTriggerSchema struct{}
func (testTriggerSchema) Field(name string) (ruki.FieldSpec, bool) {
fields := map[string]ruki.FieldSpec{
"id": {Name: "id", Type: ruki.ValueID},
"title": {Name: "title", Type: ruki.ValueString},
"description": {Name: "description", Type: ruki.ValueString},
"status": {Name: "status", Type: ruki.ValueStatus},
"type": {Name: "type", Type: ruki.ValueTaskType},
"tags": {Name: "tags", Type: ruki.ValueListString},
"dependsOn": {Name: "dependsOn", Type: ruki.ValueListRef},
"due": {Name: "due", Type: ruki.ValueDate},
"recurrence": {Name: "recurrence", Type: ruki.ValueRecurrence},
"assignee": {Name: "assignee", Type: ruki.ValueString},
"priority": {Name: "priority", Type: ruki.ValueInt},
"points": {Name: "points", Type: ruki.ValueInt},
"createdBy": {Name: "createdBy", Type: ruki.ValueString},
"createdAt": {Name: "createdAt", Type: ruki.ValueTimestamp},
"updatedAt": {Name: "updatedAt", Type: ruki.ValueTimestamp},
}
f, ok := fields[name]
return f, ok
}
func (testTriggerSchema) NormalizeStatus(raw string) (string, bool) {
valid := map[string]string{
"backlog": "backlog",
"ready": "ready",
"in progress": "in_progress",
"in_progress": "in_progress",
"done": "done",
"cancelled": "cancelled",
}
canonical, ok := valid[raw]
return canonical, ok
}
func (testTriggerSchema) NormalizeType(raw string) (string, bool) {
valid := map[string]string{
"story": "story",
"bug": "bug",
"spike": "spike",
"epic": "epic",
}
canonical, ok := valid[raw]
return canonical, ok
}
func parseTriggerEntry(t *testing.T, desc, input string) triggerEntry {
t.Helper()
p := ruki.NewParser(testTriggerSchema{})
trig, err := p.ParseTrigger(input)
if err != nil {
t.Fatalf("parse trigger %q: %v", desc, err)
}
return triggerEntry{description: desc, trigger: trig}
}
func newGateWithStoreAndTasks(tasks ...*task.Task) (*TaskMutationGate, store.Store) {
gate := NewTaskMutationGate()
RegisterFieldValidators(gate)
s := store.NewInMemoryStore()
gate.SetStore(s)
for _, tk := range tasks {
if err := gate.CreateTask(context.Background(), tk); err != nil {
panic("setup: " + err.Error())
}
}
return gate, s
}
// --- before-trigger tests ---
func TestTriggerEngine_BeforeCreateDenyAggregate(t *testing.T) {
// task cap: deny when 3+ tasks for same assignee.
// Regression: allTasks must include the proposed new task,
// otherwise the count undercounts by one.
entry := parseTriggerEntry(t, "task cap",
`before create where count(select where assignee = new.assignee) >= 3 deny "task cap reached"`)
existing1 := &task.Task{ID: "TIKI-CAP001", Title: "e1", Status: "ready", Assignee: "alice", Type: "story", Priority: 3}
existing2 := &task.Task{ID: "TIKI-CAP002", Title: "e2", Status: "ready", Assignee: "alice", Type: "story", Priority: 3}
gate, _ := newGateWithStoreAndTasks(existing1, existing2)
engine := NewTriggerEngine([]triggerEntry{entry}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
engine.RegisterWithGate(gate)
// create a 3rd task for alice — count(alice tasks) = 3, should be denied
newTask := &task.Task{ID: "TIKI-CAP003", Title: "e3", Status: "ready", Assignee: "alice", Type: "story", Priority: 3}
err := gate.CreateTask(context.Background(), newTask)
if err == nil {
t.Fatal("expected task cap denial, got nil")
}
if !strings.Contains(err.Error(), "task cap reached") {
t.Fatalf("expected task cap message, got: %v", err)
}
}
func TestTriggerEngine_BeforeCreateAllowUnderAggregate(t *testing.T) {
entry := parseTriggerEntry(t, "task cap",
`before create where count(select where assignee = new.assignee) >= 3 deny "task cap reached"`)
existing := &task.Task{ID: "TIKI-CAP001", Title: "e1", Status: "ready", Assignee: "alice", Type: "story", Priority: 3}
gate, _ := newGateWithStoreAndTasks(existing)
engine := NewTriggerEngine([]triggerEntry{entry}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
engine.RegisterWithGate(gate)
// create a 2nd task for alice — count(alice tasks) = 2, should be allowed
newTask := &task.Task{ID: "TIKI-CAP002", Title: "e2", Status: "ready", Assignee: "alice", Type: "story", Priority: 3}
if err := gate.CreateTask(context.Background(), newTask); err != nil {
t.Fatalf("unexpected denial: %v", err)
}
}
func TestTriggerEngine_BeforeDeny(t *testing.T) {
entry := parseTriggerEntry(t, "block completion with open deps",
`before update where old.status = "in_progress" and new.status = "done" deny "cannot skip review"`)
dep := &task.Task{ID: "TIKI-DEP001", Title: "dep", Status: "in_progress", Type: "story", Priority: 3}
main := &task.Task{ID: "TIKI-MAIN01", Title: "main", Status: "in_progress", Type: "story", Priority: 3}
gate, _ := newGateWithStoreAndTasks(dep, main)
engine := NewTriggerEngine([]triggerEntry{entry}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
engine.RegisterWithGate(gate)
// try to move main to done — should be denied
updated := main.Clone()
updated.Status = "done"
err := gate.UpdateTask(context.Background(), updated)
if err == nil {
t.Fatal("expected denial, got nil")
}
if !strings.Contains(err.Error(), "cannot skip review") {
t.Fatalf("expected denial message, got: %v", err)
}
}
func TestTriggerEngine_BeforeDenyNoMatch(t *testing.T) {
entry := parseTriggerEntry(t, "block completion",
`before update where old.status = "in_progress" and new.status = "done" deny "no"`)
tk := &task.Task{ID: "TIKI-000001", Title: "test", Status: "ready", Type: "story", Priority: 3}
gate, _ := newGateWithStoreAndTasks(tk)
engine := NewTriggerEngine([]triggerEntry{entry}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
engine.RegisterWithGate(gate)
// move ready → in_progress — should NOT be denied
updated := tk.Clone()
updated.Status = "in_progress"
if err := gate.UpdateTask(context.Background(), updated); err != nil {
t.Fatalf("unexpected denial: %v", err)
}
}
func TestTriggerEngine_BeforeDenyWIPLimit(t *testing.T) {
// WIP limit: deny when 3+ in-progress tasks for same assignee.
// Regression: allTasks must contain proposed values for the task being updated,
// otherwise the count sees the old status and undercounts.
entry := parseTriggerEntry(t, "WIP limit",
`before update where new.status = "in progress" and count(select where assignee = new.assignee and status = "in progress") >= 3 deny "WIP limit reached"`)
// two tasks already in_progress for alice, plus the one about to transition
existing1 := &task.Task{ID: "TIKI-WIP001", Title: "a1", Status: "in_progress", Assignee: "alice", Type: "story", Priority: 3}
existing2 := &task.Task{ID: "TIKI-WIP002", Title: "a2", Status: "in_progress", Assignee: "alice", Type: "story", Priority: 3}
target := &task.Task{ID: "TIKI-WIP003", Title: "a3", Status: "ready", Assignee: "alice", Type: "story", Priority: 3}
gate, _ := newGateWithStoreAndTasks(existing1, existing2, target)
engine := NewTriggerEngine([]triggerEntry{entry}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
engine.RegisterWithGate(gate)
// move target ready → in_progress — would be 3 in-progress for alice, should be denied
updated := target.Clone()
updated.Status = "in_progress"
err := gate.UpdateTask(context.Background(), updated)
if err == nil {
t.Fatal("expected WIP limit denial, got nil")
}
if !strings.Contains(err.Error(), "WIP limit reached") {
t.Fatalf("expected WIP limit message, got: %v", err)
}
}
func TestTriggerEngine_BeforeAllowUnderWIPLimit(t *testing.T) {
entry := parseTriggerEntry(t, "WIP limit",
`before update where new.status = "in progress" and count(select where assignee = new.assignee and status = "in progress") >= 3 deny "WIP limit reached"`)
// only one task already in_progress for alice
existing := &task.Task{ID: "TIKI-WIP001", Title: "a1", Status: "in_progress", Assignee: "alice", Type: "story", Priority: 3}
target := &task.Task{ID: "TIKI-WIP002", Title: "a2", Status: "ready", Assignee: "alice", Type: "story", Priority: 3}
gate, _ := newGateWithStoreAndTasks(existing, target)
engine := NewTriggerEngine([]triggerEntry{entry}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
engine.RegisterWithGate(gate)
// move target ready → in_progress — only 2 in-progress, should be allowed
updated := target.Clone()
updated.Status = "in_progress"
if err := gate.UpdateTask(context.Background(), updated); err != nil {
t.Fatalf("unexpected denial: %v", err)
}
}
// --- after-trigger tests ---
func TestTriggerEngine_AfterUpdateCascade(t *testing.T) {
entry := parseTriggerEntry(t, "auto-assign urgent",
`after create where new.priority <= 2 and new.assignee is empty update where id = new.id set assignee="autobot"`)
gate, s := newGateWithStoreAndTasks()
engine := NewTriggerEngine([]triggerEntry{entry}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
engine.RegisterWithGate(gate)
// create an urgent task without assignee — trigger should auto-assign
tk := &task.Task{ID: "TIKI-URGENT", Title: "urgent bug", Status: "ready", Type: "bug", Priority: 1}
if err := gate.CreateTask(context.Background(), tk); err != nil {
t.Fatalf("create failed: %v", err)
}
persisted := s.GetTask("TIKI-URGENT")
if persisted == nil {
t.Fatal("task not found")
}
if persisted.Assignee != "autobot" {
t.Fatalf("expected assignee=autobot, got %q", persisted.Assignee)
}
}
func TestTriggerEngine_AfterTriggerNoMatchSkipped(t *testing.T) {
entry := parseTriggerEntry(t, "auto-assign urgent",
`after create where new.priority <= 2 and new.assignee is empty update where id = new.id set assignee="autobot"`)
gate, s := newGateWithStoreAndTasks()
engine := NewTriggerEngine([]triggerEntry{entry}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
engine.RegisterWithGate(gate)
// create a low-priority task — trigger should NOT fire
tk := &task.Task{ID: "TIKI-LOWPRI", Title: "low pri", Status: "ready", Type: "story", Priority: 5}
if err := gate.CreateTask(context.Background(), tk); err != nil {
t.Fatalf("create failed: %v", err)
}
persisted := s.GetTask("TIKI-LOWPRI")
if persisted.Assignee != "" {
t.Fatalf("expected empty assignee, got %q", persisted.Assignee)
}
}
func TestTriggerEngine_AfterDeleteCleanupDeps(t *testing.T) {
entry := parseTriggerEntry(t, "cleanup deps on delete",
`after delete update where old.id in dependsOn set dependsOn=dependsOn - [old.id]`)
dep := &task.Task{ID: "TIKI-DEP001", Title: "dep", Status: "done", Type: "story", Priority: 3}
downstream := &task.Task{
ID: "TIKI-DOWN01", Title: "downstream", Status: "ready", Type: "story", Priority: 3,
DependsOn: []string{"TIKI-DEP001", "TIKI-OTHER1"},
}
other := &task.Task{ID: "TIKI-OTHER1", Title: "other", Status: "done", Type: "story", Priority: 3}
gate, s := newGateWithStoreAndTasks(dep, downstream, other)
engine := NewTriggerEngine([]triggerEntry{entry}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
engine.RegisterWithGate(gate)
// delete dep — should remove it from downstream's dependsOn
if err := gate.DeleteTask(context.Background(), dep); err != nil {
t.Fatalf("delete failed: %v", err)
}
persisted := s.GetTask("TIKI-DOWN01")
if persisted == nil {
t.Fatal("downstream task missing")
}
if len(persisted.DependsOn) != 1 || persisted.DependsOn[0] != "TIKI-OTHER1" {
t.Fatalf("expected dependsOn=[TIKI-OTHER1], got %v", persisted.DependsOn)
}
}
func TestTriggerEngine_AfterCascadePartialFailureSurfaced(t *testing.T) {
// trigger: after create, update all tasks with same assignee to priority=1.
// One of the target tasks has an invalid state (priority=0 after set, which
// passes here), but we deliberately set up a validator that rejects priority=0.
// The trigger should succeed for some tasks and fail for the blocked one.
// Key assertion: the successful updates persist, the failed one doesn't.
entry := parseTriggerEntry(t, "cascade to peers",
`after create where new.priority = 1 update where assignee = new.assignee and id != new.id set priority=1`)
peer1 := &task.Task{ID: "TIKI-PEER01", Title: "peer1", Status: "ready", Assignee: "alice", Type: "story", Priority: 5}
peer2 := &task.Task{ID: "TIKI-PEER02", Title: "peer2", Status: "ready", Assignee: "alice", Type: "story", Priority: 5}
gate, s := newGateWithStoreAndTasks(peer1, peer2)
// register a validator that blocks updates to TIKI-PEER02 specifically
gate.OnUpdate(func(old, new *task.Task, allTasks []*task.Task) *Rejection {
if new.ID == "TIKI-PEER02" && new.Priority == 1 {
return &Rejection{Reason: "peer2 blocked"}
}
return nil
})
engine := NewTriggerEngine([]triggerEntry{entry}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
engine.RegisterWithGate(gate)
// create a task that fires the trigger
trigger := &task.Task{ID: "TIKI-TRIG01", Title: "trigger", Status: "ready", Assignee: "alice", Type: "story", Priority: 1}
if err := gate.CreateTask(context.Background(), trigger); err != nil {
t.Fatalf("create failed: %v", err)
}
// peer1 should have been updated (priority=1)
p1 := s.GetTask("TIKI-PEER01")
if p1.Priority != 1 {
t.Errorf("peer1 priority = %d, want 1 (cascade should succeed)", p1.Priority)
}
// peer2 should NOT have been updated (blocked by validator)
p2 := s.GetTask("TIKI-PEER02")
if p2.Priority != 5 {
t.Errorf("peer2 priority = %d, want 5 (cascade should have been blocked)", p2.Priority)
}
}
// --- recursion limit ---
func TestTriggerEngine_RecursionLimit(t *testing.T) {
// trigger that cascades indefinitely: every update triggers another update
entry := parseTriggerEntry(t, "infinite cascade",
`after update where new.status = "in_progress" update where id = old.id set priority=new.priority`)
tk := &task.Task{ID: "TIKI-LOOP01", Title: "loop", Status: "ready", Type: "story", Priority: 3}
gate, _ := newGateWithStoreAndTasks(tk)
engine := NewTriggerEngine([]triggerEntry{entry}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
engine.RegisterWithGate(gate)
// update to in_progress — should cascade but not infinite loop
updated := tk.Clone()
updated.Status = "in_progress"
err := gate.UpdateTask(context.Background(), updated)
// should not error — recursion limit is handled gracefully by skipping at depth
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestTriggerEngine_DepthExceededAtGateLevel(t *testing.T) {
gate, _ := newGateWithStoreAndTasks(
&task.Task{ID: "TIKI-000001", Title: "test", Status: "ready", Type: "story", Priority: 3},
)
// simulate a context already at max+1 depth
ctx := withTriggerDepth(context.Background(), maxTriggerDepth+1)
updated := &task.Task{ID: "TIKI-000001", Title: "test", Status: "in_progress", Type: "story", Priority: 3}
err := gate.UpdateTask(ctx, updated)
if err == nil {
t.Fatal("expected depth exceeded error")
}
if !strings.Contains(err.Error(), "cascade depth exceeded") {
t.Fatalf("expected cascade depth error, got: %v", err)
}
}
// --- run() trigger ---
// These tests invoke sh -c which requires a Unix shell.
func skipOnWindows(t *testing.T) {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("run() triggers use sh -c, skipping on Windows")
}
}
func TestTriggerEngine_RunCommand(t *testing.T) {
skipOnWindows(t)
entry := parseTriggerEntry(t, "echo trigger",
`after update where new.status = "done" run("echo " + old.id)`)
tk := &task.Task{ID: "TIKI-RUN001", Title: "run test", Status: "in_progress", Type: "story", Priority: 3}
gate, _ := newGateWithStoreAndTasks(tk)
engine := NewTriggerEngine([]triggerEntry{entry}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
engine.RegisterWithGate(gate)
updated := tk.Clone()
updated.Status = "done"
// should not error — command succeeds
if err := gate.UpdateTask(context.Background(), updated); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestTriggerEngine_RunCommandFailure(t *testing.T) {
skipOnWindows(t)
entry := parseTriggerEntry(t, "failing command",
`after update where new.status = "done" run("exit 1")`)
tk := &task.Task{ID: "TIKI-FAIL01", Title: "fail test", Status: "in_progress", Type: "story", Priority: 3}
gate, _ := newGateWithStoreAndTasks(tk)
engine := NewTriggerEngine([]triggerEntry{entry}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
engine.RegisterWithGate(gate)
updated := tk.Clone()
updated.Status = "done"
// run() failure is logged, not propagated — mutation should succeed
if err := gate.UpdateTask(context.Background(), updated); err != nil {
t.Fatalf("unexpected error (run failure should be swallowed): %v", err)
}
}
func TestTriggerEngine_RunCommandTimeout(t *testing.T) {
skipOnWindows(t)
// use a run() trigger whose command outlives the parent context's deadline
entry := parseTriggerEntry(t, "slow command",
`after update where new.status = "done" run("sleep 30")`)
tk := &task.Task{ID: "TIKI-SLOW01", Title: "slow", Status: "in_progress", Type: "story", Priority: 3}
gate, _ := newGateWithStoreAndTasks(tk)
engine := NewTriggerEngine([]triggerEntry{entry}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
engine.RegisterWithGate(gate)
// parent context with a very short deadline — the 30s sleep will be killed
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
updated := tk.Clone()
updated.Status = "done"
start := time.Now()
// mutation should succeed (run failures are logged, not propagated)
if err := gate.UpdateTask(ctx, updated); err != nil {
t.Fatalf("unexpected error: %v", err)
}
elapsed := time.Since(start)
// the command should have been killed quickly, not run for 30 seconds
if elapsed > 5*time.Second {
t.Fatalf("expected timeout to kill the command quickly, but took %v", elapsed)
}
}
func TestTriggerEngine_AfterUpdateCreateWithNextDate(t *testing.T) {
entry := parseTriggerEntry(t, "recurring follow-up",
`after update where new.status = "done" and old.recurrence is not empty create title=old.title status="ready" type=old.type priority=old.priority due=next_date(old.recurrence)`)
tk := &task.Task{
ID: "TIKI-REC001", Title: "Daily standup", Status: "in_progress",
Type: "story", Priority: 3, Recurrence: task.RecurrenceDaily,
}
gate, s := newGateWithStoreAndTasks(tk)
engine := NewTriggerEngine([]triggerEntry{entry}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
engine.RegisterWithGate(gate)
before := time.Now()
updated := tk.Clone()
updated.Status = "done"
if err := gate.UpdateTask(context.Background(), updated); err != nil {
t.Fatalf("update failed: %v", err)
}
after := time.Now()
allTasks := s.GetAllTasks()
if len(allTasks) < 2 {
t.Fatalf("expected at least 2 tasks (original + created), got %d", len(allTasks))
}
// find created task by predicate, not by slice index
var created *task.Task
for _, at := range allTasks {
if at.ID != "TIKI-REC001" {
created = at
break
}
}
if created == nil {
t.Fatal("trigger-created task not found")
}
if created.Title != "Daily standup" {
t.Fatalf("expected title 'Daily standup', got %q", created.Title)
}
if created.Due.IsZero() {
t.Fatal("expected non-zero due date from next_date(old.recurrence)")
}
expBefore := task.NextOccurrenceFrom(task.RecurrenceDaily, before)
expAfter := task.NextOccurrenceFrom(task.RecurrenceDaily, after)
if !created.Due.Equal(expBefore) && !created.Due.Equal(expAfter) {
t.Fatalf("expected due=%v or %v, got %v", expBefore, expAfter, created.Due)
}
}