package handler import ( "context" "crypto/rand" "encoding/hex" "errors" "net/http" "net/http/httptest" "os" "path/filepath" "sort" "strings" "testing" "time" "github.com/company/ai-ops/internal/config" "github.com/company/ai-ops/internal/database" "github.com/company/ai-ops/internal/domain/model" "github.com/company/ai-ops/internal/service" ) type fakeHandlerAlertRepo struct { rules []model.AlertRule events []model.AlertEvent err error } func (r *fakeHandlerAlertRepo) GetOpenCount(context.Context) (*model.AlertCount, error) { return &model.AlertCount{}, r.err } func (r *fakeHandlerAlertRepo) ListRules(context.Context) ([]model.AlertRule, error) { return r.rules, r.err } func (r *fakeHandlerAlertRepo) GetRuleByID(_ context.Context, id string) (*model.AlertRule, error) { if r.err != nil { return nil, r.err } return &model.AlertRule{ID: id, Name: "rule"}, nil } func (r *fakeHandlerAlertRepo) CreateRule(context.Context, *model.AlertRule) error { return r.err } func (r *fakeHandlerAlertRepo) UpdateRule(context.Context, *model.AlertRule) error { return r.err } func (r *fakeHandlerAlertRepo) DeleteRule(context.Context, string) error { return r.err } func (r *fakeHandlerAlertRepo) ListEvents(context.Context, string, int, int) ([]model.AlertEvent, int, error) { return r.events, len(r.events), r.err } func (r *fakeHandlerAlertRepo) CreateEvent(context.Context, *model.AlertEvent) error { return r.err } func (r *fakeHandlerAlertRepo) CreateEventWithAggregation(_ context.Context, e *model.AlertEvent, _ time.Duration, _ int) (*model.AlertEvent, error) { return e, r.err } func (r *fakeHandlerAlertRepo) UpdateEventStatus(context.Context, string, string) error { return r.err } func (r *fakeHandlerAlertRepo) EscalateEvent(context.Context, string, string) error { return r.err } type fakeHandlerChannelRepo struct { channels []model.NotificationChannel err error } func (r *fakeHandlerChannelRepo) List(context.Context) ([]model.NotificationChannel, error) { return r.channels, r.err } func (r *fakeHandlerChannelRepo) GetByID(_ context.Context, id string) (*model.NotificationChannel, error) { if r.err != nil { return nil, r.err } return &model.NotificationChannel{ID: id, Name: "hook"}, nil } func (r *fakeHandlerChannelRepo) Create(context.Context, *model.NotificationChannel) error { return r.err } func (r *fakeHandlerChannelRepo) Update(context.Context, *model.NotificationChannel) error { return r.err } func (r *fakeHandlerChannelRepo) Delete(context.Context, string) error { return r.err } type fakeHandlerLogRepo struct { logs []model.RequestLog total int err error } func (r *fakeHandlerLogRepo) Query(context.Context, model.LogQueryFilter) ([]model.RequestLog, int, error) { return r.logs, r.total, r.err } func TestAuthHandlerLoginRolesAndValidation(t *testing.T) { h := NewAuthHandler(service.NewAuthService("secret")) cases := []struct{ username, wantRole string }{{"admin", "admin"}, {"ops", "operator"}, {"alice", "viewer"}} for _, tc := range cases { w := httptest.NewRecorder() h.Login(w, httptest.NewRequest(http.MethodPost, "/api/v1/ai-ops/login", strings.NewReader(`{"username":"`+tc.username+`","password":"pw"}`))) if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"role":"`+tc.wantRole+`"`) || !strings.Contains(w.Body.String(), `"token"`) { t.Fatalf("login %s failed: status=%d body=%s", tc.username, w.Code, w.Body.String()) } } w := httptest.NewRecorder() h.Login(w, httptest.NewRequest(http.MethodPost, "/api/v1/ai-ops/login", strings.NewReader(`{"username":"","password":""}`))) if w.Code != http.StatusBadRequest { t.Fatalf("invalid login status = %d", w.Code) } bad := httptest.NewRecorder() h.Login(bad, httptest.NewRequest(http.MethodPost, "/api/v1/ai-ops/login", strings.NewReader(`{`))) if bad.Code != http.StatusBadRequest { t.Fatalf("bad json status = %d", bad.Code) } } func TestHealthAndDashboardHandlers(t *testing.T) { health := NewHealthHandler() w := httptest.NewRecorder() health.Health(w, httptest.NewRequest(http.MethodGet, "/actuator/health", nil)) if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"status":"UP"`) { t.Fatalf("health = %d %s", w.Code, w.Body.String()) } live := httptest.NewRecorder() health.Live(live, httptest.NewRequest(http.MethodGet, "/actuator/health/live", nil)) if live.Code != http.StatusOK { t.Fatalf("live = %d", live.Code) } ready := httptest.NewRecorder() health.Ready(ready, httptest.NewRequest(http.MethodGet, "/actuator/health/ready", nil)) if ready.Code != http.StatusServiceUnavailable || !strings.Contains(ready.Body.String(), `"status":"DOWN"`) { t.Fatalf("ready = %d %s", ready.Code, ready.Body.String()) } dash := httptest.NewRecorder() NewDashboardHandler().Dashboard(dash, httptest.NewRequest(http.MethodGet, "/ops/dashboard", nil)) if dash.Code != http.StatusOK || !strings.Contains(dash.Body.String(), "AI-Ops 运维看板") { t.Fatalf("dashboard = %d", dash.Code) } } func TestRuleHandlerCRUDHappyAndErrorPaths(t *testing.T) { repo := &fakeHandlerAlertRepo{rules: []model.AlertRule{{ID: "r1", Name: "rule"}}} h := NewRuleHandler(service.NewRuleService(repo)) mux := http.NewServeMux() h.RegisterRoutes(mux) for _, tc := range []struct { method, path string body string want int }{ {http.MethodGet, "/api/v1/ai-ops/rules", "", http.StatusOK}, {http.MethodGet, "/api/v1/ai-ops/rules/r1", "", http.StatusOK}, {http.MethodPost, "/api/v1/ai-ops/rules", `{"id":"r2","name":"latency","metric_name":"p99"}`, http.StatusCreated}, {http.MethodPut, "/api/v1/ai-ops/rules/r2", `{"name":"latency","metric_name":"p99"}`, http.StatusOK}, {http.MethodDelete, "/api/v1/ai-ops/rules/r2", "", http.StatusNoContent}, {http.MethodPost, "/api/v1/ai-ops/rules", `{`, http.StatusBadRequest}, {http.MethodPost, "/api/v1/ai-ops/rules", `{}`, http.StatusBadRequest}, } { w := httptest.NewRecorder() mux.ServeHTTP(w, httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))) if w.Code != tc.want { t.Fatalf("%s %s status=%d want=%d body=%s", tc.method, tc.path, w.Code, tc.want, w.Body.String()) } } errHandler := NewRuleHandler(service.NewRuleService(&fakeHandlerAlertRepo{err: errors.New("db")})) errMux := http.NewServeMux() errHandler.RegisterRoutes(errMux) w := httptest.NewRecorder() errMux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/ai-ops/rules", nil)) if w.Code != http.StatusInternalServerError { t.Fatalf("error list status = %d", w.Code) } } func TestChannelHandlerCRUDHappyAndErrorPaths(t *testing.T) { h := NewChannelHandler(service.NewChannelService(&fakeHandlerChannelRepo{channels: []model.NotificationChannel{{ID: "c1", Name: "hook"}}})) mux := http.NewServeMux() h.RegisterRoutes(mux) for _, tc := range []struct { method, path, body string want int }{ {http.MethodGet, "/api/v1/ai-ops/channels", "", http.StatusOK}, {http.MethodGet, "/api/v1/ai-ops/channels/c1", "", http.StatusOK}, {http.MethodPost, "/api/v1/ai-ops/channels", `{"name":"hook","channel_type":"webhook"}`, http.StatusCreated}, {http.MethodPut, "/api/v1/ai-ops/channels/c1", `{"name":"hook","channel_type":"webhook"}`, http.StatusOK}, {http.MethodDelete, "/api/v1/ai-ops/channels/c1", "", http.StatusNoContent}, {http.MethodPost, "/api/v1/ai-ops/channels/test", `{"channel_id":"c1","message":"hello"}`, http.StatusOK}, {http.MethodPost, "/api/v1/ai-ops/channels", `{}`, http.StatusBadRequest}, {http.MethodPost, "/api/v1/ai-ops/channels/test", `{`, http.StatusBadRequest}, } { w := httptest.NewRecorder() mux.ServeHTTP(w, httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))) if w.Code != tc.want { t.Fatalf("%s %s status=%d want=%d body=%s", tc.method, tc.path, w.Code, tc.want, w.Body.String()) } } } func TestAlertAndLogHandlers(t *testing.T) { alertHandler := NewAlertHandler(&fakeHandlerAlertRepo{events: []model.AlertEvent{{ID: "e1", Status: "triggered"}}}) alertMux := http.NewServeMux() alertHandler.RegisterRoutes(alertMux) aw := httptest.NewRecorder() alertMux.ServeHTTP(aw, httptest.NewRequest(http.MethodGet, "/api/v1/ai-ops/alerts?status=triggered&page=2&page_size=5", nil)) if aw.Code != http.StatusOK || !strings.Contains(aw.Body.String(), `"items"`) { t.Fatalf("alerts = %d %s", aw.Code, aw.Body.String()) } logHandler := NewLogHandler(service.NewLogService(&fakeHandlerLogRepo{logs: []model.RequestLog{{Timestamp: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), Service: "api", Path: "/v1", Method: "GET", StatusCode: 200}}, total: 1})) logMux := http.NewServeMux() logHandler.RegisterRoutes(logMux) lw := httptest.NewRecorder() logMux.ServeHTTP(lw, httptest.NewRequest(http.MethodGet, "/api/v1/ai-ops/logs?page=2&page_size=5&status_code=200", nil)) if lw.Code != http.StatusOK || !strings.Contains(lw.Body.String(), `"total_pages"`) { t.Fatalf("logs = %d %s", lw.Code, lw.Body.String()) } csv := httptest.NewRecorder() logMux.ServeHTTP(csv, httptest.NewRequest(http.MethodGet, "/api/v1/ai-ops/logs/export", nil)) if csv.Code != http.StatusOK || !strings.Contains(csv.Body.String(), "时间,服务名") { t.Fatalf("csv = %d %s", csv.Code, csv.Body.String()) } badCSV := httptest.NewRecorder() badLogHandler := NewLogHandler(service.NewLogService(&fakeHandlerLogRepo{err: errors.New("export failed")})) badLogMux := http.NewServeMux() badLogHandler.RegisterRoutes(badLogMux) badLogMux.ServeHTTP(badCSV, httptest.NewRequest(http.MethodGet, "/api/v1/ai-ops/logs/export?start=bad&end=bad&status_code=bad", nil)) if badCSV.Code != http.StatusInternalServerError { t.Fatalf("csv error = %d %s", badCSV.Code, badCSV.Body.String()) } } type fakeHandlerMetricRepo struct { realtime *model.RealtimeMetrics points []model.MetricPoint err error } func (r *fakeHandlerMetricRepo) GetRealtime(context.Context) (*model.RealtimeMetrics, error) { if r.err != nil { return nil, r.err } return r.realtime, nil } func (r *fakeHandlerMetricRepo) Query(context.Context, model.MetricQueryRequest) ([]model.MetricPoint, error) { if r.err != nil { return nil, r.err } return r.points, nil } func (r *fakeHandlerMetricRepo) GetLatest(context.Context, string, string) (*model.MetricPoint, error) { if r.err != nil { return nil, r.err } return &model.MetricPoint{Value: 1}, nil } func TestRegisterRoutesForSmallHandlers(t *testing.T) { mux := http.NewServeMux() NewAuthHandler(service.NewAuthService("secret")).RegisterRoutes(mux) NewDashboardHandler().RegisterRoutes(mux) NewHealthHandler().RegisterRoutes(mux) NewHealingHandler().RegisterRoutes(mux) w := httptest.NewRecorder() mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/ai-ops/healings", nil)) if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"total":0`) { t.Fatalf("healings = %d %s", w.Code, w.Body.String()) } one := httptest.NewRecorder() mux.ServeHTTP(one, httptest.NewRequest(http.MethodGet, "/api/v1/ai-ops/healings/h1", nil)) if one.Code != http.StatusOK || !strings.Contains(one.Body.String(), `"id":"h1"`) { t.Fatalf("healing = %d %s", one.Code, one.Body.String()) } } func TestMetricHandlerRoutesAndErrors(t *testing.T) { metricRepo := &fakeHandlerMetricRepo{realtime: &model.RealtimeMetrics{QPS: 9}, points: []model.MetricPoint{{Name: "qps", Value: 1}}} alertRepo := &fakeHandlerAlertRepo{} h := NewMetricHandler(service.NewMetricService(metricRepo, alertRepo)) mux := http.NewServeMux() h.RegisterRoutes(mux) for _, path := range []string{ "/api/v1/ai-ops/metrics/realtime", "/api/v1/ai-ops/metrics/suppliers/count", "/api/v1/ai-ops/alerts/open/count", "/api/v1/ai-ops/metrics/query?source=prom&name=qps&start=2026-01-01T00:00:00Z&end=2026-01-01T01:00:00Z", } { w := httptest.NewRecorder() mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) if w.Code != http.StatusOK { t.Fatalf("%s status=%d body=%s", path, w.Code, w.Body.String()) } } errHandler := NewMetricHandler(service.NewMetricService(&fakeHandlerMetricRepo{err: errors.New("metrics down")}, &fakeHandlerAlertRepo{err: errors.New("alerts down")})) errMux := http.NewServeMux() errHandler.RegisterRoutes(errMux) for _, path := range []string{"/api/v1/ai-ops/metrics/realtime", "/api/v1/ai-ops/metrics/query", "/api/v1/ai-ops/alerts/open/count"} { w := httptest.NewRecorder() errMux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) if w.Code != http.StatusInternalServerError { t.Fatalf("%s status=%d", path, w.Code) } } } func setupHandlerAuditDB(t *testing.T) context.Context { t.Helper() ctx := context.Background() if database.Pool == nil { ports := []int{15432, 5432} var lastErr error for _, port := range ports { lastErr = database.Init(config.DatabaseConfig{Host: "localhost", Port: port, User: "aiops", Password: "aiops123", DBName: "ai_ops", SSLMode: "disable", PoolSize: 4}) if lastErr == nil { break } database.Close() database.Pool = nil } if lastErr != nil { t.Skipf("PostgreSQL integration database not available: %v", lastErr) } } if _, err := database.Pool.Exec(ctx, `SELECT pg_advisory_lock(424242001)`); err != nil { t.Fatal(err) } defer database.Pool.Exec(ctx, `SELECT pg_advisory_unlock(424242001)`) files, err := filepath.Glob(filepath.Join("..", "..", "tech", "migrations", "*.up.sql")) if err != nil { t.Fatal(err) } sort.Strings(files) for _, f := range files { b, err := os.ReadFile(f) if err != nil { t.Fatal(err) } if _, err := database.Pool.Exec(ctx, string(b)); err != nil { t.Fatalf("apply migration %s: %v", f, err) } } return ctx } func handlerUUID(t *testing.T) string { t.Helper() b := make([]byte, 16) if _, err := rand.Read(b); err != nil { t.Fatal(err) } b[6] = (b[6] & 0x0f) | 0x40 b[8] = (b[8] & 0x3f) | 0x80 return hex.EncodeToString(b[0:4]) + "-" + hex.EncodeToString(b[4:6]) + "-" + hex.EncodeToString(b[6:8]) + "-" + hex.EncodeToString(b[8:10]) + "-" + hex.EncodeToString(b[10:16]) } func TestAuditHandlerListAndRollback(t *testing.T) { ctx := setupHandlerAuditDB(t) svc := service.NewAuditService() id := handlerUUID(t) defer database.Pool.Exec(ctx, `DELETE FROM ai_ops_audits WHERE id=$1 OR parent_audit_id=$1 OR object_id=$1`, id) if err := svc.Record(ctx, &service.AuditLog{ID: id, TenantID: "tenant", ObjectType: "rule", ObjectID: id, Action: "update", BeforeState: map[string]any{"enabled": false}, AfterState: map[string]any{"enabled": true}, RequestID: "req", ResultCode: "SUCCESS", SourceIP: "127.0.0.1", ActorID: "actor", RiskLevel: "normal"}); err != nil { t.Fatal(err) } h := NewAuditHandler(svc) mux := http.NewServeMux() h.RegisterRoutes(mux) list := httptest.NewRecorder() mux.ServeHTTP(list, httptest.NewRequest(http.MethodGet, "/api/v1/ai-ops/audits?object_type=rule&object_id="+id+"&page=0&page_size=999", nil)) if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), id) { t.Fatalf("list audits = %d %s", list.Code, list.Body.String()) } rollback := httptest.NewRecorder() mux.ServeHTTP(rollback, httptest.NewRequest(http.MethodPost, "/api/v1/ai-ops/audits/"+id+"/rollback", nil)) if rollback.Code != http.StatusOK || !strings.Contains(rollback.Body.String(), `"action":"rollback"`) { t.Fatalf("rollback = %d %s", rollback.Code, rollback.Body.String()) } missing := httptest.NewRecorder() mux.ServeHTTP(missing, httptest.NewRequest(http.MethodPost, "/api/v1/ai-ops/audits/"+handlerUUID(t)+"/rollback", nil)) if missing.Code != http.StatusBadRequest { t.Fatalf("missing rollback status = %d", missing.Code) } }