Pulse/internal/api/ai_handlers_test.go
rcourtman 3fdf753a5b Enhance devcontainer and CI workflows
- Add persistent volume mounts for Go/npm caches (faster rebuilds)
- Add shell config with helpful aliases and custom prompt
- Add comprehensive devcontainer documentation
- Add pre-commit hooks for Go formatting and linting
- Use go-version-file in CI workflows instead of hardcoded versions
- Simplify docker compose commands with --wait flag
- Add gitignore entries for devcontainer auth files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 22:29:15 +00:00

1083 lines
31 KiB
Go

package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
func TestAISettingsHandler_GetAndUpdateSettings_RoundTrip(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
// GET should return defaults if no config has been saved yet.
{
req := httptest.NewRequest(http.MethodGet, "/api/settings/ai", nil)
rec := httptest.NewRecorder()
handler.HandleGetAISettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp AISettingsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Enabled {
t.Fatalf("expected default Enabled=false, got %+v", resp)
}
}
// Update settings to enable AI via Ollama.
{
body, _ := json.Marshal(AISettingsUpdateRequest{
Enabled: ptr(true),
Provider: ptr("ollama"),
Model: ptr("ollama:llama3"),
OllamaBaseURL: ptr("http://localhost:11434"),
})
req := httptest.NewRequest(http.MethodPut, "/api/settings/ai", bytes.NewReader(body))
rec := httptest.NewRecorder()
handler.HandleUpdateAISettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PUT status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp AISettingsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if !resp.Enabled || !resp.OllamaConfigured {
t.Fatalf("expected enabled + ollama configured, got %+v", resp)
}
if resp.OllamaBaseURL != "http://localhost:11434" {
t.Fatalf("unexpected ollama base url: %+v", resp)
}
}
// GET again should reflect persisted updates.
{
req := httptest.NewRequest(http.MethodGet, "/api/settings/ai", nil)
rec := httptest.NewRecorder()
handler.HandleGetAISettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp AISettingsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if !resp.Enabled || !resp.OllamaConfigured {
t.Fatalf("expected enabled + ollama configured, got %+v", resp)
}
}
}
func TestAISettingsHandler_ListModels_Ollama(t *testing.T) {
t.Parallel()
ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/tags":
_ = json.NewEncoder(w).Encode(map[string]any{
"models": []map[string]any{
{"name": "llama3:latest"},
{"name": "tinyllama:latest"},
},
})
default:
http.NotFound(w, r)
}
}))
defer ollama.Close()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
aiCfg := config.NewDefaultAIConfig()
aiCfg.Enabled = true
aiCfg.Model = "ollama:llama3"
aiCfg.OllamaBaseURL = ollama.URL
if err := persistence.SaveAIConfig(*aiCfg); err != nil {
t.Fatalf("SaveAIConfig: %v", err)
}
handler := NewAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodGet, "/api/ai/models", nil)
rec := httptest.NewRecorder()
handler.HandleListModels(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Models []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"models"`
Error string `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Error != "" {
t.Fatalf("unexpected error: %s", resp.Error)
}
if len(resp.Models) != 2 {
t.Fatalf("expected 2 models, got %+v", resp.Models)
}
}
func TestAISettingsHandler_Execute_Ollama(t *testing.T) {
t.Parallel()
ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/chat":
_ = json.NewEncoder(w).Encode(map[string]any{
"model": "llama3",
"created_at": time.Now().Format(time.RFC3339),
"message": map[string]any{
"role": "assistant",
"content": "hello from ollama",
},
"done": true,
"done_reason": "stop",
"prompt_eval_count": 3,
"eval_count": 5,
})
case "/api/version":
_ = json.NewEncoder(w).Encode(map[string]any{"version": "0.1.0"})
case "/api/tags":
_ = json.NewEncoder(w).Encode(map[string]any{"models": []map[string]any{{"name": "llama3"}}})
default:
http.NotFound(w, r)
}
}))
defer ollama.Close()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
aiCfg := config.NewDefaultAIConfig()
aiCfg.Enabled = true
aiCfg.Model = "ollama:llama3"
aiCfg.OllamaBaseURL = ollama.URL
if err := persistence.SaveAIConfig(*aiCfg); err != nil {
t.Fatalf("SaveAIConfig: %v", err)
}
handler := NewAISettingsHandler(cfg, persistence, nil)
body, _ := json.Marshal(AIExecuteRequest{Prompt: "hi"})
req := httptest.NewRequest(http.MethodPost, "/api/ai/execute", bytes.NewReader(body))
req = req.WithContext(context.Background())
rec := httptest.NewRecorder()
handler.HandleExecute(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp AIExecuteResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Content != "hello from ollama" || resp.Model == "" {
t.Fatalf("unexpected response: %+v", resp)
}
}
func ptr[T any](v T) *T { return &v }
func TestAISettingsHandler_TestConnection_Ollama(t *testing.T) {
t.Parallel()
ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/version" {
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"version": "0.1.0"})
}))
defer ollama.Close()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
aiCfg := config.NewDefaultAIConfig()
aiCfg.Enabled = true
aiCfg.Model = "ollama:llama3"
aiCfg.OllamaBaseURL = ollama.URL
if err := persistence.SaveAIConfig(*aiCfg); err != nil {
t.Fatalf("SaveAIConfig: %v", err)
}
handler := NewAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodPost, "/api/ai/test", nil)
rec := httptest.NewRecorder()
handler.HandleTestAIConnection(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Success bool `json:"success"`
Message string `json:"message"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if !resp.Success {
t.Fatalf("expected success, got %+v", resp)
}
}
func TestAISettingsHandler_TestProvider_Ollama(t *testing.T) {
t.Parallel()
ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/version" {
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"version": "0.1.0"})
}))
defer ollama.Close()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
aiCfg := config.NewDefaultAIConfig()
aiCfg.Enabled = true
aiCfg.Model = "ollama:llama3"
aiCfg.OllamaBaseURL = ollama.URL
if err := persistence.SaveAIConfig(*aiCfg); err != nil {
t.Fatalf("SaveAIConfig: %v", err)
}
handler := NewAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodPost, "/api/ai/test/ollama", nil)
rec := httptest.NewRecorder()
handler.HandleTestProvider(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Success bool `json:"success"`
Provider string `json:"provider"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if !resp.Success || resp.Provider != "ollama" {
t.Fatalf("unexpected response: %+v", resp)
}
}
// ========================================
// HandleGetAICostSummary tests
// ========================================
func TestHandleGetAICostSummary_MethodNotAllowed(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodPost, "/api/ai/cost/summary", nil)
rec := httptest.NewRecorder()
handler.HandleGetAICostSummary(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
func TestHandleGetAICostSummary_NoAIService(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodGet, "/api/ai/cost/summary", nil)
rec := httptest.NewRecorder()
handler.HandleGetAICostSummary(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
}
var resp struct {
Days int `json:"days"`
PricingAsOf string `json:"pricing_as_of"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Days != 30 {
t.Fatalf("expected Days=30 default, got %d", resp.Days)
}
}
func TestHandleGetAICostSummary_CustomDays(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodGet, "/api/ai/cost/summary?days=7", nil)
rec := httptest.NewRecorder()
handler.HandleGetAICostSummary(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
}
var resp struct {
Days int `json:"days"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Days != 7 {
t.Fatalf("expected Days=7, got %d", resp.Days)
}
}
func TestHandleGetAICostSummary_MaxDays(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
// Test that days > 365 is capped at 365
req := httptest.NewRequest(http.MethodGet, "/api/ai/cost/summary?days=1000", nil)
rec := httptest.NewRecorder()
handler.HandleGetAICostSummary(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
}
var resp struct {
Days int `json:"days"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Days != 365 {
t.Fatalf("expected Days=365 (capped), got %d", resp.Days)
}
}
// ========================================
// HandleResetAICostHistory tests
// ========================================
func TestHandleResetAICostHistory_MethodNotAllowed(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodGet, "/api/ai/cost/reset", nil)
rec := httptest.NewRecorder()
handler.HandleResetAICostHistory(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
func TestHandleResetAICostHistory_Success(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodPost, "/api/ai/cost/reset", nil)
rec := httptest.NewRecorder()
handler.HandleResetAICostHistory(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
}
var resp struct {
Ok bool `json:"ok"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if !resp.Ok {
t.Fatalf("expected ok=true")
}
}
// ========================================
// HandleExportAICostHistory tests
// ========================================
func TestHandleExportAICostHistory_MethodNotAllowed(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodPost, "/api/ai/cost/export", nil)
rec := httptest.NewRecorder()
handler.HandleExportAICostHistory(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
// ========================================
// HandleGetSuppressionRules tests
// ========================================
func TestHandleGetSuppressionRules_MethodNotAllowed(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodPost, "/api/ai/patrol/suppressions", nil)
rec := httptest.NewRecorder()
handler.HandleGetSuppressionRules(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
// ========================================
// HandleAddSuppressionRule tests
// ========================================
func TestHandleAddSuppressionRule_MethodNotAllowed(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/suppressions", nil)
rec := httptest.NewRecorder()
handler.HandleAddSuppressionRule(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
// ========================================
// HandleDeleteSuppressionRule tests
// ========================================
func TestHandleDeleteSuppressionRule_MethodNotAllowed(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/suppressions/rule-123", nil)
rec := httptest.NewRecorder()
handler.HandleDeleteSuppressionRule(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
// ========================================
// HandleGetDismissedFindings tests
// ========================================
func TestHandleGetDismissedFindings_MethodNotAllowed(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodPost, "/api/ai/patrol/dismissed", nil)
rec := httptest.NewRecorder()
handler.HandleGetDismissedFindings(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
// ========================================
// HandleGetGuestKnowledge tests
// ========================================
func TestHandleGetGuestKnowledge_MissingGuestID(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodGet, "/api/ai/knowledge", nil)
rec := httptest.NewRecorder()
handler.HandleGetGuestKnowledge(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code)
}
}
// ========================================
// HandleSaveGuestNote tests
// ========================================
func TestHandleSaveGuestNote_InvalidBody(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodPost, "/api/ai/knowledge", bytes.NewReader([]byte(`{invalid json}`)))
rec := httptest.NewRecorder()
handler.HandleSaveGuestNote(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code)
}
}
func TestHandleSaveGuestNote_MissingFields(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
body := []byte(`{"guest_id": "vm-100"}`)
req := httptest.NewRequest(http.MethodPost, "/api/ai/knowledge", bytes.NewReader(body))
rec := httptest.NewRecorder()
handler.HandleSaveGuestNote(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code)
}
}
// ========================================
// HandleDeleteGuestNote tests
// ========================================
func TestHandleDeleteGuestNote_InvalidBody(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodPost, "/api/ai/knowledge/delete", bytes.NewReader([]byte(`{invalid json}`)))
rec := httptest.NewRecorder()
handler.HandleDeleteGuestNote(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code)
}
}
func TestHandleDeleteGuestNote_MissingFields(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
body := []byte(`{"guest_id": "vm-100"}`)
req := httptest.NewRequest(http.MethodPost, "/api/ai/knowledge/delete", bytes.NewReader(body))
rec := httptest.NewRecorder()
handler.HandleDeleteGuestNote(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code)
}
}
// ========================================
// HandleClearGuestKnowledge tests
// ========================================
func TestHandleClearGuestKnowledge_MethodNotAllowed(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodGet, "/api/ai/knowledge/clear", nil)
rec := httptest.NewRecorder()
handler.HandleClearGuestKnowledge(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
func TestHandleClearGuestKnowledge_MissingGuestID(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
body := []byte(`{}`)
req := httptest.NewRequest(http.MethodPost, "/api/ai/knowledge/clear", bytes.NewReader(body))
rec := httptest.NewRecorder()
handler.HandleClearGuestKnowledge(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code)
}
}
// ========================================
// HandleDebugContext tests
// ========================================
func TestHandleDebugContext_MethodNotAllowed(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodPost, "/api/ai/debug/context", nil)
rec := httptest.NewRecorder()
handler.HandleDebugContext(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
// ========================================
// HandleGetConnectedAgents tests
// ========================================
func TestHandleGetConnectedAgents_MethodNotAllowed(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodPost, "/api/ai/agents", nil)
rec := httptest.NewRecorder()
handler.HandleGetConnectedAgents(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
func TestHandleGetConnectedAgents_NoAgentServer(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
// handler created with nil agentServer
handler := NewAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodGet, "/api/ai/agents", nil)
rec := httptest.NewRecorder()
handler.HandleGetConnectedAgents(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
}
var resp struct {
Count int `json:"count"`
Note string `json:"note"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Count != 0 {
t.Fatalf("expected count=0, got %d", resp.Count)
}
if resp.Note == "" {
t.Fatalf("expected note to be present")
}
}
// ========================================
// HandleRunCommand tests
// ========================================
func TestHandleRunCommand_MethodNotAllowed(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodGet, "/api/ai/run-command", nil)
rec := httptest.NewRecorder()
handler.HandleRunCommand(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
func TestHandleRunCommand_InvalidBody(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodPost, "/api/ai/run-command", bytes.NewReader([]byte(`{invalid json}`)))
rec := httptest.NewRecorder()
handler.HandleRunCommand(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code)
}
}
// ========================================
// HandleAnalyzeKubernetesCluster tests
// ========================================
func TestHandleAnalyzeKubernetesCluster_MethodNotAllowed(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodGet, "/api/ai/kubernetes/analyze", nil)
rec := httptest.NewRecorder()
handler.HandleAnalyzeKubernetesCluster(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
func TestHandleAnalyzeKubernetesCluster_InvalidBody(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodPost, "/api/ai/kubernetes/analyze", bytes.NewReader([]byte(`{invalid json}`)))
rec := httptest.NewRecorder()
handler.HandleAnalyzeKubernetesCluster(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code)
}
}
// ========================================
// HandleInvestigateAlert tests
// ========================================
func TestHandleInvestigateAlert_MethodNotAllowed(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodGet, "/api/ai/investigate", nil)
rec := httptest.NewRecorder()
handler.HandleInvestigateAlert(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
func TestHandleInvestigateAlert_InvalidBody(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodPost, "/api/ai/investigate", bytes.NewReader([]byte(`{invalid json}`)))
rec := httptest.NewRecorder()
handler.HandleInvestigateAlert(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code)
}
}
func TestHandleInvestigateAlert_MissingAlertID(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
body := []byte(`{}`)
req := httptest.NewRequest(http.MethodPost, "/api/ai/investigate", bytes.NewReader(body))
rec := httptest.NewRecorder()
handler.HandleInvestigateAlert(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code)
}
}
// ========================================
// AISettingsHandler setter method tests
// ========================================
func TestAISettingsHandler_SetConfig(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
// SetConfig with nil should be a no-op
handler.SetConfig(nil)
// SetConfig with new config should update the handler's config
newCfg := &config.Config{DataPath: tmp, BackendPort: 9999}
handler.SetConfig(newCfg)
// No assertion needed - just verifying it doesn't panic
}
func TestAISettingsHandler_StopPatrol(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
// StopPatrol should be safe to call even when patrol is not running
handler.StopPatrol()
// No assertion needed - just verifying it doesn't panic
}
func TestAISettingsHandler_GetAlertTriggeredAnalyzer(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
// Should return the analyzer (may be nil if not initialized)
analyzer := handler.GetAlertTriggeredAnalyzer()
// Just verify it doesn't panic and returns something
_ = analyzer
}
func TestAISettingsHandler_StartPatrol(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
// Start patrol with a cancellable context
ctx, cancel := context.WithCancel(context.Background())
handler.StartPatrol(ctx)
// Give it a brief moment then stop
time.Sleep(10 * time.Millisecond)
cancel()
handler.StopPatrol()
}
func TestAISettingsHandler_SetPatrolFindingsPersistence(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
// Set nil persistence should not panic
err := handler.SetPatrolFindingsPersistence(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestAISettingsHandler_SetPatrolRunHistoryPersistence(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
// Set nil persistence should not panic
err := handler.SetPatrolRunHistoryPersistence(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestAISettingsHandler_SetPatrolThresholdProvider(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
// Set nil threshold provider should not panic
handler.SetPatrolThresholdProvider(nil)
}
func TestAISettingsHandler_SetMetricsHistoryProvider(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
// Set nil metrics provider should not panic
handler.SetMetricsHistoryProvider(nil)
}
func TestAISettingsHandler_SetBaselineStore(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
// Set nil baseline store should not panic
handler.SetBaselineStore(nil)
}
func TestAISettingsHandler_SetChangeDetector(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
// Set nil change detector should not panic
handler.SetChangeDetector(nil)
}
func TestAISettingsHandler_SetRemediationLog(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
// Set nil remediation log should not panic
handler.SetRemediationLog(nil)
}
func TestAISettingsHandler_SetPatternDetector(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
// Set nil pattern detector should not panic
handler.SetPatternDetector(nil)
}
func TestAISettingsHandler_SetCorrelationDetector(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := NewAISettingsHandler(cfg, persistence, nil)
// Set nil correlation detector should not panic
handler.SetCorrelationDetector(nil)
}