mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
1377 lines
49 KiB
Go
1377 lines
49 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"runtime"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/boolean-maybe/tiki/config"
|
||
"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": "inProgress",
|
||
"inProgress": "inProgress",
|
||
"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}, nil, 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}, nil, 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 = "inProgress" and new.status = "done" deny "cannot skip review"`)
|
||
|
||
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}
|
||
gate, _ := newGateWithStoreAndTasks(dep, main)
|
||
|
||
engine := NewTriggerEngine([]triggerEntry{entry}, nil, 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 = "inProgress" 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}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||
engine.RegisterWithGate(gate)
|
||
|
||
// move ready → in_progress — should NOT be denied
|
||
updated := tk.Clone()
|
||
updated.Status = "inProgress"
|
||
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: "inProgress", Assignee: "alice", Type: "story", Priority: 3}
|
||
existing2 := &task.Task{ID: "TIKI-WIP002", Title: "a2", Status: "inProgress", 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}, nil, 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 = "inProgress"
|
||
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: "inProgress", 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}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||
engine.RegisterWithGate(gate)
|
||
|
||
// move target ready → in_progress — only 2 in-progress, should be allowed
|
||
updated := target.Clone()
|
||
updated.Status = "inProgress"
|
||
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}, nil, 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")
|
||
return
|
||
}
|
||
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}, nil, 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}, nil, 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")
|
||
return
|
||
}
|
||
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}, nil, 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 = "inProgress" 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}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||
engine.RegisterWithGate(gate)
|
||
|
||
// update to in_progress — should cascade but not infinite loop
|
||
updated := tk.Clone()
|
||
updated.Status = "inProgress"
|
||
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: "inProgress", 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: "inProgress", Type: "story", Priority: 3}
|
||
gate, _ := newGateWithStoreAndTasks(tk)
|
||
|
||
engine := NewTriggerEngine([]triggerEntry{entry}, nil, 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: "inProgress", Type: "story", Priority: 3}
|
||
gate, _ := newGateWithStoreAndTasks(tk)
|
||
|
||
engine := NewTriggerEngine([]triggerEntry{entry}, nil, 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: "inProgress", Type: "story", Priority: 3}
|
||
gate, _ := newGateWithStoreAndTasks(tk)
|
||
|
||
engine := NewTriggerEngine([]triggerEntry{entry}, nil, 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: "inProgress",
|
||
Type: "story", Priority: 3, Recurrence: task.RecurrenceDaily,
|
||
}
|
||
gate, s := newGateWithStoreAndTasks(tk)
|
||
|
||
engine := NewTriggerEngine([]triggerEntry{entry}, nil, 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")
|
||
return
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
|
||
// --- 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"`)
|
||
|
||
tk := &task.Task{ID: "TIKI-PRIO01", Title: "critical", Status: "inProgress", Type: "story", Priority: 1}
|
||
gate, _ := newGateWithStoreAndTasks(tk)
|
||
|
||
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||
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)
|
||
|
||
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||
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)
|
||
|
||
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||
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")
|
||
}
|
||
}
|
||
|
||
// --- 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]`),
|
||
}
|
||
|
||
engine := NewTriggerEngine(entries, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||
|
||
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()
|
||
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||
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)
|
||
|
||
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||
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)
|
||
}
|
||
}
|
||
|
||
// --- LoadAndRegisterTriggers ---
|
||
|
||
func TestLoadAndRegisterTriggers_EmptyDefs(t *testing.T) {
|
||
// no workflow files → empty defs → engine, 0, nil
|
||
|
||
// 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()
|
||
|
||
gate := NewTaskMutationGate()
|
||
schema := testTriggerSchema{}
|
||
engine, count, err := LoadAndRegisterTriggers(gate, schema, nil)
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
if count != 0 {
|
||
t.Fatalf("expected 0 triggers loaded, got %d", count)
|
||
}
|
||
if engine == nil {
|
||
t.Fatal("expected non-nil engine even with zero triggers")
|
||
}
|
||
}
|
||
|
||
// --- 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)
|
||
|
||
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||
engine.RegisterWithGate(gate)
|
||
|
||
updated := tk.Clone()
|
||
updated.Status = "inProgress"
|
||
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",
|
||
`after update where new.status = "inProgress" update where id = new.id set title="updated"`)
|
||
// 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: "=",
|
||
Right: &ruki.StringLiteral{Value: "inProgress"},
|
||
}
|
||
|
||
tk := &task.Task{ID: "TIKI-ERR001", Title: "test", Status: "ready", Type: "story", Priority: 3}
|
||
gate, s := newGateWithStoreAndTasks(tk)
|
||
|
||
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||
engine.RegisterWithGate(gate)
|
||
|
||
updated := tk.Clone()
|
||
updated.Status = "inProgress"
|
||
// 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",
|
||
`after update where new.status = "inProgress" update where id = new.id set title="x"`)
|
||
// 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)
|
||
|
||
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||
engine.RegisterWithGate(gate)
|
||
|
||
updated := tk.Clone()
|
||
updated.Status = "inProgress"
|
||
// 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")
|
||
if persisted.Status != "inProgress" {
|
||
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)
|
||
|
||
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||
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")
|
||
}
|
||
}
|
||
|
||
// --- 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)
|
||
|
||
_, count, err := LoadAndRegisterTriggers(gate, testTriggerSchema{}, func() string { return "test-user" })
|
||
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())
|
||
|
||
engine, _, err := LoadAndRegisterTriggers(gate, testTriggerSchema{}, nil)
|
||
if err == nil {
|
||
t.Fatal("expected parse error")
|
||
}
|
||
if !strings.Contains(err.Error(), "broken") {
|
||
t.Fatalf("expected trigger description in error, got: %v", err)
|
||
}
|
||
if engine == nil {
|
||
t.Fatal("expected non-nil engine even on error")
|
||
}
|
||
}
|
||
|
||
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())
|
||
|
||
_, _, err := LoadAndRegisterTriggers(gate, testTriggerSchema{}, nil)
|
||
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})
|
||
|
||
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||
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
|
||
})
|
||
|
||
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||
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}
|
||
engine := NewTriggerEngine(entries, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||
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) })
|
||
if r, openErr := os.Open(f); openErr == nil {
|
||
_ = r.Close()
|
||
t.Skip("chmod 0000 did not restrict read access on this platform")
|
||
}
|
||
|
||
gate := NewTaskMutationGate()
|
||
gate.SetStore(store.NewInMemoryStore())
|
||
|
||
engine, _, err := LoadAndRegisterTriggers(gate, testTriggerSchema{}, nil)
|
||
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)
|
||
}
|
||
if engine == nil {
|
||
t.Fatal("expected non-nil engine even on load error")
|
||
}
|
||
}
|
||
|
||
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)
|
||
|
||
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||
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)
|
||
}
|
||
}
|
||
|
||
// --- 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"
|
||
ruki: 'every 1hour update where status = "inProgress" and updatedAt < now() - 7day set status="backlog"'
|
||
- 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())
|
||
|
||
engine, count, err := LoadAndRegisterTriggers(gate, testTriggerSchema{}, func() string { return "test-user" })
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
if count != 3 {
|
||
t.Fatalf("expected 3 triggers loaded, got %d", count)
|
||
}
|
||
if engine == nil {
|
||
t.Fatal("expected non-nil engine with mixed triggers")
|
||
}
|
||
}
|
||
|
||
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())
|
||
|
||
_, _, err := LoadAndRegisterTriggers(gate, testTriggerSchema{}, nil)
|
||
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())
|
||
|
||
_, _, err := LoadAndRegisterTriggers(gate, testTriggerSchema{}, nil)
|
||
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)
|
||
}
|
||
}
|
||
|
||
// --- 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")
|
||
return
|
||
}
|
||
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{})
|
||
tt, err := p.ParseTimeTrigger(`every 1sec update where status = "ready" set status="inProgress"`)
|
||
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")
|
||
return
|
||
}
|
||
if persisted.Status != "ready" {
|
||
t.Errorf("status should be unchanged, got %q", persisted.Status)
|
||
}
|
||
}
|