package storage import ( "fmt" "path/filepath" "testing" "time" "github.com/thepeterstone/claudomator/internal/task" ) func testDB(t *testing.T) *DB { t.Helper() dbPath := filepath.Join(t.TempDir(), "test.db") db, err := Open(dbPath) if err != nil { t.Fatalf("opening db: %v", err) } t.Cleanup(func() { db.Close() }) return db } func TestOpen_CreatesSchema(t *testing.T) { db := testDB(t) // Should be able to query tasks table. _, err := db.ListTasks(TaskFilter{}) if err != nil { t.Fatalf("querying tasks: %v", err) } } func TestCreateTask_AndGetTask(t *testing.T) { db := testDB(t) now := time.Now().UTC().Truncate(time.Second) tk := &task.Task{ ID: "task-1", Name: "Test Task", Description: "A test", Claude: task.ClaudeConfig{ Model: "sonnet", Instructions: "do it", WorkingDir: "/tmp", MaxBudgetUSD: 2.5, }, Priority: task.PriorityHigh, Tags: []string{"test", "alpha"}, DependsOn: []string{}, Retry: task.RetryConfig{MaxAttempts: 3, Backoff: "exponential"}, State: task.StatePending, CreatedAt: now, UpdatedAt: now, } tk.Timeout.Duration = 10 * time.Minute if err := db.CreateTask(tk); err != nil { t.Fatalf("creating task: %v", err) } got, err := db.GetTask("task-1") if err != nil { t.Fatalf("getting task: %v", err) } if got.Name != "Test Task" { t.Errorf("name: want 'Test Task', got %q", got.Name) } if got.Claude.Model != "sonnet" { t.Errorf("model: want 'sonnet', got %q", got.Claude.Model) } if got.Claude.MaxBudgetUSD != 2.5 { t.Errorf("budget: want 2.5, got %f", got.Claude.MaxBudgetUSD) } if got.Priority != task.PriorityHigh { t.Errorf("priority: want 'high', got %q", got.Priority) } if got.Timeout.Duration != 10*time.Minute { t.Errorf("timeout: want 10m, got %v", got.Timeout.Duration) } if got.Retry.MaxAttempts != 3 { t.Errorf("retry: want 3, got %d", got.Retry.MaxAttempts) } if len(got.Tags) != 2 || got.Tags[0] != "test" { t.Errorf("tags: want [test alpha], got %v", got.Tags) } if got.State != task.StatePending { t.Errorf("state: want PENDING, got %v", got.State) } } func TestUpdateTaskState(t *testing.T) { db := testDB(t) now := time.Now().UTC() tk := &task.Task{ ID: "task-2", Name: "Stateful", Claude: task.ClaudeConfig{Instructions: "test"}, Priority: task.PriorityNormal, Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"}, Tags: []string{}, DependsOn: []string{}, State: task.StatePending, CreatedAt: now, UpdatedAt: now, } if err := db.CreateTask(tk); err != nil { t.Fatal(err) } if err := db.UpdateTaskState("task-2", task.StateQueued); err != nil { t.Fatalf("updating state: %v", err) } got, _ := db.GetTask("task-2") if got.State != task.StateQueued { t.Errorf("state: want QUEUED, got %v", got.State) } } func TestUpdateTaskState_NotFound(t *testing.T) { db := testDB(t) err := db.UpdateTaskState("nonexistent", task.StateQueued) if err == nil { t.Fatal("expected error for nonexistent task") } } func TestListTasks_FilterByState(t *testing.T) { db := testDB(t) now := time.Now().UTC() for i, state := range []task.State{task.StatePending, task.StatePending, task.StateRunning} { tk := &task.Task{ ID: fmt.Sprintf("t-%d", i), Name: fmt.Sprintf("Task %d", i), Claude: task.ClaudeConfig{Instructions: "x"}, Priority: task.PriorityNormal, Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"}, Tags: []string{}, DependsOn: []string{}, State: state, CreatedAt: now, UpdatedAt: now, } if err := db.CreateTask(tk); err != nil { t.Fatal(err) } } pending, err := db.ListTasks(TaskFilter{State: task.StatePending}) if err != nil { t.Fatal(err) } if len(pending) != 2 { t.Errorf("want 2 pending, got %d", len(pending)) } running, err := db.ListTasks(TaskFilter{State: task.StateRunning}) if err != nil { t.Fatal(err) } if len(running) != 1 { t.Errorf("want 1 running, got %d", len(running)) } } func TestListTasks_WithLimit(t *testing.T) { db := testDB(t) now := time.Now().UTC() for i := 0; i < 5; i++ { tk := &task.Task{ ID: fmt.Sprintf("lt-%d", i), Name: fmt.Sprintf("T%d", i), Claude: task.ClaudeConfig{Instructions: "x"}, Priority: task.PriorityNormal, Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"}, Tags: []string{}, DependsOn: []string{}, State: task.StatePending, CreatedAt: now.Add(time.Duration(i) * time.Second), UpdatedAt: now, } db.CreateTask(tk) } tasks, err := db.ListTasks(TaskFilter{Limit: 3}) if err != nil { t.Fatal(err) } if len(tasks) != 3 { t.Errorf("want 3, got %d", len(tasks)) } } func TestCreateExecution_AndGet(t *testing.T) { db := testDB(t) now := time.Now().UTC().Truncate(time.Second) // Need a task first. tk := &task.Task{ ID: "etask", Name: "E", Claude: task.ClaudeConfig{Instructions: "x"}, Priority: task.PriorityNormal, Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"}, Tags: []string{}, DependsOn: []string{}, State: task.StatePending, CreatedAt: now, UpdatedAt: now, } db.CreateTask(tk) exec := &Execution{ ID: "exec-1", TaskID: "etask", StartTime: now, EndTime: now.Add(5 * time.Minute), ExitCode: 0, Status: "COMPLETED", StdoutPath: "/tmp/stdout.log", StderrPath: "/tmp/stderr.log", CostUSD: 0.42, } if err := db.CreateExecution(exec); err != nil { t.Fatalf("creating execution: %v", err) } got, err := db.GetExecution("exec-1") if err != nil { t.Fatalf("getting execution: %v", err) } if got.Status != "COMPLETED" { t.Errorf("status: want COMPLETED, got %q", got.Status) } if got.CostUSD != 0.42 { t.Errorf("cost: want 0.42, got %f", got.CostUSD) } if got.ExitCode != 0 { t.Errorf("exit code: want 0, got %d", got.ExitCode) } } func TestListExecutions(t *testing.T) { db := testDB(t) now := time.Now().UTC() tk := &task.Task{ ID: "ltask", Name: "L", Claude: task.ClaudeConfig{Instructions: "x"}, Priority: task.PriorityNormal, Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"}, Tags: []string{}, DependsOn: []string{}, State: task.StatePending, CreatedAt: now, UpdatedAt: now, } db.CreateTask(tk) for i := 0; i < 3; i++ { db.CreateExecution(&Execution{ ID: fmt.Sprintf("le-%d", i), TaskID: "ltask", StartTime: now.Add(time.Duration(i) * time.Minute), EndTime: now.Add(time.Duration(i+1) * time.Minute), Status: "COMPLETED", }) } execs, err := db.ListExecutions("ltask") if err != nil { t.Fatal(err) } if len(execs) != 3 { t.Errorf("want 3, got %d", len(execs)) } } func TestDB_UpdateTask(t *testing.T) { t.Run("happy path", func(t *testing.T) { db := testDB(t) now := time.Now().UTC().Truncate(time.Second) tk := &task.Task{ ID: "upd-1", Name: "Original Name", Description: "original desc", Claude: task.ClaudeConfig{Model: "sonnet", Instructions: "original"}, Priority: task.PriorityNormal, Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"}, Tags: []string{"old"}, DependsOn: []string{}, State: task.StateCompleted, CreatedAt: now, UpdatedAt: now, } tk.Timeout.Duration = 5 * time.Minute if err := db.CreateTask(tk); err != nil { t.Fatalf("creating task: %v", err) } u := TaskUpdate{ Name: "Updated Name", Description: "updated desc", Config: task.ClaudeConfig{Model: "opus", Instructions: "updated"}, Priority: task.PriorityHigh, TimeoutNS: int64(15 * time.Minute), Retry: task.RetryConfig{MaxAttempts: 3, Backoff: "exponential"}, Tags: []string{"new", "tag"}, DependsOn: []string{"dep-1"}, } if err := db.UpdateTask("upd-1", u); err != nil { t.Fatalf("UpdateTask: %v", err) } got, err := db.GetTask("upd-1") if err != nil { t.Fatalf("GetTask: %v", err) } if got.Name != "Updated Name" { t.Errorf("name: want 'Updated Name', got %q", got.Name) } if got.Description != "updated desc" { t.Errorf("description: want 'updated desc', got %q", got.Description) } if got.Claude.Model != "opus" { t.Errorf("model: want 'opus', got %q", got.Claude.Model) } if got.Priority != task.PriorityHigh { t.Errorf("priority: want 'high', got %q", got.Priority) } if got.Timeout.Duration != 15*time.Minute { t.Errorf("timeout: want 15m, got %v", got.Timeout.Duration) } if got.Retry.MaxAttempts != 3 { t.Errorf("retry.max_attempts: want 3, got %d", got.Retry.MaxAttempts) } if got.Retry.Backoff != "exponential" { t.Errorf("retry.backoff: want 'exponential', got %q", got.Retry.Backoff) } if len(got.Tags) != 2 || got.Tags[0] != "new" || got.Tags[1] != "tag" { t.Errorf("tags: want [new tag], got %v", got.Tags) } if len(got.DependsOn) != 1 || got.DependsOn[0] != "dep-1" { t.Errorf("depends_on: want [dep-1], got %v", got.DependsOn) } if got.State != task.StatePending { t.Errorf("state: want PENDING after update, got %v", got.State) } // id and created_at must be unchanged if got.ID != "upd-1" { t.Errorf("id changed: got %q", got.ID) } if !got.CreatedAt.Equal(now) { t.Errorf("created_at changed: want %v, got %v", now, got.CreatedAt) } }) t.Run("not found", func(t *testing.T) { db := testDB(t) err := db.UpdateTask("nonexistent", TaskUpdate{Name: "x"}) if err == nil { t.Fatal("expected error for nonexistent task, got nil") } }) } func TestUpdateExecution(t *testing.T) { db := testDB(t) now := time.Now().UTC() tk := &task.Task{ ID: "utask", Name: "U", Claude: task.ClaudeConfig{Instructions: "x"}, Priority: task.PriorityNormal, Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"}, Tags: []string{}, DependsOn: []string{}, State: task.StatePending, CreatedAt: now, UpdatedAt: now, } db.CreateTask(tk) exec := &Execution{ ID: "ue-1", TaskID: "utask", StartTime: now, EndTime: now, Status: "RUNNING", } db.CreateExecution(exec) exec.Status = "FAILED" exec.ExitCode = 1 exec.ErrorMsg = "something broke" exec.EndTime = now.Add(2 * time.Minute) if err := db.UpdateExecution(exec); err != nil { t.Fatal(err) } got, _ := db.GetExecution("ue-1") if got.Status != "FAILED" { t.Errorf("status: want FAILED, got %q", got.Status) } if got.ErrorMsg != "something broke" { t.Errorf("error: want 'something broke', got %q", got.ErrorMsg) } }