mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
- C1: add orgId field to _tasks/_workflows, filter all queries by org - C2: validate spaceID/orgID with regex before Temporal visibility queries - H1: add sync.RWMutex to orgClients map (prevent concurrent map panic) - H2: atomic FailTask with CASE expression (eliminate TOCTOU race) - H4: extractCreator uses authenticated user ID, not raw headers - Add orgId to collection schemas, store methods, and API filters
493 lines
12 KiB
Go
493 lines
12 KiB
Go
package tasks
|
|
|
|
import (
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/hanzoai/base/core"
|
|
_ "github.com/hanzoai/base/migrations"
|
|
)
|
|
|
|
// newTestApp creates a minimal base app for testing (no import of tests package to avoid cycles).
|
|
func newTestApp(t *testing.T) core.App {
|
|
t.Helper()
|
|
dir, err := os.MkdirTemp("", "tasks_test_*")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { os.RemoveAll(dir) })
|
|
|
|
app := core.NewBaseApp(core.BaseAppConfig{
|
|
DataDir: dir,
|
|
EncryptionEnv: "test_enc_key",
|
|
})
|
|
|
|
if err := Register(app, Config{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := app.Bootstrap(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
return app
|
|
}
|
|
|
|
func TestCreateAndGetTask(t *testing.T) {
|
|
app := newTestApp(t)
|
|
s := GetStore(app)
|
|
if s == nil {
|
|
t.Fatal("store not registered")
|
|
}
|
|
|
|
task := &Task{
|
|
SpaceID: "space-1",
|
|
Title: "Build the thing",
|
|
}
|
|
if err := s.CreateTask(task); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if task.ID == "" {
|
|
t.Fatal("expected auto-generated ID")
|
|
}
|
|
if task.State != TaskPending {
|
|
t.Fatalf("expected pending, got %s", task.State)
|
|
}
|
|
|
|
got, err := s.GetTask(task.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Title != "Build the thing" {
|
|
t.Fatalf("expected 'Build the thing', got %q", got.Title)
|
|
}
|
|
if got.SpaceID != "space-1" {
|
|
t.Fatalf("expected space-1, got %q", got.SpaceID)
|
|
}
|
|
}
|
|
|
|
func TestClaimTask(t *testing.T) {
|
|
app := newTestApp(t)
|
|
s := GetStore(app)
|
|
|
|
task := &Task{SpaceID: "s1", Title: "Claimable"}
|
|
if err := s.CreateTask(task); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := s.ClaimTask(task.ID, "agent-1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Second claim should fail.
|
|
if err := s.ClaimTask(task.ID, "agent-2"); err != ErrAlreadyClaimed {
|
|
t.Fatalf("expected ErrAlreadyClaimed, got %v", err)
|
|
}
|
|
|
|
got, _ := s.GetTask(task.ID)
|
|
if got.State != TaskClaimed {
|
|
t.Fatalf("expected claimed, got %s", got.State)
|
|
}
|
|
if got.AssignedTo != "agent-1" {
|
|
t.Fatalf("expected agent-1, got %s", got.AssignedTo)
|
|
}
|
|
}
|
|
|
|
func TestCompleteTask(t *testing.T) {
|
|
app := newTestApp(t)
|
|
s := GetStore(app)
|
|
|
|
task := &Task{SpaceID: "s1", Title: "Completable"}
|
|
_ = s.CreateTask(task)
|
|
_ = s.ClaimTask(task.ID, "agent-1")
|
|
_ = s.StartTask(task.ID)
|
|
|
|
output := map[string]any{"result": "success"}
|
|
if err := s.CompleteTask(task.ID, output); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, _ := s.GetTask(task.ID)
|
|
if got.State != TaskCompleted {
|
|
t.Fatalf("expected completed, got %s", got.State)
|
|
}
|
|
if got.Progress != 100 {
|
|
t.Fatalf("expected progress 100, got %d", got.Progress)
|
|
}
|
|
if got.CompletedAt == nil {
|
|
t.Fatal("expected completedAt to be set")
|
|
}
|
|
}
|
|
|
|
func TestFailTaskWithRetry(t *testing.T) {
|
|
app := newTestApp(t)
|
|
s := GetStore(app)
|
|
|
|
task := &Task{SpaceID: "s1", Title: "Retriable", MaxRetries: 2}
|
|
_ = s.CreateTask(task)
|
|
_ = s.ClaimTask(task.ID, "agent-1")
|
|
_ = s.StartTask(task.ID)
|
|
|
|
// First failure should re-queue.
|
|
if err := s.FailTask(task.ID, "oops"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, _ := s.GetTask(task.ID)
|
|
if got.State != TaskPending {
|
|
t.Fatalf("expected re-queued to pending, got %s", got.State)
|
|
}
|
|
if got.RetryCount != 1 {
|
|
t.Fatalf("expected retryCount 1, got %d", got.RetryCount)
|
|
}
|
|
|
|
// Second cycle.
|
|
_ = s.ClaimTask(task.ID, "agent-2")
|
|
_ = s.StartTask(task.ID)
|
|
_ = s.FailTask(task.ID, "oops again")
|
|
|
|
got, _ = s.GetTask(task.ID)
|
|
if got.State != TaskPending {
|
|
t.Fatalf("expected re-queued, got %s", got.State)
|
|
}
|
|
if got.RetryCount != 2 {
|
|
t.Fatalf("expected retryCount 2, got %d", got.RetryCount)
|
|
}
|
|
|
|
// Third failure should be terminal.
|
|
_ = s.ClaimTask(task.ID, "agent-3")
|
|
_ = s.StartTask(task.ID)
|
|
_ = s.FailTask(task.ID, "final fail")
|
|
|
|
got, _ = s.GetTask(task.ID)
|
|
if got.State != TaskFailed {
|
|
t.Fatalf("expected terminal failed, got %s", got.State)
|
|
}
|
|
}
|
|
|
|
func TestCancelTask(t *testing.T) {
|
|
app := newTestApp(t)
|
|
s := GetStore(app)
|
|
|
|
task := &Task{SpaceID: "s1", Title: "Cancellable"}
|
|
_ = s.CreateTask(task)
|
|
|
|
if err := s.CancelTask(task.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, _ := s.GetTask(task.ID)
|
|
if got.State != TaskCancelled {
|
|
t.Fatalf("expected cancelled, got %s", got.State)
|
|
}
|
|
|
|
// Can't cancel again.
|
|
if err := s.CancelTask(task.ID); err != ErrInvalidTransition {
|
|
t.Fatalf("expected ErrInvalidTransition, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGetNextPendingTask(t *testing.T) {
|
|
app := newTestApp(t)
|
|
s := GetStore(app)
|
|
|
|
low := &Task{SpaceID: "s1", Title: "Low", Priority: PriorityLow}
|
|
high := &Task{SpaceID: "s1", Title: "High", Priority: PriorityHigh}
|
|
_ = s.CreateTask(low)
|
|
_ = s.CreateTask(high)
|
|
|
|
// Should get high-priority first.
|
|
next, err := s.GetNextPendingTask("s1", "agent-1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if next == nil {
|
|
t.Fatal("expected a task")
|
|
}
|
|
if next.Title != "High" {
|
|
t.Fatalf("expected High, got %q", next.Title)
|
|
}
|
|
if next.State != TaskClaimed {
|
|
t.Fatalf("expected claimed, got %s", next.State)
|
|
}
|
|
|
|
// Next should get low.
|
|
next, _ = s.GetNextPendingTask("s1", "agent-2")
|
|
if next == nil || next.Title != "Low" {
|
|
t.Fatalf("expected Low, got %v", next)
|
|
}
|
|
|
|
// No more.
|
|
next, _ = s.GetNextPendingTask("s1", "agent-3")
|
|
if next != nil {
|
|
t.Fatalf("expected nil, got %+v", next)
|
|
}
|
|
}
|
|
|
|
func TestDependencyBlocking(t *testing.T) {
|
|
app := newTestApp(t)
|
|
s := GetStore(app)
|
|
|
|
a := &Task{SpaceID: "s1", Title: "Task A"}
|
|
_ = s.CreateTask(a)
|
|
|
|
b := &Task{SpaceID: "s1", Title: "Task B", DependsOn: []string{a.ID}}
|
|
_ = s.CreateTask(b)
|
|
|
|
// Next should return A (B blocked).
|
|
next, _ := s.GetNextPendingTask("s1", "agent-1")
|
|
if next == nil || next.Title != "Task A" {
|
|
t.Fatal("expected Task A")
|
|
}
|
|
|
|
// B still blocked (A claimed, not completed).
|
|
next, _ = s.GetNextPendingTask("s1", "agent-2")
|
|
if next != nil {
|
|
t.Fatalf("expected nil (B blocked), got %q", next.Title)
|
|
}
|
|
|
|
// Complete A.
|
|
_ = s.StartTask(a.ID)
|
|
_ = s.CompleteTask(a.ID, nil)
|
|
|
|
// Now B available.
|
|
next, _ = s.GetNextPendingTask("s1", "agent-2")
|
|
if next == nil || next.Title != "Task B" {
|
|
t.Fatal("expected Task B after A completed")
|
|
}
|
|
}
|
|
|
|
func TestListTasksFiltered(t *testing.T) {
|
|
app := newTestApp(t)
|
|
s := GetStore(app)
|
|
|
|
_ = s.CreateTask(&Task{SpaceID: "s1", Title: "T1", Priority: PriorityLow})
|
|
_ = s.CreateTask(&Task{SpaceID: "s1", Title: "T2", Priority: PriorityHigh})
|
|
_ = s.CreateTask(&Task{SpaceID: "s2", Title: "T3", Priority: PriorityNormal})
|
|
|
|
items, _ := s.ListTasks(TaskFilters{SpaceID: "s1"})
|
|
if len(items) != 2 {
|
|
t.Fatalf("expected 2, got %d", len(items))
|
|
}
|
|
if items[0].Title != "T2" {
|
|
t.Fatalf("expected T2 first (higher priority), got %q", items[0].Title)
|
|
}
|
|
|
|
pending := TaskPending
|
|
items, _ = s.ListTasks(TaskFilters{SpaceID: "s2", State: &pending})
|
|
if len(items) != 1 {
|
|
t.Fatalf("expected 1 pending in s2, got %d", len(items))
|
|
}
|
|
}
|
|
|
|
func TestWorkflowLifecycle(t *testing.T) {
|
|
app := newTestApp(t)
|
|
s := GetStore(app)
|
|
|
|
wf := &Workflow{SpaceID: "s1", Name: "Pipeline"}
|
|
if err := s.CreateWorkflow(wf); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if wf.ID == "" {
|
|
t.Fatal("expected auto-generated ID")
|
|
}
|
|
|
|
got, err := s.GetWorkflow(wf.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Name != "Pipeline" {
|
|
t.Fatalf("expected Pipeline, got %q", got.Name)
|
|
}
|
|
if got.State != TaskPending {
|
|
t.Fatalf("expected pending, got %s", got.State)
|
|
}
|
|
|
|
items, _ := s.ListWorkflows("s1")
|
|
if len(items) != 1 {
|
|
t.Fatalf("expected 1, got %d", len(items))
|
|
}
|
|
}
|
|
|
|
func TestWorkflowAdvancement(t *testing.T) {
|
|
app := newTestApp(t)
|
|
s := GetStore(app)
|
|
|
|
// Create workflow with 2 tasks.
|
|
t1 := &Task{SpaceID: "s1", Title: "Step 1"}
|
|
_ = s.CreateTask(t1)
|
|
t2 := &Task{SpaceID: "s1", Title: "Step 2", DependsOn: []string{t1.ID}}
|
|
_ = s.CreateTask(t2)
|
|
|
|
wf := &Workflow{SpaceID: "s1", Name: "Pipeline", Tasks: []string{t1.ID, t2.ID}}
|
|
_ = s.CreateWorkflow(wf)
|
|
_ = s.UpdateWorkflowTasks(wf)
|
|
|
|
// Advance: should go from pending → running.
|
|
_ = s.AdvanceWorkflows()
|
|
got, _ := s.GetWorkflow(wf.ID)
|
|
if got.State != TaskRunning {
|
|
t.Fatalf("expected running, got %s", got.State)
|
|
}
|
|
|
|
// Complete both tasks.
|
|
_ = s.ClaimTask(t1.ID, "a")
|
|
_ = s.StartTask(t1.ID)
|
|
_ = s.CompleteTask(t1.ID, nil)
|
|
_ = s.ClaimTask(t2.ID, "a")
|
|
_ = s.StartTask(t2.ID)
|
|
_ = s.CompleteTask(t2.ID, nil)
|
|
|
|
// Advance: should complete workflow.
|
|
_ = s.AdvanceWorkflows()
|
|
got, _ = s.GetWorkflow(wf.ID)
|
|
if got.State != TaskCompleted {
|
|
t.Fatalf("expected completed, got %s", got.State)
|
|
}
|
|
if got.CompletedAt == nil {
|
|
t.Fatal("expected completedAt set")
|
|
}
|
|
}
|
|
|
|
func TestOrgIsolation(t *testing.T) {
|
|
app := newTestApp(t)
|
|
s := GetStore(app)
|
|
|
|
// Create tasks in two different orgs.
|
|
taskA := &Task{OrgID: "org-alpha", SpaceID: "s1", Title: "Alpha task"}
|
|
taskB := &Task{OrgID: "org-beta", SpaceID: "s1", Title: "Beta task"}
|
|
if err := s.CreateTask(taskA); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.CreateTask(taskB); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// ListTasks scoped to org-alpha should only return alpha task.
|
|
items, err := s.ListTasks(TaskFilters{OrgID: "org-alpha"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(items) != 1 || items[0].Title != "Alpha task" {
|
|
t.Fatalf("expected 1 alpha task, got %d", len(items))
|
|
}
|
|
|
|
// GetTask with wrong org should fail.
|
|
_, err = s.GetTask(taskA.ID, "org-beta")
|
|
if err != ErrTaskNotFound {
|
|
t.Fatalf("expected ErrTaskNotFound for cross-org GetTask, got %v", err)
|
|
}
|
|
|
|
// GetTask with correct org should succeed.
|
|
got, err := s.GetTask(taskA.ID, "org-alpha")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.OrgID != "org-alpha" {
|
|
t.Fatalf("expected org-alpha, got %q", got.OrgID)
|
|
}
|
|
|
|
// ClaimTask with wrong org should fail silently (0 rows affected).
|
|
err = s.ClaimTask(taskA.ID, "agent-1", "org-beta")
|
|
if err != ErrTaskNotFound {
|
|
t.Fatalf("expected ErrTaskNotFound for cross-org ClaimTask, got %v", err)
|
|
}
|
|
|
|
// ClaimTask with correct org should succeed.
|
|
if err := s.ClaimTask(taskA.ID, "agent-1", "org-alpha"); err != nil {
|
|
t.Fatalf("expected claim to succeed for correct org, got %v", err)
|
|
}
|
|
|
|
// StartTask with wrong org should fail.
|
|
err = s.StartTask(taskA.ID, "org-beta")
|
|
if err != ErrTaskNotFound {
|
|
t.Fatalf("expected ErrTaskNotFound for cross-org StartTask, got %v", err)
|
|
}
|
|
|
|
// StartTask with correct org should succeed.
|
|
if err := s.StartTask(taskA.ID, "org-alpha"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// CompleteTask with wrong org should fail.
|
|
err = s.CompleteTask(taskA.ID, nil, "org-beta")
|
|
if err != ErrTaskNotFound {
|
|
t.Fatalf("expected ErrTaskNotFound for cross-org CompleteTask, got %v", err)
|
|
}
|
|
|
|
// CompleteTask with correct org should succeed.
|
|
if err := s.CompleteTask(taskA.ID, nil, "org-alpha"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// CancelTask with wrong org should fail.
|
|
err = s.CancelTask(taskB.ID, "org-alpha")
|
|
if err != ErrTaskNotFound {
|
|
t.Fatalf("expected ErrTaskNotFound for cross-org CancelTask, got %v", err)
|
|
}
|
|
|
|
// CancelTask with correct org should succeed.
|
|
if err := s.CancelTask(taskB.ID, "org-beta"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// GetNextPendingTask scoped to org returns only that org's tasks.
|
|
taskC := &Task{OrgID: "org-gamma", SpaceID: "s1", Title: "Gamma task"}
|
|
taskD := &Task{OrgID: "org-delta", SpaceID: "s1", Title: "Delta task"}
|
|
_ = s.CreateTask(taskC)
|
|
_ = s.CreateTask(taskD)
|
|
|
|
next, err := s.GetNextPendingTask("s1", "agent-2", "org-gamma")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if next == nil || next.OrgID != "org-gamma" {
|
|
t.Fatalf("expected org-gamma task, got %v", next)
|
|
}
|
|
}
|
|
|
|
func TestWorkflowOrgIsolation(t *testing.T) {
|
|
app := newTestApp(t)
|
|
s := GetStore(app)
|
|
|
|
wfA := &Workflow{OrgID: "org-alpha", SpaceID: "s1", Name: "Alpha WF"}
|
|
wfB := &Workflow{OrgID: "org-beta", SpaceID: "s1", Name: "Beta WF"}
|
|
if err := s.CreateWorkflow(wfA); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.CreateWorkflow(wfB); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// GetWorkflow with wrong org should fail.
|
|
_, err := s.GetWorkflow(wfA.ID, "org-beta")
|
|
if err != ErrWorkflowNotFound {
|
|
t.Fatalf("expected ErrWorkflowNotFound for cross-org GetWorkflow, got %v", err)
|
|
}
|
|
|
|
// GetWorkflow with correct org should succeed.
|
|
got, err := s.GetWorkflow(wfA.ID, "org-alpha")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.OrgID != "org-alpha" {
|
|
t.Fatalf("expected org-alpha, got %q", got.OrgID)
|
|
}
|
|
|
|
// ListWorkflows scoped to org-alpha should only return alpha workflow.
|
|
items, err := s.ListWorkflows("s1", "org-alpha")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(items) != 1 || items[0].Name != "Alpha WF" {
|
|
t.Fatalf("expected 1 alpha workflow, got %d", len(items))
|
|
}
|
|
|
|
// ListWorkflows without org should return both.
|
|
all, err := s.ListWorkflows("s1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(all) != 2 {
|
|
t.Fatalf("expected 2 workflows, got %d", len(all))
|
|
}
|
|
}
|