tiki/service/trigger_engine_test.go

1378 lines
49 KiB
Go
Raw Normal View History

2026-04-05 16:05:39 +00:00
package service
import (
"context"
2026-04-05 23:34:16 +00:00
"fmt"
"os"
"path/filepath"
2026-04-05 16:37:25 +00:00
"runtime"
2026-04-05 16:05:39 +00:00
"strings"
"testing"
"time"
2026-04-05 23:34:16 +00:00
"github.com/boolean-maybe/tiki/config"
2026-04-05 16:05:39 +00:00
"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",
2026-04-08 17:44:18 +00:00
"in progress": "inProgress",
"inProgress": "inProgress",
2026-04-05 16:05:39 +00:00
"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)
2026-04-07 18:43:10 +00:00
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
2026-04-05 16:05:39 +00:00
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)
2026-04-07 18:43:10 +00:00
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
2026-04-05 16:05:39 +00:00
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",
2026-04-08 17:44:18 +00:00
`before update where old.status = "inProgress" and new.status = "done" deny "cannot skip review"`)
2026-04-05 16:05:39 +00:00
2026-04-08 17:44:18 +00:00
dep := &task.Task{ID: "TIKI-DEP001", Title: "dep", Status: "inProgress", Type: "story", Priority: 3}
main := &task.Task{ID: "TIKI-MAIN01", Title: "main", Status: "inProgress", Type: "story", Priority: 3}
2026-04-05 16:05:39 +00:00
gate, _ := newGateWithStoreAndTasks(dep, main)
2026-04-07 18:43:10 +00:00
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
2026-04-05 16:05:39 +00:00
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",
2026-04-08 17:44:18 +00:00
`before update where old.status = "inProgress" and new.status = "done" deny "no"`)
2026-04-05 16:05:39 +00:00
tk := &task.Task{ID: "TIKI-000001", Title: "test", Status: "ready", Type: "story", Priority: 3}
gate, _ := newGateWithStoreAndTasks(tk)
2026-04-07 18:43:10 +00:00
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
2026-04-05 16:05:39 +00:00
engine.RegisterWithGate(gate)
// move ready → in_progress — should NOT be denied
updated := tk.Clone()
2026-04-08 17:44:18 +00:00
updated.Status = "inProgress"
2026-04-05 16:05:39 +00:00
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
2026-04-08 17:44:18 +00:00
existing1 := &task.Task{ID: "TIKI-WIP001", Title: "a1", Status: "inProgress", Assignee: "alice", Type: "story", Priority: 3}
existing2 := &task.Task{ID: "TIKI-WIP002", Title: "a2", Status: "inProgress", Assignee: "alice", Type: "story", Priority: 3}
2026-04-05 16:05:39 +00:00
target := &task.Task{ID: "TIKI-WIP003", Title: "a3", Status: "ready", Assignee: "alice", Type: "story", Priority: 3}
gate, _ := newGateWithStoreAndTasks(existing1, existing2, target)
2026-04-07 18:43:10 +00:00
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
2026-04-05 16:05:39 +00:00
engine.RegisterWithGate(gate)
// move target ready → in_progress — would be 3 in-progress for alice, should be denied
updated := target.Clone()
2026-04-08 17:44:18 +00:00
updated.Status = "inProgress"
2026-04-05 16:05:39 +00:00
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
2026-04-08 17:44:18 +00:00
existing := &task.Task{ID: "TIKI-WIP001", Title: "a1", Status: "inProgress", Assignee: "alice", Type: "story", Priority: 3}
2026-04-05 16:05:39 +00:00
target := &task.Task{ID: "TIKI-WIP002", Title: "a2", Status: "ready", Assignee: "alice", Type: "story", Priority: 3}
gate, _ := newGateWithStoreAndTasks(existing, target)
2026-04-07 18:43:10 +00:00
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
2026-04-05 16:05:39 +00:00
engine.RegisterWithGate(gate)
// move target ready → in_progress — only 2 in-progress, should be allowed
updated := target.Clone()
2026-04-08 17:44:18 +00:00
updated.Status = "inProgress"
2026-04-05 16:05:39 +00:00
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()
2026-04-07 18:43:10 +00:00
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
2026-04-05 16:05:39 +00:00
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")
2026-04-15 02:54:44 +00:00
return
2026-04-05 16:05:39 +00:00
}
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()
2026-04-07 18:43:10 +00:00
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
2026-04-05 16:05:39 +00:00
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)
2026-04-07 18:43:10 +00:00
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
2026-04-05 16:05:39 +00:00
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")
2026-04-15 02:54:44 +00:00
return
2026-04-05 16:05:39 +00:00
}
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
})
2026-04-07 18:43:10 +00:00
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
2026-04-05 16:05:39 +00:00
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",
2026-04-08 17:44:18 +00:00
`after update where new.status = "inProgress" update where id = old.id set priority=new.priority`)
2026-04-05 16:05:39 +00:00
tk := &task.Task{ID: "TIKI-LOOP01", Title: "loop", Status: "ready", Type: "story", Priority: 3}
gate, _ := newGateWithStoreAndTasks(tk)
2026-04-07 18:43:10 +00:00
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
2026-04-05 16:05:39 +00:00
engine.RegisterWithGate(gate)
// update to in_progress — should cascade but not infinite loop
updated := tk.Clone()
2026-04-08 17:44:18 +00:00
updated.Status = "inProgress"
2026-04-05 16:05:39 +00:00
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)
2026-04-08 17:44:18 +00:00
updated := &task.Task{ID: "TIKI-000001", Title: "test", Status: "inProgress", Type: "story", Priority: 3}
2026-04-05 16:05:39 +00:00
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 ---
2026-04-05 16:37:25 +00:00
// 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")
}
}
2026-04-05 16:05:39 +00:00
func TestTriggerEngine_RunCommand(t *testing.T) {
2026-04-05 16:37:25 +00:00
skipOnWindows(t)
2026-04-05 16:05:39 +00:00
entry := parseTriggerEntry(t, "echo trigger",
`after update where new.status = "done" run("echo " + old.id)`)
2026-04-08 17:44:18 +00:00
tk := &task.Task{ID: "TIKI-RUN001", Title: "run test", Status: "inProgress", Type: "story", Priority: 3}
2026-04-05 16:05:39 +00:00
gate, _ := newGateWithStoreAndTasks(tk)
2026-04-07 18:43:10 +00:00
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
2026-04-05 16:05:39 +00:00
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) {
2026-04-05 16:37:25 +00:00
skipOnWindows(t)
2026-04-05 16:05:39 +00:00
entry := parseTriggerEntry(t, "failing command",
`after update where new.status = "done" run("exit 1")`)
2026-04-08 17:44:18 +00:00
tk := &task.Task{ID: "TIKI-FAIL01", Title: "fail test", Status: "inProgress", Type: "story", Priority: 3}
2026-04-05 16:05:39 +00:00
gate, _ := newGateWithStoreAndTasks(tk)
2026-04-07 18:43:10 +00:00
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
2026-04-05 16:05:39 +00:00
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) {
2026-04-05 16:37:25 +00:00
skipOnWindows(t)
2026-04-05 16:05:39 +00:00
// 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")`)
2026-04-08 17:44:18 +00:00
tk := &task.Task{ID: "TIKI-SLOW01", Title: "slow", Status: "inProgress", Type: "story", Priority: 3}
2026-04-05 16:05:39 +00:00
gate, _ := newGateWithStoreAndTasks(tk)
2026-04-07 18:43:10 +00:00
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
2026-04-05 16:05:39 +00:00
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{
2026-04-08 17:44:18 +00:00
ID: "TIKI-REC001", Title: "Daily standup", Status: "inProgress",
2026-04-05 16:05:39 +00:00
Type: "story", Priority: 3, Recurrence: task.RecurrenceDaily,
}
gate, s := newGateWithStoreAndTasks(tk)
2026-04-07 18:43:10 +00:00
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
2026-04-05 16:05:39 +00:00
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")
2026-04-15 02:54:44 +00:00
return
2026-04-05 16:05:39 +00:00
}
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)
}
}
2026-04-05 17:35:45 +00:00
// --- before-delete trigger ---
func TestTriggerEngine_BeforeDeleteDeny(t *testing.T) {
entry := parseTriggerEntry(t, "block delete of high priority",
`before delete where old.priority <= 2 deny "cannot delete high priority tasks"`)
2026-04-08 17:44:18 +00:00
tk := &task.Task{ID: "TIKI-PRIO01", Title: "critical", Status: "inProgress", Type: "story", Priority: 1}
2026-04-05 17:35:45 +00:00
gate, _ := newGateWithStoreAndTasks(tk)
2026-04-07 18:43:10 +00:00
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
2026-04-05 17:35:45 +00:00
engine.RegisterWithGate(gate)
err := gate.DeleteTask(context.Background(), tk)
if err == nil {
t.Fatal("expected delete denial, got nil")
}
if !strings.Contains(err.Error(), "cannot delete high priority tasks") {
t.Fatalf("expected denial message, got: %v", err)
}
}
func TestTriggerEngine_BeforeDeleteAllow(t *testing.T) {
entry := parseTriggerEntry(t, "block delete of high priority",
`before delete where old.priority <= 2 deny "cannot delete high priority tasks"`)
tk := &task.Task{ID: "TIKI-LOWP01", Title: "low priority", Status: "ready", Type: "story", Priority: 5}
gate, _ := newGateWithStoreAndTasks(tk)
2026-04-07 18:43:10 +00:00
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
2026-04-05 17:35:45 +00:00
engine.RegisterWithGate(gate)
if err := gate.DeleteTask(context.Background(), tk); err != nil {
t.Fatalf("unexpected denial: %v", err)
}
}
// --- after-delete trigger creating new task ---
func TestTriggerEngine_AfterDeleteCascadeCreate(t *testing.T) {
// when a task is deleted, create an archive placeholder
entry := parseTriggerEntry(t, "create archive on delete",
`after delete create title="archived: " + old.title status="done" type=old.type priority=5`)
tk := &task.Task{ID: "TIKI-ADEL01", Title: "delete me", Status: "ready", Type: "bug", Priority: 3}
gate, s := newGateWithStoreAndTasks(tk)
2026-04-07 18:43:10 +00:00
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
2026-04-05 17:35:45 +00:00
engine.RegisterWithGate(gate)
if err := gate.DeleteTask(context.Background(), tk); err != nil {
t.Fatalf("delete failed: %v", err)
}
// original should be gone
if s.GetTask("TIKI-ADEL01") != nil {
t.Fatal("original task should have been deleted")
}
// a new task should have been created
allTasks := s.GetAllTasks()
if len(allTasks) < 1 {
t.Fatal("expected at least 1 task (the archive placeholder)")
}
found := false
for _, at := range allTasks {
if strings.Contains(at.Title, "archived: delete me") {
found = true
if at.Status != "done" {
t.Errorf("expected status done, got %q", at.Status)
}
}
}
if !found {
t.Fatal("archive placeholder task not found")
}
}
2026-04-05 22:15:26 +00:00
// --- addTrigger routing ---
func TestTriggerEngine_AddTriggerRouting(t *testing.T) {
// build entries covering all 6 timing×event combinations
entries := []triggerEntry{
parseTriggerEntry(t, "bc", `before create deny "bc"`),
parseTriggerEntry(t, "bu", `before update where old.status = "ready" deny "bu"`),
parseTriggerEntry(t, "bd", `before delete where old.priority <= 1 deny "bd"`),
parseTriggerEntry(t, "ac", `after create where new.priority = 1 update where id = new.id set title="ac"`),
parseTriggerEntry(t, "au", `after update where new.status = "done" update where id = old.id set title="au"`),
parseTriggerEntry(t, "ad", `after delete update where old.id in dependsOn set dependsOn=dependsOn - [old.id]`),
}
2026-04-07 18:43:10 +00:00
engine := NewTriggerEngine(entries, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
2026-04-05 22:15:26 +00:00
if len(engine.beforeCreate) != 1 {
t.Errorf("beforeCreate: got %d, want 1", len(engine.beforeCreate))
}
if len(engine.beforeUpdate) != 1 {
t.Errorf("beforeUpdate: got %d, want 1", len(engine.beforeUpdate))
}
if len(engine.beforeDelete) != 1 {
t.Errorf("beforeDelete: got %d, want 1", len(engine.beforeDelete))
}
if len(engine.afterCreate) != 1 {
t.Errorf("afterCreate: got %d, want 1", len(engine.afterCreate))
}
if len(engine.afterUpdate) != 1 {
t.Errorf("afterUpdate: got %d, want 1", len(engine.afterUpdate))
}
if len(engine.afterDelete) != 1 {
t.Errorf("afterDelete: got %d, want 1", len(engine.afterDelete))
}
}
// --- before-trigger unconditional deny (no where clause) ---
func TestTriggerEngine_BeforeCreateUnconditionalDeny(t *testing.T) {
entry := parseTriggerEntry(t, "block all creates",
`before create deny "no new tasks allowed"`)
gate, _ := newGateWithStoreAndTasks()
2026-04-07 18:43:10 +00:00
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
2026-04-05 22:15:26 +00:00
engine.RegisterWithGate(gate)
tk := &task.Task{ID: "TIKI-NEW001", Title: "new", Status: "ready", Type: "story", Priority: 3}
err := gate.CreateTask(context.Background(), tk)
if err == nil {
t.Fatal("expected denial")
}
if !strings.Contains(err.Error(), "no new tasks allowed") {
t.Fatalf("expected denial message, got: %v", err)
}
}
func TestTriggerEngine_BeforeDeleteUnconditionalDeny(t *testing.T) {
entry := parseTriggerEntry(t, "block all deletes",
`before delete deny "deletes are forbidden"`)
tk := &task.Task{ID: "TIKI-DEL001", Title: "test", Status: "ready", Type: "story", Priority: 3}
gate, _ := newGateWithStoreAndTasks(tk)
2026-04-07 18:43:10 +00:00
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
2026-04-05 22:15:26 +00:00
engine.RegisterWithGate(gate)
err := gate.DeleteTask(context.Background(), tk)
if err == nil {
t.Fatal("expected denial")
}
if !strings.Contains(err.Error(), "deletes are forbidden") {
t.Fatalf("expected denial message, got: %v", err)
}
}
2026-04-05 17:35:45 +00:00
// --- LoadAndRegisterTriggers ---
func TestLoadAndRegisterTriggers_EmptyDefs(t *testing.T) {
2026-04-07 20:04:53 +00:00
// no workflow files → empty defs → engine, 0, nil
2026-04-08 16:43:20 +00:00
// isolate from real user/project workflow.yaml files
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
originalDir, _ := os.Getwd()
t.Cleanup(func() { _ = os.Chdir(originalDir) })
_ = os.Chdir(t.TempDir())
config.ResetPathManager()
2026-04-05 17:35:45 +00:00
gate := NewTaskMutationGate()
schema := testTriggerSchema{}
2026-04-07 20:04:53 +00:00
engine, count, err := LoadAndRegisterTriggers(gate, schema, nil)
2026-04-05 17:35:45 +00:00
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if count != 0 {
t.Fatalf("expected 0 triggers loaded, got %d", count)
}
2026-04-07 20:04:53 +00:00
if engine == nil {
t.Fatal("expected non-nil engine even with zero triggers")
}
2026-04-05 17:35:45 +00:00
}
2026-04-05 19:14:07 +00:00
// --- coverage gap tests ---
func TestTriggerEngine_BeforeGuardEvalError(t *testing.T) {
// before-trigger whose guard references an unknown qualifier ("mid.status")
// should produce a rejection (fail-closed)
entry := parseTriggerEntry(t, "broken guard",
`before update where old.status = "ready" deny "blocked"`)
// overwrite the parsed where with one that will fail at eval time:
// use a QualifiedRef with unknown qualifier "mid"
entry.trigger.Where = &ruki.CompareExpr{
Left: &ruki.QualifiedRef{Qualifier: "mid", Name: "status"},
Op: "=",
Right: &ruki.StringLiteral{Value: "ready"},
}
tk := &task.Task{ID: "TIKI-ERR001", Title: "test", Status: "ready", Type: "story", Priority: 3}
gate, _ := newGateWithStoreAndTasks(tk)
2026-04-07 18:43:10 +00:00
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
2026-04-05 19:14:07 +00:00
engine.RegisterWithGate(gate)
updated := tk.Clone()
2026-04-08 17:44:18 +00:00
updated.Status = "inProgress"
2026-04-05 19:14:07 +00:00
err := gate.UpdateTask(context.Background(), updated)
if err == nil {
t.Fatal("expected rejection when guard eval fails")
}
if !strings.Contains(err.Error(), "guard evaluation failed") {
t.Fatalf("expected 'guard evaluation failed' error, got: %v", err)
}
}
func TestTriggerEngine_AfterGuardEvalError(t *testing.T) {
// after-trigger whose guard evaluation fails (unknown qualifier)
// should log and skip (not propagate error)
entry := parseTriggerEntry(t, "broken after guard",
2026-04-08 17:44:18 +00:00
`after update where new.status = "inProgress" update where id = new.id set title="updated"`)
2026-04-05 19:14:07 +00:00
// overwrite the parsed where with one that will fail at eval time
entry.trigger.Where = &ruki.CompareExpr{
Left: &ruki.QualifiedRef{Qualifier: "mid", Name: "status"},
Op: "=",
2026-04-08 17:44:18 +00:00
Right: &ruki.StringLiteral{Value: "inProgress"},
2026-04-05 19:14:07 +00:00
}
tk := &task.Task{ID: "TIKI-ERR001", Title: "test", Status: "ready", Type: "story", Priority: 3}
gate, s := newGateWithStoreAndTasks(tk)
2026-04-07 18:43:10 +00:00
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
2026-04-05 19:14:07 +00:00
engine.RegisterWithGate(gate)
updated := tk.Clone()
2026-04-08 17:44:18 +00:00
updated.Status = "inProgress"
2026-04-05 19:14:07 +00:00
// guard eval error is logged and skipped → mutation should succeed
if err := gate.UpdateTask(context.Background(), updated); err != nil {
t.Fatalf("unexpected error (guard eval error should be logged, not propagated): %v", err)
}
// the after-trigger should NOT have fired (guard errored → skipped)
persisted := s.GetTask("TIKI-ERR001")
if persisted.Title != "test" {
t.Errorf("title should remain unchanged, got %q", persisted.Title)
}
}
func TestTriggerEngine_ExecActionError(t *testing.T) {
// after-trigger whose action execution fails
// the error is logged by runAfterHooks, not propagated to the caller
entry := parseTriggerEntry(t, "broken action",
2026-04-08 17:44:18 +00:00
`after update where new.status = "inProgress" update where id = new.id set title="x"`)
2026-04-05 19:14:07 +00:00
// overwrite the action with an empty statement to trigger exec error
entry.trigger.Action = &ruki.Statement{}
tk := &task.Task{ID: "TIKI-ERR002", Title: "test", Status: "ready", Type: "story", Priority: 3}
gate, s := newGateWithStoreAndTasks(tk)
2026-04-07 18:43:10 +00:00
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
2026-04-05 19:14:07 +00:00
engine.RegisterWithGate(gate)
updated := tk.Clone()
2026-04-08 17:44:18 +00:00
updated.Status = "inProgress"
2026-04-05 19:14:07 +00:00
// after-hook errors are logged but not propagated — mutation succeeds
if err := gate.UpdateTask(context.Background(), updated); err != nil {
t.Fatalf("unexpected error (after-hook errors should be logged, not propagated): %v", err)
}
// the task should have been updated (the after-trigger's action failed, but the mutation itself succeeded)
persisted := s.GetTask("TIKI-ERR002")
2026-04-08 17:44:18 +00:00
if persisted.Status != "inProgress" {
2026-04-05 19:14:07 +00:00
t.Errorf("expected status in_progress, got %q", persisted.Status)
}
// title should remain unchanged since the action failed
if persisted.Title != "test" {
t.Errorf("title should remain unchanged since action failed, got %q", persisted.Title)
}
}
func TestTriggerEngine_AfterDeleteCascadeDelete(t *testing.T) {
// exercises the persistResult delete branch:
// when a task is deleted, also delete all tasks that depend on it
entry := parseTriggerEntry(t, "cascade delete deps",
`after delete delete where old.id in dependsOn`)
parent := &task.Task{ID: "TIKI-PAR001", Title: "parent", Status: "done", Type: "story", Priority: 3}
child := &task.Task{
ID: "TIKI-CHI001", Title: "child", Status: "ready", Type: "story", Priority: 3,
DependsOn: []string{"TIKI-PAR001"},
}
unrelated := &task.Task{ID: "TIKI-UNR001", Title: "unrelated", Status: "ready", Type: "story", Priority: 3}
gate, s := newGateWithStoreAndTasks(parent, child, unrelated)
2026-04-07 18:43:10 +00:00
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
2026-04-05 19:14:07 +00:00
engine.RegisterWithGate(gate)
if err := gate.DeleteTask(context.Background(), parent); err != nil {
t.Fatalf("delete failed: %v", err)
}
// parent should be gone
if s.GetTask("TIKI-PAR001") != nil {
t.Error("parent task should have been deleted")
}
// child should be gone (cascade delete)
if s.GetTask("TIKI-CHI001") != nil {
t.Error("child task should have been cascade-deleted")
}
// unrelated should remain
if s.GetTask("TIKI-UNR001") == nil {
t.Error("unrelated task should remain")
}
}
2026-04-05 23:34:16 +00:00
// --- LoadAndRegisterTriggers full path ---
// setupTriggerLoadTest creates a temp environment for LoadAndRegisterTriggers tests.
// Returns the cwd where workflow.yaml should be written.
func setupTriggerLoadTest(t *testing.T) string {
t.Helper()
userDir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", userDir)
if err := os.MkdirAll(filepath.Join(userDir, "tiki"), 0750); err != nil {
t.Fatal(err)
}
cwdDir := t.TempDir()
// create .doc so path manager recognizes this as a project root
if err := os.MkdirAll(filepath.Join(cwdDir, ".doc"), 0750); err != nil {
t.Fatal(err)
}
originalDir, _ := os.Getwd()
t.Cleanup(func() { _ = os.Chdir(originalDir) })
_ = os.Chdir(cwdDir)
config.ResetPathManager()
return cwdDir
}
func TestLoadAndRegisterTriggers_WithValidTriggers(t *testing.T) {
cwdDir := setupTriggerLoadTest(t)
content := `triggers:
- description: "block done"
ruki: 'before update where new.status = "done" deny "no"'
- description: "auto-assign"
ruki: 'after create where new.assignee is empty update where id = new.id set assignee="bot"'
`
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
gate := NewTaskMutationGate()
s := store.NewInMemoryStore()
gate.SetStore(s)
2026-04-07 20:04:53 +00:00
_, count, err := LoadAndRegisterTriggers(gate, testTriggerSchema{}, func() string { return "test-user" })
2026-04-05 23:34:16 +00:00
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if count != 2 {
t.Fatalf("expected 2 triggers loaded, got %d", count)
}
// verify the before-trigger works: try moving a task to done
tk := &task.Task{ID: "TIKI-LRT001", Title: "test", Status: "ready", Type: "story", Priority: 3}
if err := gate.CreateTask(context.Background(), tk); err != nil {
t.Fatalf("create failed: %v", err)
}
updated := tk.Clone()
updated.Status = "done"
err = gate.UpdateTask(context.Background(), updated)
if err == nil || !strings.Contains(err.Error(), "no") {
t.Fatalf("expected 'no' denial from trigger, got: %v", err)
}
}
func TestLoadAndRegisterTriggers_ParseError(t *testing.T) {
cwdDir := setupTriggerLoadTest(t)
content := `triggers:
- description: "broken"
ruki: 'before update where garbled %%% deny "no"'
`
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
gate := NewTaskMutationGate()
gate.SetStore(store.NewInMemoryStore())
2026-04-07 20:04:53 +00:00
engine, _, err := LoadAndRegisterTriggers(gate, testTriggerSchema{}, nil)
2026-04-05 23:34:16 +00:00
if err == nil {
t.Fatal("expected parse error")
}
if !strings.Contains(err.Error(), "broken") {
t.Fatalf("expected trigger description in error, got: %v", err)
}
2026-04-07 20:04:53 +00:00
if engine == nil {
t.Fatal("expected non-nil engine even on error")
}
2026-04-05 23:34:16 +00:00
}
func TestLoadAndRegisterTriggers_ParseErrorNoDescription(t *testing.T) {
cwdDir := setupTriggerLoadTest(t)
content := `triggers:
- ruki: 'before update where garbled %%% deny "no"'
`
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
gate := NewTaskMutationGate()
gate.SetStore(store.NewInMemoryStore())
2026-04-07 20:04:53 +00:00
_, _, err := LoadAndRegisterTriggers(gate, testTriggerSchema{}, nil)
2026-04-05 23:34:16 +00:00
if err == nil {
t.Fatal("expected parse error")
}
if !strings.Contains(err.Error(), "#1") {
t.Fatalf("expected fallback description '#1' in error, got: %v", err)
}
}
// --- persistResult error branches ---
func TestTriggerEngine_PersistCreateTemplateError(t *testing.T) {
// after-trigger that creates a task, but the store fails on NewTaskTemplate
entry := parseTriggerEntry(t, "create on delete",
`after delete create title="replacement" status="ready" type=old.type priority=3`)
s := store.NewInMemoryStore()
gate := NewTaskMutationGate()
RegisterFieldValidators(gate)
gate.SetStore(s)
tk := &task.Task{ID: "TIKI-TPL001", Title: "original", Status: "ready", Type: "story", Priority: 3}
if err := gate.CreateTask(context.Background(), tk); err != nil {
t.Fatal(err)
}
// now swap to a failing template store
gate.SetStore(&failingTemplateWrapper{Store: s})
2026-04-07 18:43:10 +00:00
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
2026-04-05 23:34:16 +00:00
engine.RegisterWithGate(gate)
// delete triggers the after-hook which tries to create → template fails
// after-hook errors are logged, not propagated, so we just verify no panic
_ = gate.DeleteTask(context.Background(), tk)
}
type failingTemplateWrapper struct {
store.Store
}
func (f *failingTemplateWrapper) NewTaskTemplate() (*task.Task, error) {
return nil, fmt.Errorf("simulated template failure")
}
func TestTriggerEngine_PersistCreateGateError(t *testing.T) {
// after-trigger that creates a valid task, but gate rejects via custom validator
entry := parseTriggerEntry(t, "create on delete",
`after delete create title="valid title" status="ready" type=old.type priority=3`)
tk := &task.Task{ID: "TIKI-GCR001", Title: "original", Status: "ready", Type: "story", Priority: 3}
gate, _ := newGateWithStoreAndTasks(tk)
// add a custom create validator that rejects all trigger-created tasks
gate.OnCreate(func(old, new *task.Task, allTasks []*task.Task) *Rejection {
if new.Title == "valid title" {
return &Rejection{Reason: "no trigger creates allowed"}
}
return nil
})
2026-04-07 18:43:10 +00:00
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
2026-04-05 23:34:16 +00:00
engine.RegisterWithGate(gate)
// delete triggers the after-hook which tries to create → gate rejects
// after-hook errors are logged, not propagated
_ = gate.DeleteTask(context.Background(), tk)
}
func TestTriggerEngine_PersistDeleteError(t *testing.T) {
// after-trigger that deletes tasks, but gate rejects delete via before-delete trigger
blockDelete := parseTriggerEntry(t, "block all deletes",
`before delete deny "deletes forbidden"`)
cascadeDelete := parseTriggerEntry(t, "cascade delete",
`after update where new.status = "done" delete where id != old.id`)
tk := &task.Task{ID: "TIKI-PDL001", Title: "main", Status: "ready", Type: "story", Priority: 3}
other := &task.Task{ID: "TIKI-PDL002", Title: "other", Status: "ready", Type: "story", Priority: 3}
gate, _ := newGateWithStoreAndTasks(tk, other)
entries := []triggerEntry{blockDelete, cascadeDelete}
2026-04-07 18:43:10 +00:00
engine := NewTriggerEngine(entries, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
2026-04-05 23:34:16 +00:00
engine.RegisterWithGate(gate)
// update tk to done → cascade tries to delete other → blocked by before-delete
updated := tk.Clone()
updated.Status = "done"
// after-hook errors are logged, not propagated
_ = gate.UpdateTask(context.Background(), updated)
}
func TestLoadAndRegisterTriggers_LoadDefError(t *testing.T) {
cwdDir := setupTriggerLoadTest(t)
// write an unreadable workflow.yaml to trigger a LoadTriggerDefs error
f := filepath.Join(cwdDir, "workflow.yaml")
if err := os.WriteFile(f, []byte("triggers: []\n"), 0644); err != nil {
t.Fatal(err)
}
if err := os.Chmod(f, 0000); err != nil {
t.Skip("cannot change file permissions on this platform")
}
t.Cleanup(func() { _ = os.Chmod(f, 0600) })
2026-04-06 00:33:01 +00:00
if r, openErr := os.Open(f); openErr == nil {
_ = r.Close()
t.Skip("chmod 0000 did not restrict read access on this platform")
}
2026-04-05 23:34:16 +00:00
gate := NewTaskMutationGate()
gate.SetStore(store.NewInMemoryStore())
2026-04-07 20:04:53 +00:00
engine, _, err := LoadAndRegisterTriggers(gate, testTriggerSchema{}, nil)
2026-04-05 23:34:16 +00:00
if err == nil {
t.Fatal("expected error for unreadable workflow.yaml")
}
if !strings.Contains(err.Error(), "loading trigger definitions") {
t.Fatalf("expected 'loading trigger definitions' error, got: %v", err)
}
2026-04-07 20:04:53 +00:00
if engine == nil {
t.Fatal("expected non-nil engine even on load error")
}
2026-04-05 23:34:16 +00:00
}
func TestTriggerEngine_ExecRunEvalError(t *testing.T) {
// after-trigger with run() that references an unknown qualifier
entry := parseTriggerEntry(t, "broken run",
`after update where new.status = "done" run("echo " + old.id)`)
// overwrite run command to reference unknown qualifier
entry.trigger.Run = &ruki.RunAction{
Command: &ruki.QualifiedRef{Qualifier: "mid", Name: "title"},
}
tk := &task.Task{ID: "TIKI-RNE001", Title: "test", Status: "ready", Type: "story", Priority: 3}
gate, _ := newGateWithStoreAndTasks(tk)
2026-04-07 18:43:10 +00:00
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
2026-04-05 23:34:16 +00:00
engine.RegisterWithGate(gate)
updated := tk.Clone()
updated.Status = "done"
// after-hook errors are logged, not propagated
if err := gate.UpdateTask(context.Background(), updated); err != nil {
t.Fatalf("unexpected error (run eval error should be logged): %v", err)
}
}
2026-04-07 18:43:10 +00:00
// --- time trigger loading ---
func TestLoadAndRegisterTriggers_MixedEventAndTimeTriggers(t *testing.T) {
cwdDir := setupTriggerLoadTest(t)
content := `triggers:
- description: "block done"
ruki: 'before update where new.status = "done" deny "no"'
- description: "stale cleanup"
2026-04-08 17:44:18 +00:00
ruki: 'every 1hour update where status = "inProgress" and updatedAt < now() - 7day set status="backlog"'
2026-04-07 18:43:10 +00:00
- description: "auto-assign"
ruki: 'after create where new.assignee is empty update where id = new.id set assignee="bot"'
`
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
gate := NewTaskMutationGate()
gate.SetStore(store.NewInMemoryStore())
2026-04-07 20:04:53 +00:00
engine, count, err := LoadAndRegisterTriggers(gate, testTriggerSchema{}, func() string { return "test-user" })
2026-04-07 18:43:10 +00:00
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if count != 3 {
t.Fatalf("expected 3 triggers loaded, got %d", count)
}
2026-04-07 20:04:53 +00:00
if engine == nil {
t.Fatal("expected non-nil engine with mixed triggers")
}
2026-04-07 18:43:10 +00:00
}
func TestLoadAndRegisterTriggers_TimeTriggerAccessor(t *testing.T) {
p := ruki.NewParser(testTriggerSchema{})
tt, err := p.ParseTimeTrigger(`every 1day delete where status = "done"`)
if err != nil {
t.Fatalf("parse error: %v", err)
}
engine := NewTriggerEngine(nil, []TimeTriggerEntry{
{Description: "daily cleanup", Trigger: tt},
}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
result := engine.TimeTriggers()
if len(result) != 1 {
t.Fatalf("expected 1 time trigger, got %d", len(result))
}
if result[0].Description != "daily cleanup" {
t.Fatalf("expected description 'daily cleanup', got %q", result[0].Description)
}
if result[0].Trigger.Interval.Value != 1 || result[0].Trigger.Interval.Unit != "day" {
t.Fatalf("expected 1day interval, got %d%s", result[0].Trigger.Interval.Value, result[0].Trigger.Interval.Unit)
}
}
func TestLoadAndRegisterTriggers_InvalidTimeTrigger(t *testing.T) {
cwdDir := setupTriggerLoadTest(t)
content := `triggers:
- description: "broken time trigger"
ruki: 'every 0day delete where status = "done"'
`
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
gate := NewTaskMutationGate()
gate.SetStore(store.NewInMemoryStore())
2026-04-07 20:04:53 +00:00
_, _, err := LoadAndRegisterTriggers(gate, testTriggerSchema{}, nil)
2026-04-07 18:43:10 +00:00
if err == nil {
t.Fatal("expected error for invalid time trigger")
}
if !strings.Contains(err.Error(), "broken time trigger") {
t.Fatalf("expected trigger description in error, got: %v", err)
}
}
func TestLoadAndRegisterTriggers_RunRejectedInTimeTrigger(t *testing.T) {
cwdDir := setupTriggerLoadTest(t)
content := `triggers:
- description: "run not allowed"
ruki: 'every 1hour run("echo hi")'
`
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
gate := NewTaskMutationGate()
gate.SetStore(store.NewInMemoryStore())
2026-04-07 20:04:53 +00:00
_, _, err := LoadAndRegisterTriggers(gate, testTriggerSchema{}, nil)
2026-04-07 18:43:10 +00:00
if err == nil {
t.Fatal("expected error for run() in time trigger")
}
if !strings.Contains(err.Error(), "run not allowed") {
t.Fatalf("expected trigger description in error, got: %v", err)
}
}
2026-04-07 20:04:53 +00:00
// --- StartScheduler tests ---
func TestTriggerEngine_StartScheduler_TickExecutes(t *testing.T) {
// time trigger: delete all done tasks every 50ms
p := ruki.NewParser(testTriggerSchema{})
tt, err := p.ParseTimeTrigger(`every 1sec delete where status = "done"`)
if err != nil {
t.Fatalf("parse: %v", err)
}
doneTk := &task.Task{ID: "TIKI-DONE01", Title: "done task", Status: "done", Type: "story", Priority: 3}
activeTk := &task.Task{ID: "TIKI-ACT001", Title: "active", Status: "ready", Type: "story", Priority: 3}
gate, s := newGateWithStoreAndTasks(doneTk, activeTk)
engine := NewTriggerEngine(nil, []TimeTriggerEntry{
{Description: "cleanup", Trigger: tt},
}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
engine.RegisterWithGate(gate)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// override the interval to 50ms for fast test
go engine.runTimeTrigger(ctx, engine.timeTriggers[0], 50*time.Millisecond)
// wait long enough for at least one tick
time.Sleep(200 * time.Millisecond)
cancel()
// done task should have been deleted
if s.GetTask("TIKI-DONE01") != nil {
t.Fatal("expected done task to be deleted by time trigger")
}
// active task should remain
if s.GetTask("TIKI-ACT001") == nil {
t.Fatal("expected active task to remain")
}
}
func TestTriggerEngine_StartScheduler_NoTimeTriggers(t *testing.T) {
// StartScheduler with no time triggers should return immediately without error
engine := NewTriggerEngine(nil, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// should not block or panic
engine.StartScheduler(ctx)
}
func TestTriggerEngine_StartScheduler_ContextCancellation(t *testing.T) {
p := ruki.NewParser(testTriggerSchema{})
tt, err := p.ParseTimeTrigger(`every 1day delete where status = "done"`)
if err != nil {
t.Fatalf("parse: %v", err)
}
gate, _ := newGateWithStoreAndTasks()
engine := NewTriggerEngine(nil, []TimeTriggerEntry{
{Description: "daily cleanup", Trigger: tt},
}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
engine.RegisterWithGate(gate)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
engine.runTimeTrigger(ctx, engine.timeTriggers[0], 1*time.Hour)
close(done)
}()
// cancel immediately — goroutine should exit promptly
cancel()
select {
case <-done:
// success — goroutine exited
case <-time.After(2 * time.Second):
t.Fatal("runTimeTrigger did not exit after context cancellation")
}
}
func TestTriggerEngine_StartScheduler_ActionErrorContinues(t *testing.T) {
// time trigger with an action that will error on execution
// (update with assignment to immutable field)
p := ruki.NewParser(testTriggerSchema{})
tt, err := p.ParseTimeTrigger(`every 1sec update where status = "ready" set createdBy="hacker"`)
if err != nil {
t.Fatalf("parse: %v", err)
}
tk := &task.Task{ID: "TIKI-ERR001", Title: "test", Status: "ready", Type: "story", Priority: 3}
gate, s := newGateWithStoreAndTasks(tk)
engine := NewTriggerEngine(nil, []TimeTriggerEntry{
{Description: "broken trigger", Trigger: tt},
}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
engine.RegisterWithGate(gate)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// run with a short interval — the error should be swallowed and ticker continues
go engine.runTimeTrigger(ctx, engine.timeTriggers[0], 50*time.Millisecond)
time.Sleep(200 * time.Millisecond)
cancel()
// task should remain unchanged since the action errored
persisted := s.GetTask("TIKI-ERR001")
if persisted == nil {
t.Fatal("task should still exist")
2026-04-15 02:54:44 +00:00
return
2026-04-07 20:04:53 +00:00
}
if persisted.CreatedBy != "" {
t.Errorf("createdBy should be unchanged, got %q", persisted.CreatedBy)
}
}
func TestTriggerEngine_StartScheduler_ValidTriggerRuns(t *testing.T) {
// verify StartScheduler actually launches goroutines that execute the trigger
p := ruki.NewParser(testTriggerSchema{})
tt, err := p.ParseTimeTrigger(`every 1sec delete where status = "done"`)
if err != nil {
t.Fatalf("parse: %v", err)
}
doneTk := &task.Task{ID: "TIKI-SCH001", Title: "done task", Status: "done", Type: "story", Priority: 3}
gate, s := newGateWithStoreAndTasks(doneTk)
engine := NewTriggerEngine(nil, []TimeTriggerEntry{
{Description: "scheduler-test", Trigger: tt},
}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
engine.RegisterWithGate(gate)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
engine.StartScheduler(ctx)
// 1sec is the smallest parseable interval; wait long enough for one tick
time.Sleep(1500 * time.Millisecond)
cancel()
if s.GetTask("TIKI-SCH001") != nil {
t.Fatal("expected done task to be deleted by scheduler")
}
}
func TestTriggerEngine_StartScheduler_InvalidIntervalSkipped(t *testing.T) {
// construct a time trigger with an unrecognized unit — StartScheduler should
// log an error and skip it without panicking or launching a goroutine
tt := &ruki.TimeTrigger{
Interval: ruki.DurationLiteral{Value: 1, Unit: "fortnights"},
Action: nil, // won't be reached
}
gate, _ := newGateWithStoreAndTasks()
engine := NewTriggerEngine(nil, []TimeTriggerEntry{
{Description: "bad interval", Trigger: tt},
}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
engine.RegisterWithGate(gate)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// should not panic or launch any goroutines
engine.StartScheduler(ctx)
}
func TestTriggerEngine_ExecuteTimeTrigger_PersistError(t *testing.T) {
// update time trigger where persistResult fails because a before-update
// validator denies the mutation
p := ruki.NewParser(testTriggerSchema{})
2026-04-08 17:44:18 +00:00
tt, err := p.ParseTimeTrigger(`every 1sec update where status = "ready" set status="inProgress"`)
2026-04-07 20:04:53 +00:00
if err != nil {
t.Fatalf("parse: %v", err)
}
tk := &task.Task{ID: "TIKI-PER001", Title: "target", Status: "ready", Type: "story", Priority: 3}
gate, s := newGateWithStoreAndTasks(tk)
// register a before-update validator that always denies
gate.OnUpdate(func(old, proposed *task.Task, all []*task.Task) *Rejection {
return &Rejection{Reason: "update blocked by validator"}
})
engine := NewTriggerEngine(nil, []TimeTriggerEntry{
{Description: "persist-fail", Trigger: tt},
}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
engine.RegisterWithGate(gate)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// run one tick — persist should fail (logged), ticker continues
go engine.runTimeTrigger(ctx, engine.timeTriggers[0], 50*time.Millisecond)
time.Sleep(200 * time.Millisecond)
cancel()
// task should remain unchanged since persist was rejected
persisted := s.GetTask("TIKI-PER001")
if persisted == nil {
t.Fatal("task should still exist")
2026-04-15 02:54:44 +00:00
return
2026-04-07 20:04:53 +00:00
}
if persisted.Status != "ready" {
t.Errorf("status should be unchanged, got %q", persisted.Status)
}
}