summaryrefslogtreecommitdiff
path: root/internal/api/server_test.go
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-02-08 21:35:45 -1000
committerPeter Stone <thepeterstone@gmail.com>2026-02-08 21:35:45 -1000
commit2e2b2187b957e9af78797a67ec5c6874615fae02 (patch)
tree1181dbb7e43f5d30cb025fa4d50fd4e7a2c893b3 /internal/api/server_test.go
Initial project: task model, executor, API server, CLI, storage, reporter
Claudomator automation toolkit for Claude Code with: - Task model with YAML parsing, validation, state machine (49 tests, 0 races) - SQLite storage for tasks and executions - Executor pool with bounded concurrency, timeout, cancellation - REST API + WebSocket for mobile PWA integration - Webhook/multi-notifier system - CLI: init, run, serve, list, status commands - Console, JSON, HTML reporters with cost tracking Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/api/server_test.go')
-rw-r--r--internal/api/server_test.go186
1 files changed, 186 insertions, 0 deletions
diff --git a/internal/api/server_test.go b/internal/api/server_test.go
new file mode 100644
index 0000000..c3b77ae
--- /dev/null
+++ b/internal/api/server_test.go
@@ -0,0 +1,186 @@
+package api
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "context"
+
+ "github.com/claudomator/claudomator/internal/executor"
+ "github.com/claudomator/claudomator/internal/storage"
+ "github.com/claudomator/claudomator/internal/task"
+)
+
+func testServer(t *testing.T) (*Server, *storage.DB) {
+ t.Helper()
+ dbPath := filepath.Join(t.TempDir(), "test.db")
+ store, err := storage.Open(dbPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { store.Close() })
+
+ logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
+ runner := &mockRunner{}
+ pool := executor.NewPool(2, runner, store, logger)
+ srv := NewServer(store, pool, logger)
+ return srv, store
+}
+
+type mockRunner struct{}
+
+func (m *mockRunner) Run(_ context.Context, _ *task.Task, _ *storage.Execution) error {
+ return nil
+}
+
+func TestHealthEndpoint(t *testing.T) {
+ srv, _ := testServer(t)
+ req := httptest.NewRequest("GET", "/api/health", nil)
+ w := httptest.NewRecorder()
+
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status: want 200, got %d", w.Code)
+ }
+ var body map[string]string
+ json.NewDecoder(w.Body).Decode(&body)
+ if body["status"] != "ok" {
+ t.Errorf("want status=ok, got %v", body)
+ }
+}
+
+func TestCreateTask_Success(t *testing.T) {
+ srv, _ := testServer(t)
+
+ payload := `{
+ "name": "API Task",
+ "description": "Created via API",
+ "claude": {
+ "instructions": "do the thing",
+ "model": "sonnet"
+ },
+ "timeout": "5m",
+ "tags": ["api"]
+ }`
+ req := httptest.NewRequest("POST", "/api/tasks", bytes.NewBufferString(payload))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusCreated {
+ t.Fatalf("status: want 201, got %d; body: %s", w.Code, w.Body.String())
+ }
+
+ var created task.Task
+ json.NewDecoder(w.Body).Decode(&created)
+ if created.Name != "API Task" {
+ t.Errorf("name: want 'API Task', got %q", created.Name)
+ }
+ if created.ID == "" {
+ t.Error("expected auto-generated ID")
+ }
+}
+
+func TestCreateTask_InvalidJSON(t *testing.T) {
+ srv, _ := testServer(t)
+
+ req := httptest.NewRequest("POST", "/api/tasks", bytes.NewBufferString("{bad json"))
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("status: want 400, got %d", w.Code)
+ }
+}
+
+func TestCreateTask_ValidationFailure(t *testing.T) {
+ srv, _ := testServer(t)
+
+ payload := `{"name": "", "claude": {"instructions": ""}}`
+ req := httptest.NewRequest("POST", "/api/tasks", bytes.NewBufferString(payload))
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("status: want 400, got %d", w.Code)
+ }
+}
+
+func TestListTasks_Empty(t *testing.T) {
+ srv, _ := testServer(t)
+
+ req := httptest.NewRequest("GET", "/api/tasks", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status: want 200, got %d", w.Code)
+ }
+
+ var tasks []task.Task
+ json.NewDecoder(w.Body).Decode(&tasks)
+ if len(tasks) != 0 {
+ t.Errorf("want 0 tasks, got %d", len(tasks))
+ }
+}
+
+func TestGetTask_NotFound(t *testing.T) {
+ srv, _ := testServer(t)
+
+ req := httptest.NewRequest("GET", "/api/tasks/nonexistent", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusNotFound {
+ t.Errorf("status: want 404, got %d", w.Code)
+ }
+}
+
+func TestListTasks_WithTasks(t *testing.T) {
+ srv, store := testServer(t)
+
+ // Create tasks directly in store.
+ for i := 0; i < 3; 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,
+ }
+ store.CreateTask(tk)
+ }
+
+ req := httptest.NewRequest("GET", "/api/tasks", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ var tasks []task.Task
+ json.NewDecoder(w.Body).Decode(&tasks)
+ if len(tasks) != 3 {
+ t.Errorf("want 3 tasks, got %d", len(tasks))
+ }
+}
+
+func TestCORS_Headers(t *testing.T) {
+ srv, _ := testServer(t)
+
+ req := httptest.NewRequest("OPTIONS", "/api/tasks", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Header().Get("Access-Control-Allow-Origin") != "*" {
+ t.Error("missing CORS origin header")
+ }
+ if w.Code != http.StatusOK {
+ t.Errorf("OPTIONS status: want 200, got %d", w.Code)
+ }
+}