package bell_rule_alert_test import ( "context" "encoding/json" "fmt" "os" "testing" "time" "gorm.io/driver/postgres" "gorm.io/gorm" adminmodels "go-admin/app/admin/models" "go-admin/app/bell/alert" "go-admin/app/bell/evaluation" "go-admin/app/bell/event" "go-admin/app/bell/receipt" "go-admin/app/bell/rule" ) func TestPostgresRuleEvaluationAndAlertProjection(t *testing.T) { dsn := os.Getenv("BELL_RULE_ALERT_TEST_DATABASE_URL") if dsn == "" { t.Skip("set BELL_RULE_ALERT_TEST_DATABASE_URL to run the isolated PostgreSQL test") } db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) if err != nil { t.Fatal(err) } ctx := context.Background() rules := rule.NewService(db) events := event.NewService(db) evaluations := evaluation.NewService(db) alerts := alert.NewService(db) assertOperatorAccess(t, db) createOperatorUser(t, db) eventType := "danger_area_entered" location := "东门" first, err := rules.Create(ctx, rule.WriteInput{Code: "area-high", Name: "高风险区域", EventType: &eventType, MinimumSeverity: "high", LocationContains: &location}, 1) if err != nil { t.Fatal(err) } second, err := rules.Create(ctx, rule.WriteInput{Code: "all-high", Name: "全局高风险", MinimumSeverity: "high"}, 1) if err != nil { t.Fatal(err) } otherEventType := "fire_detected" _, err = rules.Create(ctx, rule.WriteInput{Code: "critical-fire", Name: "仅严重火情", EventType: &otherEventType, MinimumSeverity: "critical"}, 1) if err != nil { t.Fatal(err) } created := ingest(t, events, "event-001", "东门 A 区", "high") result, err := evaluations.ForEvent(ctx, created.Event.ID) if err != nil { t.Fatal(err) } if len(result.Evaluations) != 3 || len(result.Alerts) != 2 { t.Fatalf("expected 3 evaluations and 2 alerts, got %d and %d", len(result.Evaluations), len(result.Alerts)) } matched, unmatched := 0, 0 for _, item := range result.Evaluations { if item.Matched { matched++ } else if item.Explanation != "" { unmatched++ } } if matched != 2 || unmatched != 1 { t.Fatalf("unexpected match explanations: matched=%d unmatched=%d", matched, unmatched) } replay := ingest(t, events, "event-001", "东门 A 区", "high") if !replay.Duplicate || replay.Event.ID != created.Event.ID { t.Fatalf("idempotent replay created another fact: %#v", replay) } assertCount(t, db, "bell_rule_evaluations", 3) assertCount(t, db, "bell_alerts", 2) secondEvent := ingest(t, events, "event-002", "东门 A 区", "critical") assertCount(t, db, "bell_alerts", 2) items, total, err := alerts.List(ctx, alert.PageQuery{PageIndex: 1, PageSize: 20, Status: "open"}) if err != nil || total != 2 || len(items) != 2 { t.Fatalf("unexpected alert list: total=%d len=%d err=%v", total, len(items), err) } for _, item := range items { if item.EventCount != 2 || item.Status != "open" || item.Severity != "critical" { t.Fatalf("open alert did not aggregate and escalate: %#v", item) } detail, detailErr := alerts.Get(ctx, item.ID) if detailErr != nil || len(detail.Events) != 2 || len(detail.Matches) != 2 { t.Fatalf("event-alert navigation is incomplete: events=%d matches=%d err=%v", len(detail.Events), len(detail.Matches), detailErr) } } updated, err := rules.Update(ctx, first.ID, rule.WriteInput{Name: "高风险区域(更新)", EventType: &eventType, MinimumSeverity: "medium", LocationContains: &location}, 1) if err != nil || updated.Version != 2 { t.Fatalf("rule version was not incremented: version=%d err=%v", updated.Version, err) } if _, err = rules.SetEnabled(ctx, second.ID, false, 1); err != nil { t.Fatal(err) } thirdEvent := ingest(t, events, "event-003", "东门 B 区", "medium") thirdResults, err := evaluations.ForEvent(ctx, thirdEvent.Event.ID) if err != nil { t.Fatal(err) } if len(thirdResults.Evaluations) != 2 || len(thirdResults.Alerts) != 1 { t.Fatalf("disabled rule was evaluated: evaluations=%d alerts=%d", len(thirdResults.Evaluations), len(thirdResults.Alerts)) } var snapshot struct { Version int `json:"version"` } for _, item := range thirdResults.Evaluations { if item.RuleID == first.ID { if err = json.Unmarshal(item.RuleSnapshot, &snapshot); err != nil || item.RuleVersion != 2 || snapshot.Version != 2 { t.Fatalf("versioned rule snapshot missing: item=%#v snapshot=%#v err=%v", item, snapshot, err) } } } if err = db.Model(&evaluation.Evaluation{}).Where("event_id = ? AND rule_id = ?", created.Event.ID, first.ID).Update("explanation", "tampered").Error; err == nil { t.Fatal("immutable evaluation update unexpectedly succeeded") } if err = db.Exec(`CREATE FUNCTION bell_test_reject_alert() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'forced alert failure'; END $$`).Error; err != nil { t.Fatal(err) } if err = db.Exec(`CREATE TRIGGER bell_test_reject_alert BEFORE INSERT ON bell_alerts FOR EACH ROW EXECUTE FUNCTION bell_test_reject_alert()`).Error; err != nil { t.Fatal(err) } failedID := "event-rollback" _, ingestErr := events.Ingest(ctx, eventCommand(failedID, "东门 C 区", "high"), 1) if ingestErr == nil { t.Fatal("forced alert failure did not roll back Event ingest") } var eventCount, receiptCount int64 db.Model(&event.Event{}).Where("source_event_id = ?", failedID).Count(&eventCount) db.Model(&receipt.Receipt{}).Where("source_event_id = ?", failedID).Count(&receiptCount) if eventCount != 0 || receiptCount != 0 { t.Fatalf("transaction failure left partial facts: events=%d receipts=%d", eventCount, receiptCount) } if err = db.Exec(`DROP TRIGGER bell_test_reject_alert ON bell_alerts; DROP FUNCTION bell_test_reject_alert()`).Error; err != nil { t.Fatal(err) } if secondEvent.Event.ID == thirdEvent.Event.ID { t.Fatal("independent Events unexpectedly share an id") } } func ingest(t *testing.T, service event.Service, sourceID, location, severity string) event.Result { t.Helper() result, err := service.Ingest(context.Background(), eventCommand(sourceID, location, severity), 1) if err != nil { t.Fatal(err) } return result } func eventCommand(sourceID, location, severity string) event.Command { return event.Command{ProducerID: "bell.rule-test", SourceEventID: sourceID, EventType: "danger_area_entered", OccurredAt: time.Date(2026, 8, 29, 0, 0, 0, 0, time.UTC), Location: location, Severity: severity, Attributes: map[string]any{"test": true}} } func assertCount(t *testing.T, db *gorm.DB, table string, want int64) { t.Helper() var got int64 if err := db.Table(table).Count(&got).Error; err != nil { t.Fatal(err) } if got != want { t.Fatal(fmt.Sprintf("%s count: got %d want %d", table, got, want)) } } func assertOperatorAccess(t *testing.T, db *gorm.DB) { t.Helper() var menuCount, readPolicyCount, ruleWritePolicyCount, lifecycleWritePolicyCount int64 if err := db.Table("sys_role_menu rm").Joins("JOIN sys_role r ON r.role_id = rm.role_id"). Joins("JOIN sys_menu m ON m.menu_id = rm.menu_id"). Where("r.role_key = ? AND m.path IN ?", "operator", []string{"/bell", "alerts", "events", "rules"}).Count(&menuCount).Error; err != nil { t.Fatal(err) } if err := db.Table("casbin_rule").Where("v0 = ? AND v2 = ?", "operator", "GET").Count(&readPolicyCount).Error; err != nil { t.Fatal(err) } if err := db.Table("casbin_rule").Where("v0 = ? AND v2 <> ? AND v1 LIKE ?", "operator", "GET", "/api/v1/bell/rules%").Count(&ruleWritePolicyCount).Error; err != nil { t.Fatal(err) } if err := db.Table("casbin_rule").Where("v0 = ? AND v2 = ? AND v1 IN ?", "operator", "POST", []string{"/api/v1/bell/alerts/:id/ack", "/api/v1/bell/alerts/:id/close"}).Count(&lifecycleWritePolicyCount).Error; err != nil { t.Fatal(err) } if menuCount != 4 || readPolicyCount < 6 || ruleWritePolicyCount != 0 || lifecycleWritePolicyCount > 2 { t.Fatalf("operator access escaped Bell scope: menus=%d reads=%d rule_writes=%d lifecycle_writes=%d", menuCount, readPolicyCount, ruleWritePolicyCount, lifecycleWritePolicyCount) } } func createOperatorUser(t *testing.T, db *gorm.DB) { t.Helper() password := os.Getenv("BELL_RULE_ALERT_OPERATOR_PASSWORD") if password == "" { t.Skip("set BELL_RULE_ALERT_OPERATOR_PASSWORD for the HTTP RBAC continuation") } var roleID int if err := db.Table("sys_role").Select("role_id").Where("role_key = ?", "operator").Scan(&roleID).Error; err != nil || roleID == 0 { t.Fatalf("load operator role: id=%d err=%v", roleID, err) } user := adminmodels.SysUser{Username: "bell_132_operator", Password: password, NickName: "Bell 处置员", RoleId: roleID, DeptId: 1, PostId: 1, Status: "2"} if err := db.Create(&user).Error; err != nil { t.Fatal(err) } }