package handlers import ( "encoding/json" "fmt" "net/http" "strings" "testing" "git.gobha.me/xcaliber/chat-switchboard/database" ) // ═══════════════════════════════════════════════ // Task CRUD Tests // ═══════════════════════════════════════════════ func TestTask_CreateAndGet(t *testing.T) { h := setupHarness(t) _, token := h.createAdminUser("taskowner", "taskowner@test.com") // Create a prompt task w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ "name": "Test Task", "description": "Unit test task", "task_type": "prompt", "schedule": "@daily", "user_prompt": "Summarize news", "model_id": "test-model", "timezone": "UTC", }) if w.Code != http.StatusCreated { t.Fatalf("create task: want 201, got %d: %s", w.Code, w.Body.String()) } var task map[string]interface{} decode(w, &task) taskID := task["id"].(string) if taskID == "" { t.Fatal("task should have an id") } if task["name"].(string) != "Test Task" { t.Fatalf("name mismatch: got %q", task["name"]) } if task["is_active"] != true { t.Fatal("task should be active by default") } if task["schedule"].(string) != "@daily" { t.Fatalf("schedule mismatch: got %q", task["schedule"]) } // next_run_at should be computed if task["next_run_at"] == nil { t.Fatal("next_run_at should be set for cron task") } // created_at should not be zero time createdAt, _ := task["created_at"].(string) if createdAt == "" || createdAt == "0001-01-01T00:00:00Z" { t.Fatalf("created_at should be a real timestamp, got %q", createdAt) } // Get it back w = h.request("GET", "/api/v1/tasks/"+taskID, token, nil) if w.Code != http.StatusOK { t.Fatalf("get task: want 200, got %d: %s", w.Code, w.Body.String()) } var got map[string]interface{} decode(w, &got) if got["id"].(string) != taskID { t.Fatal("get returned wrong task") } } func TestTask_ListMine(t *testing.T) { h := setupHarness(t) _, token := h.createAdminUser("lister", "lister@test.com") // Create 2 tasks for i := 0; i < 2; i++ { w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ "name": fmt.Sprintf("Task %d", i), "task_type": "prompt", "schedule": "@hourly", "user_prompt": "do stuff", "model_id": "m", }) if w.Code != http.StatusCreated { t.Fatalf("create task %d: want 201, got %d", i, w.Code) } } // List w := h.request("GET", "/api/v1/tasks", token, nil) if w.Code != http.StatusOK { t.Fatalf("list: want 200, got %d", w.Code) } var resp map[string]interface{} decode(w, &resp) data := resp["data"].([]interface{}) if len(data) != 2 { t.Fatalf("expected 2 tasks, got %d", len(data)) } } func TestTask_Update(t *testing.T) { h := setupHarness(t) _, token := h.createAdminUser("updater", "updater@test.com") // Create w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ "name": "Before", "task_type": "prompt", "schedule": "@daily", "user_prompt": "old", "model_id": "m", }) if w.Code != http.StatusCreated { t.Fatalf("create: %d: %s", w.Code, w.Body.String()) } var task map[string]interface{} decode(w, &task) taskID := task["id"].(string) // Patch name and user_prompt w = h.request("PUT", "/api/v1/tasks/"+taskID, token, map[string]interface{}{ "name": "After", "user_prompt": "new prompt", }) if w.Code != http.StatusOK { t.Fatalf("update: want 200, got %d: %s", w.Code, w.Body.String()) } var updated map[string]interface{} decode(w, &updated) if updated["name"].(string) != "After" { t.Fatalf("name not updated: got %q", updated["name"]) } if updated["user_prompt"].(string) != "new prompt" { t.Fatalf("user_prompt not updated: got %q", updated["user_prompt"]) } } func TestTask_UpdateToolGrants(t *testing.T) { h := setupHarness(t) _, token := h.createAdminUser("grantuser", "grant@test.com") // Create with tool_grants w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ "name": "Grants", "task_type": "prompt", "schedule": "@daily", "user_prompt": "x", "model_id": "m", "tool_grants": []string{"web_search"}, }) if w.Code != http.StatusCreated { t.Fatalf("create: %d: %s", w.Code, w.Body.String()) } var task map[string]interface{} decode(w, &task) taskID := task["id"].(string) // Patch tool_grants newGrants := json.RawMessage(`["url_fetch","web_search"]`) w = h.request("PUT", "/api/v1/tasks/"+taskID, token, map[string]interface{}{ "tool_grants": newGrants, }) if w.Code != http.StatusOK { t.Fatalf("update tool_grants: want 200, got %d: %s", w.Code, w.Body.String()) } // Verify via GET w = h.request("GET", "/api/v1/tasks/"+taskID, token, nil) if w.Code != http.StatusOK { t.Fatalf("get: %d", w.Code) } var got map[string]interface{} decode(w, &got) grants, ok := got["tool_grants"].([]interface{}) if !ok || len(grants) != 2 { t.Fatalf("tool_grants not updated: got %v", got["tool_grants"]) } } func TestTask_Delete(t *testing.T) { h := setupHarness(t) _, token := h.createAdminUser("deleter", "deleter@test.com") w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ "name": "Doomed", "task_type": "prompt", "schedule": "once", "user_prompt": "bye", "model_id": "m", }) if w.Code != http.StatusCreated { t.Fatalf("create: %d", w.Code) } var task map[string]interface{} decode(w, &task) taskID := task["id"].(string) // Delete w = h.request("DELETE", "/api/v1/tasks/"+taskID, token, nil) if w.Code != http.StatusOK { t.Fatalf("delete: want 200, got %d: %s", w.Code, w.Body.String()) } // Verify gone w = h.request("GET", "/api/v1/tasks/"+taskID, token, nil) if w.Code != http.StatusNotFound { t.Fatalf("get deleted: want 404, got %d", w.Code) } } // ═══════════════════════════════════════════════ // Ownership / Access Control Tests // ═══════════════════════════════════════════════ func TestTask_OwnershipIsolation(t *testing.T) { h := setupHarness(t) _, tokenA := h.createAdminUser("ownerA", "ownerA@test.com") // User B — non-admin regular user (seeded directly, bypassing register endpoint) userB := database.SeedTestUser(h.t, "ownerB", "ownerB@test.com") database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userB) tokenB := makeToken(userB, "ownerB@test.com", "user") // A creates a task w := h.request("POST", "/api/v1/tasks", tokenA, map[string]interface{}{ "name": "A's Task", "task_type": "prompt", "schedule": "@daily", "user_prompt": "private", "model_id": "m", }) if w.Code != http.StatusCreated { t.Fatalf("create: %d: %s", w.Code, w.Body.String()) } var task map[string]interface{} decode(w, &task) taskID := task["id"].(string) // B cannot GET it (no RequirePermission on GET — reaches handler → 404) w = h.request("GET", "/api/v1/tasks/"+taskID, tokenB, nil) if w.Code != http.StatusNotFound { t.Fatalf("non-owner GET: want 404, got %d", w.Code) } // B cannot UPDATE it (RequirePermission blocks → 403, or handler blocks → 404) w = h.request("PUT", "/api/v1/tasks/"+taskID, tokenB, map[string]interface{}{ "name": "Hijacked", }) if w.Code == http.StatusOK { t.Fatal("non-owner PUT should not succeed") } // B cannot DELETE it w = h.request("DELETE", "/api/v1/tasks/"+taskID, tokenB, nil) if w.Code == http.StatusOK { t.Fatal("non-owner DELETE should not succeed") } // B's list should be empty (no tasks of their own) w = h.request("GET", "/api/v1/tasks", tokenB, nil) if w.Code != http.StatusOK { t.Fatalf("list: want 200, got %d", w.Code) } var resp map[string]interface{} decode(w, &resp) data := resp["data"].([]interface{}) if len(data) != 0 { t.Fatalf("expected 0 tasks for B, got %d", len(data)) } } // ═══════════════════════════════════════════════ // Validation Tests // ═══════════════════════════════════════════════ func TestTask_CreateValidation(t *testing.T) { h := setupHarness(t) _, token := h.createAdminUser("validator", "validator@test.com") tests := []struct { name string body map[string]interface{} want int }{ { name: "missing name", body: map[string]interface{}{"task_type": "prompt", "schedule": "@daily", "user_prompt": "x", "model_id": "m"}, want: http.StatusBadRequest, }, { name: "missing schedule", body: map[string]interface{}{"name": "X", "task_type": "prompt", "user_prompt": "x", "model_id": "m"}, want: http.StatusBadRequest, }, { name: "prompt without user_prompt", body: map[string]interface{}{"name": "X", "task_type": "prompt", "schedule": "@daily", "model_id": "m"}, want: http.StatusBadRequest, }, { name: "invalid cron", body: map[string]interface{}{"name": "X", "task_type": "prompt", "schedule": "not-a-cron", "user_prompt": "x", "model_id": "m"}, want: http.StatusBadRequest, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { w := h.request("POST", "/api/v1/tasks", token, tc.body) if w.Code != tc.want { t.Fatalf("want %d, got %d: %s", tc.want, w.Code, w.Body.String()) } }) } } func TestTask_UpdateInvalidSchedule(t *testing.T) { h := setupHarness(t) _, token := h.createAdminUser("badsched", "badsched@test.com") w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ "name": "T", "task_type": "prompt", "schedule": "@daily", "user_prompt": "x", "model_id": "m", }) if w.Code != http.StatusCreated { t.Fatalf("create: %d", w.Code) } var task map[string]interface{} decode(w, &task) // Try to update with invalid schedule w = h.request("PUT", "/api/v1/tasks/"+task["id"].(string), token, map[string]interface{}{ "schedule": "garbage-cron", }) if w.Code != http.StatusBadRequest { t.Fatalf("invalid schedule update: want 400, got %d: %s", w.Code, w.Body.String()) } } // ═══════════════════════════════════════════════ // Run Now / Kill Tests // ═══════════════════════════════════════════════ func TestTask_RunNowAndListRuns(t *testing.T) { h := setupHarness(t) _, token := h.createAdminUser("runner", "runner@test.com") // Create w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ "name": "Runnable", "task_type": "prompt", "schedule": "@daily", "user_prompt": "go", "model_id": "m", }) if w.Code != http.StatusCreated { t.Fatalf("create: %d", w.Code) } var task map[string]interface{} decode(w, &task) taskID := task["id"].(string) // Run Now w = h.request("POST", "/api/v1/tasks/"+taskID+"/run", token, nil) if w.Code != http.StatusOK { t.Fatalf("run now: want 200, got %d: %s", w.Code, w.Body.String()) } var runResp map[string]interface{} decode(w, &runResp) if runResp["scheduled"] != true { t.Fatal("expected scheduled: true") } // List runs — might be empty if scheduler hasn't ticked, but endpoint should work w = h.request("GET", "/api/v1/tasks/"+taskID+"/runs", token, nil) if w.Code != http.StatusOK { t.Fatalf("list runs: want 200, got %d: %s", w.Code, w.Body.String()) } } func TestTask_KillNoActiveRun(t *testing.T) { h := setupHarness(t) _, token := h.createAdminUser("killer", "killer@test.com") w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ "name": "NoRun", "task_type": "prompt", "schedule": "@daily", "user_prompt": "x", "model_id": "m", }) if w.Code != http.StatusCreated { t.Fatalf("create: %d", w.Code) } var task map[string]interface{} decode(w, &task) // Kill with no active run — should 404 w = h.request("POST", "/api/v1/tasks/"+task["id"].(string)+"/kill", token, nil) if w.Code != http.StatusNotFound { t.Fatalf("kill no-run: want 404, got %d: %s", w.Code, w.Body.String()) } } // ═══════════════════════════════════════════════ // Webhook Trigger Tests (v0.28.0) // ═══════════════════════════════════════════════ func TestTask_WebhookScheduleCreatesTriggerToken(t *testing.T) { h := setupHarness(t) _, token := h.createAdminUser("webhooker", "webhooker@test.com") w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ "name": "Webhook Task", "task_type": "prompt", "schedule": "webhook", "user_prompt": "process this", "model_id": "m", }) if w.Code != http.StatusCreated { t.Fatalf("create webhook task: want 201, got %d: %s", w.Code, w.Body.String()) } var task map[string]interface{} decode(w, &task) // Should have trigger_token triggerToken, ok := task["trigger_token"].(string) if !ok || triggerToken == "" { t.Fatal("webhook task should have a trigger_token") } // next_run_at should be nil (webhook tasks don't have cron) if task["next_run_at"] != nil { t.Fatalf("webhook task should have nil next_run_at, got %v", task["next_run_at"]) } } func TestTask_TriggerEndpoint(t *testing.T) { h := setupHarness(t) _, token := h.createAdminUser("triguser", "triguser@test.com") // Create webhook task w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ "name": "CI Watcher", "task_type": "prompt", "schedule": "webhook", "user_prompt": "analyze failure", "model_id": "m", }) if w.Code != http.StatusCreated { t.Fatalf("create: %d: %s", w.Code, w.Body.String()) } var task map[string]interface{} decode(w, &task) triggerToken := task["trigger_token"].(string) // Fire the trigger (unauthenticated) w = h.request("POST", "/api/v1/hooks/t/"+triggerToken, "", map[string]interface{}{ "build_id": 12345, "status": "failed", "repo": "chat-switchboard", }) if w.Code != http.StatusAccepted { t.Fatalf("trigger: want 202, got %d: %s", w.Code, w.Body.String()) } var trigResp map[string]interface{} decode(w, &trigResp) if trigResp["triggered"] != true { t.Fatal("expected triggered: true") } if trigResp["run_id"] == nil || trigResp["run_id"].(string) == "" { t.Fatal("expected run_id") } // Second trigger should 409 (queued run exists) w = h.request("POST", "/api/v1/hooks/t/"+triggerToken, "", map[string]interface{}{ "build_id": 12346, }) if w.Code != http.StatusConflict { t.Fatalf("duplicate trigger: want 409, got %d: %s", w.Code, w.Body.String()) } } func TestTask_TriggerInvalidToken(t *testing.T) { h := setupHarness(t) w := h.request("POST", "/api/v1/hooks/t/nonexistent-token", "", map[string]interface{}{ "data": "nope", }) if w.Code != http.StatusNotFound { t.Fatalf("bad token: want 404, got %d", w.Code) } } func TestTask_TriggerInactiveTask(t *testing.T) { h := setupHarness(t) _, token := h.createAdminUser("inactive", "inactive@test.com") // Create webhook task w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ "name": "Deactivated", "task_type": "prompt", "schedule": "webhook", "user_prompt": "x", "model_id": "m", }) if w.Code != http.StatusCreated { t.Fatalf("create: %d", w.Code) } var task map[string]interface{} decode(w, &task) taskID := task["id"].(string) triggerToken := task["trigger_token"].(string) // Deactivate w = h.request("PUT", "/api/v1/tasks/"+taskID, token, map[string]interface{}{ "is_active": false, }) if w.Code != http.StatusOK { t.Fatalf("deactivate: %d", w.Code) } // Trigger should 410 w = h.request("POST", "/api/v1/hooks/t/"+triggerToken, "", map[string]interface{}{}) if w.Code != http.StatusGone { t.Fatalf("inactive trigger: want 410, got %d: %s", w.Code, w.Body.String()) } } // ═══════════════════════════════════════════════ // Action Task Tests (v0.28.0) // ═══════════════════════════════════════════════ func TestTask_ActionTaskAdminAllowed(t *testing.T) { h := setupHarness(t) _, token := h.createAdminUser("actionadmin", "action@test.com") // Admin can always create action tasks w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ "name": "Action Relay", "task_type": "action", "schedule": "webhook", }) if w.Code != http.StatusCreated { t.Fatalf("create action task: want 201, got %d: %s", w.Code, w.Body.String()) } var task map[string]interface{} decode(w, &task) if task["task_type"].(string) != "action" { t.Fatalf("task_type should be action, got %q", task["task_type"]) } } // ═══════════════════════════════════════════════ // Admin List All Tasks // ═══════════════════════════════════════════════ func TestTask_AdminListAll(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("tadmin", "tadmin@test.com") // Create 2 tasks for i := 0; i < 2; i++ { h.request("POST", "/api/v1/tasks", adminToken, map[string]interface{}{ "name": fmt.Sprintf("Admin Task %d", i), "task_type": "prompt", "schedule": "@daily", "user_prompt": "x", "model_id": "m", }) } // Admin list w := h.request("GET", "/api/v1/admin/tasks", adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("admin list: want 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]interface{} decode(w, &resp) data := resp["data"].([]interface{}) if len(data) < 2 { t.Fatalf("admin list: expected at least 2 tasks, got %d", len(data)) } } // ═══════════════════════════════════════════════ // Webhook Secret / Output Channel (Audit C2, C3) // ═══════════════════════════════════════════════ func TestTask_WebhookSecretGenerated(t *testing.T) { h := setupHarness(t) _, token := h.createAdminUser("secretuser", "secret@test.com") w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ "name": "Webhook Out", "task_type": "prompt", "schedule": "@daily", "user_prompt": "x", "model_id": "m", "webhook_url": "https://example.com/hook", }) if w.Code != http.StatusCreated { t.Fatalf("create: %d: %s", w.Code, w.Body.String()) } var task map[string]interface{} decode(w, &task) taskID := task["id"].(string) // Verify webhook_secret was auto-generated secret, ok := task["webhook_secret"].(string) if !ok || secret == "" { t.Fatal("webhook_secret should be auto-generated when webhook_url is set") } // Verify it persists on GET (C2 fix) w = h.request("GET", "/api/v1/tasks/"+taskID, token, nil) if w.Code != http.StatusOK { t.Fatalf("get: %d", w.Code) } var got map[string]interface{} decode(w, &got) gotSecret, ok := got["webhook_secret"].(string) if !ok || gotSecret != secret { t.Fatalf("webhook_secret not persisted on read: got %q, want %q", gotSecret, secret) } } // ═══════════════════════════════════════════════ // Once-shot schedule // ═══════════════════════════════════════════════ func TestTask_OnceSchedule(t *testing.T) { h := setupHarness(t) _, token := h.createAdminUser("onceuser", "once@test.com") w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ "name": "One Shot", "task_type": "prompt", "schedule": "once", "user_prompt": "x", "model_id": "m", }) if w.Code != http.StatusCreated { t.Fatalf("create: %d: %s", w.Code, w.Body.String()) } var task map[string]interface{} decode(w, &task) // Should have next_run_at set (approximately now) if task["next_run_at"] == nil { t.Fatal("once task should have next_run_at set to ~now") } } // ═══════════════════════════════════════════════ // Budget defaults applied // ═══════════════════════════════════════════════ func TestTask_DefaultBudgets(t *testing.T) { h := setupHarness(t) _, token := h.createAdminUser("budget", "budget@test.com") // Create with zero budgets — should get defaults w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ "name": "Defaults", "task_type": "prompt", "schedule": "@daily", "user_prompt": "x", "model_id": "m", }) if w.Code != http.StatusCreated { t.Fatalf("create: %d: %s", w.Code, w.Body.String()) } var task map[string]interface{} decode(w, &task) maxTokens := int(task["max_tokens"].(float64)) maxToolCalls := int(task["max_tool_calls"].(float64)) maxWall := int(task["max_wall_clock"].(float64)) if maxTokens != 4096 { t.Fatalf("max_tokens: want 4096, got %d", maxTokens) } if maxToolCalls != 10 { t.Fatalf("max_tool_calls: want 10, got %d", maxToolCalls) } if maxWall != 300 { t.Fatalf("max_wall_clock: want 300, got %d", maxWall) } } // ═══════════════════════════════════════════════ // F2: Workflow task_type rejected at API boundary // ═══════════════════════════════════════════════ func TestTask_WorkflowTypeRejected(t *testing.T) { h := setupHarness(t) _, token := h.createAdminUser("wfuser", "wfuser@test.com") w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ "name": "Workflow Task", "task_type": "workflow", "schedule": "@daily", "workflow_id": "00000000-0000-0000-0000-000000000001", "model_id": "m", }) if w.Code != http.StatusBadRequest { t.Fatalf("workflow task_type: want 400, got %d: %s", w.Code, w.Body.String()) } // Verify error message mentions "not yet implemented" body := w.Body.String() if !strings.Contains(body, "not yet implemented") { t.Fatalf("expected 'not yet implemented' in error, got: %s", body) } } // ═══════════════════════════════════════════════ // F8: Team Task Routes // ═══════════════════════════════════════════════ func TestTask_TeamTaskCRUD(t *testing.T) { h := setupHarness(t) adminID, adminToken := h.createAdminUser("teamadm", "teamadm@test.com") // Create a team (admin users bypass RequireTeamAdmin middleware) teamID := seedInsertReturningID(t, `INSERT INTO teams (name, description, created_by) VALUES ($1, $2, $3) RETURNING id`, "Task Team", "For team task tests", adminID, ) // Create team task via team route w := h.request("POST", "/api/v1/teams/"+teamID+"/tasks", adminToken, map[string]interface{}{ "name": "Team Task", "task_type": "prompt", "schedule": "@daily", "user_prompt": "team work", "model_id": "m", }) if w.Code != http.StatusCreated { t.Fatalf("create team task: want 201, got %d: %s", w.Code, w.Body.String()) } var task map[string]interface{} decode(w, &task) taskID := task["id"].(string) // Verify scope was injected as "team" if task["scope"].(string) != "team" { t.Fatalf("team task scope: want 'team', got %q", task["scope"]) } if task["team_id"] == nil || task["team_id"].(string) != teamID { t.Fatalf("team task team_id: want %q, got %v", teamID, task["team_id"]) } // List team tasks (member route — admin bypasses membership check) w = h.request("GET", "/api/v1/teams/"+teamID+"/tasks", adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("list team tasks: want 200, got %d: %s", w.Code, w.Body.String()) } var listResp map[string]interface{} decode(w, &listResp) data := listResp["data"].([]interface{}) if len(data) != 1 { t.Fatalf("expected 1 team task, got %d", len(data)) } // Update via team route w = h.request("PUT", "/api/v1/teams/"+teamID+"/tasks/"+taskID, adminToken, map[string]interface{}{ "name": "Updated Team Task", }) if w.Code != http.StatusOK { t.Fatalf("update team task: want 200, got %d: %s", w.Code, w.Body.String()) } var updated map[string]interface{} decode(w, &updated) if updated["name"].(string) != "Updated Team Task" { t.Fatalf("name not updated: got %q", updated["name"]) } // List runs via team member route w = h.request("GET", "/api/v1/teams/"+teamID+"/tasks/"+taskID+"/runs", adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("list team task runs: want 200, got %d: %s", w.Code, w.Body.String()) } // Delete via team route w = h.request("DELETE", "/api/v1/teams/"+teamID+"/tasks/"+taskID, adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("delete team task: want 200, got %d: %s", w.Code, w.Body.String()) } // Verify gone w = h.request("GET", "/api/v1/teams/"+teamID+"/tasks", adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("list after delete: want 200, got %d", w.Code) } decode(w, &listResp) data = listResp["data"].([]interface{}) if len(data) != 0 { t.Fatalf("expected 0 team tasks after delete, got %d", len(data)) } } // ═══════════════════════════════════════════════ // F9: Admin Run / Kill / Delete // ═══════════════════════════════════════════════ func TestTask_AdminRunNow(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admrun", "admrun@test.com") // Create a task w := h.request("POST", "/api/v1/tasks", adminToken, map[string]interface{}{ "name": "Admin Runnable", "task_type": "prompt", "schedule": "@daily", "user_prompt": "go", "model_id": "m", }) if w.Code != http.StatusCreated { t.Fatalf("create: %d", w.Code) } var task map[string]interface{} decode(w, &task) taskID := task["id"].(string) // Admin run via admin route w = h.request("POST", "/api/v1/admin/tasks/"+taskID+"/run", adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("admin run: want 200, got %d: %s", w.Code, w.Body.String()) } var runResp map[string]interface{} decode(w, &runResp) if runResp["scheduled"] != true { t.Fatal("expected scheduled: true") } } func TestTask_AdminKillRun(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admkill", "admkill@test.com") // Create a webhook task and trigger it to get a queued run w := h.request("POST", "/api/v1/tasks", adminToken, map[string]interface{}{ "name": "Killable", "task_type": "prompt", "schedule": "webhook", "user_prompt": "x", "model_id": "m", }) if w.Code != http.StatusCreated { t.Fatalf("create: %d", w.Code) } var task map[string]interface{} decode(w, &task) taskID := task["id"].(string) triggerToken := task["trigger_token"].(string) // Trigger to create a queued run w = h.request("POST", "/api/v1/hooks/t/"+triggerToken, "", map[string]interface{}{"test": true}) if w.Code != http.StatusAccepted { t.Fatalf("trigger: %d", w.Code) } var trigResp map[string]interface{} decode(w, &trigResp) runID := trigResp["run_id"].(string) // Manually transition the run to "running" so kill works database.TestDB.Exec(dialectSQL("UPDATE task_runs SET status = 'running' WHERE id = $1"), runID) // Admin kill w = h.request("POST", "/api/v1/admin/tasks/"+taskID+"/kill", adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("admin kill: want 200, got %d: %s", w.Code, w.Body.String()) } var killResp map[string]interface{} decode(w, &killResp) if killResp["killed"] != true { t.Fatal("expected killed: true") } } func TestTask_AdminDelete(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admdel", "admdel@test.com") // Create a task w := h.request("POST", "/api/v1/tasks", adminToken, map[string]interface{}{ "name": "Admin Deletable", "task_type": "prompt", "schedule": "@daily", "user_prompt": "x", "model_id": "m", }) if w.Code != http.StatusCreated { t.Fatalf("create: %d", w.Code) } var task map[string]interface{} decode(w, &task) taskID := task["id"].(string) // Admin delete w = h.request("DELETE", "/api/v1/admin/tasks/"+taskID, adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("admin delete: want 200, got %d: %s", w.Code, w.Body.String()) } // Verify gone w = h.request("GET", "/api/v1/tasks/"+taskID, adminToken, nil) if w.Code != http.StatusNotFound { t.Fatalf("get deleted: want 404, got %d", w.Code) } } // ═══════════════════════════════════════════════ // F10: RunNow 409 Conflict (queued run exists) // ═══════════════════════════════════════════════ func TestTask_RunNowConflictQueued(t *testing.T) { h := setupHarness(t) _, token := h.createAdminUser("conflict", "conflict@test.com") // Create a webhook task w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ "name": "Conflict Test", "task_type": "prompt", "schedule": "webhook", "user_prompt": "x", "model_id": "m", }) if w.Code != http.StatusCreated { t.Fatalf("create: %d", w.Code) } var task map[string]interface{} decode(w, &task) taskID := task["id"].(string) triggerToken := task["trigger_token"].(string) // Trigger to create a queued run w = h.request("POST", "/api/v1/hooks/t/"+triggerToken, "", map[string]interface{}{"test": true}) if w.Code != http.StatusAccepted { t.Fatalf("trigger: %d", w.Code) } // RunNow should 409 because a queued run exists w = h.request("POST", "/api/v1/tasks/"+taskID+"/run", token, nil) if w.Code != http.StatusConflict { t.Fatalf("run-now with queued run: want 409, got %d: %s", w.Code, w.Body.String()) } } func TestTask_RunNowConflictActive(t *testing.T) { h := setupHarness(t) _, token := h.createAdminUser("active", "active@test.com") // Create a webhook task w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ "name": "Active Conflict", "task_type": "prompt", "schedule": "webhook", "user_prompt": "x", "model_id": "m", }) if w.Code != http.StatusCreated { t.Fatalf("create: %d", w.Code) } var task map[string]interface{} decode(w, &task) taskID := task["id"].(string) triggerToken := task["trigger_token"].(string) // Trigger and promote to running w = h.request("POST", "/api/v1/hooks/t/"+triggerToken, "", map[string]interface{}{"test": true}) if w.Code != http.StatusAccepted { t.Fatalf("trigger: %d", w.Code) } var trigResp map[string]interface{} decode(w, &trigResp) runID := trigResp["run_id"].(string) database.TestDB.Exec(dialectSQL("UPDATE task_runs SET status = 'running' WHERE id = $1"), runID) // RunNow should 409 because an active run exists w = h.request("POST", "/api/v1/tasks/"+taskID+"/run", token, nil) if w.Code != http.StatusConflict { t.Fatalf("run-now with active run: want 409, got %d: %s", w.Code, w.Body.String()) } }