963 lines
46 KiB
Go
963 lines
46 KiB
Go
package platform
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/DATA-DOG/go-sqlmock"
|
|
)
|
|
|
|
func TestAlertSchemaProbeRetriesAfterCanceledRequestAndCachesSuccess(t *testing.T) {
|
|
db, mock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
store := NewProductionStore(db, nil, "")
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if err := store.ensureAlertSchema(ctx); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("expected canceled schema probe, got %v", err)
|
|
}
|
|
|
|
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM vehicle_alert_rule`)).
|
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
|
if err := store.ensureAlertSchema(t.Context()); err != nil {
|
|
t.Fatalf("expected retry to succeed, got %v", err)
|
|
}
|
|
if err := store.ensureAlertSchema(t.Context()); err != nil {
|
|
t.Fatalf("expected successful probe to stay cached, got %v", err)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestAlertSQLTimestampsDeclareShanghaiOffset(t *testing.T) {
|
|
if alertSQLTimestampFormat != "%Y-%m-%dT%H:%i:%s.%f+08:00" {
|
|
t.Fatalf("unexpected alert timestamp format %q", alertSQLTimestampFormat)
|
|
}
|
|
for name, query := range map[string]string{
|
|
"event": alertEventSelect,
|
|
"action": alertActionSelect,
|
|
"rule": alertRuleSelect,
|
|
"notification": alertNotificationSelect,
|
|
} {
|
|
if !strings.Contains(query, alertSQLTimestampFormat) {
|
|
t.Fatalf("%s query does not use the alert timestamp format", name)
|
|
}
|
|
if strings.Contains(query, "%fZ") {
|
|
t.Fatalf("%s query still labels local database time as UTC", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNormalizeVehicleEventPublishesCanonicalContract(t *testing.T) {
|
|
event := normalizeVehicleEvent(AlertEvent{TriggerType: "geofence", Metric: "geofence_distance_m", Operator: "exit", Status: "unprocessed"})
|
|
if event.EventType != "vehicle.geofence.exited" || event.EventCategory != "geofence" || event.ExecutionState != "pending" {
|
|
t.Fatalf("unexpected canonical event: %#v", event)
|
|
}
|
|
recovered := normalizeVehicleEvent(AlertEvent{TriggerType: "offline", Metric: "freshness_sec", Status: "recovered"})
|
|
if recovered.EventType != "vehicle.connectivity.offline" || recovered.ExecutionState != "recovered" {
|
|
t.Fatalf("unexpected recovered event: %#v", recovered)
|
|
}
|
|
}
|
|
|
|
type configuredMetricStore struct {
|
|
*MockStore
|
|
definitions []MetricDefinition
|
|
}
|
|
|
|
func (s *configuredMetricStore) MetricDefinitions(context.Context) ([]MetricDefinition, error) {
|
|
return append([]MetricDefinition(nil), s.definitions...), nil
|
|
}
|
|
|
|
func BenchmarkAlertEvaluationPlan10000x20(b *testing.B) {
|
|
evidence := make([]alertEvaluationEvidence, 10_000)
|
|
for index := range evidence {
|
|
evidence[index] = alertEvaluationEvidence{VIN: "VIN" + strconv.Itoa(index), Protocol: "JT808", SpeedKmh: float64(index % 120), FreshnessSec: 5}
|
|
}
|
|
rules := make([]AlertRule, 20)
|
|
for index := range rules {
|
|
rules[index] = AlertRule{ID: "rule" + strconv.Itoa(index), Enabled: true, Metric: "speed_kmh", Operator: "gt", Threshold: float64(60 + index)}
|
|
}
|
|
now := time.Now()
|
|
b.ResetTimer()
|
|
for iteration := 0; iteration < b.N; iteration++ {
|
|
upserts := make([]alertCandidateUpsert, 0, len(evidence))
|
|
for _, rule := range rules {
|
|
for _, item := range evidence {
|
|
value, ok := alertMetricValue(rule.Metric, item)
|
|
if ok && alertRuleInScope(rule, item) && compareAlertValue(value, rule.Operator, rule.Threshold) {
|
|
upserts = append(upserts, alertCandidateUpsert{RuleID: rule.ID, VIN: item.VIN, Protocol: item.Protocol, FirstMatchedAt: now, LastMatchedAt: now, LatestValue: value})
|
|
}
|
|
}
|
|
}
|
|
for start := 0; start < len(upserts); start += alertCandidateBatchSize {
|
|
end := min(start+alertCandidateBatchSize, len(upserts))
|
|
_, args := buildAlertCandidateUpsert(upserts[start:end])
|
|
if len(args) == 0 {
|
|
b.Fatal("expected batched candidates")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAlertEventWorkflowIsVersionedAndAudited(t *testing.T) {
|
|
handler := NewHandler(NewService(NewMockStore()))
|
|
list := httptest.NewRecorder()
|
|
handler.ServeHTTP(list, httptest.NewRequest(http.MethodPost, "/api/v2/alerts/events", bytes.NewBufferString(`{"status":"unprocessed","limit":20}`)))
|
|
if list.Code != http.StatusOK {
|
|
t.Fatalf("list status=%d body=%s", list.Code, list.Body.String())
|
|
}
|
|
var body struct {
|
|
Data Page[AlertEvent] `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(list.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body.Data.Total < 2 {
|
|
t.Fatalf("expected seeded unprocessed events, got %+v", body.Data)
|
|
}
|
|
event := body.Data.Items[0]
|
|
action := func(version int) *httptest.ResponseRecorder {
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v2/alerts/events/"+event.ID+"/actions", bytes.NewBufferString(`{"version":`+itoa(version)+`,"action":"acknowledge","note":"已联系司机","actor":"spoofed-body-user"}`))
|
|
req.Header.Set("X-User-Name", "spoofed-header-user")
|
|
req = req.WithContext(WithPrincipal(req.Context(), Principal{Name: "operator-a", Role: "operator"}))
|
|
handler.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
rec := action(event.Version)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("action status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var updated struct {
|
|
Data AlertEvent `json:"data"`
|
|
}
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &updated)
|
|
if updated.Data.Status != "processing" || updated.Data.Handler != "operator-a" || len(updated.Data.Actions) < 2 {
|
|
t.Fatalf("workflow not persisted: %+v", updated.Data)
|
|
}
|
|
stale := action(event.Version)
|
|
if stale.Code != http.StatusConflict {
|
|
t.Fatalf("stale update should conflict, status=%d body=%s", stale.Code, stale.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAlertEventsActiveStatusIncludesOnlyOpenWorkflowStates(t *testing.T) {
|
|
service := NewService(NewMockStore())
|
|
page, err := service.AlertEvents(t.Context(), AlertQuery{Status: "active", Limit: 200})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if page.Total == 0 {
|
|
t.Fatal("expected seeded active alerts")
|
|
}
|
|
for _, event := range page.Items {
|
|
if event.Status != "unprocessed" && event.Status != "processing" {
|
|
t.Fatalf("active query returned terminal state: %+v", event)
|
|
}
|
|
}
|
|
if where, args := buildAlertWhere(AlertQuery{Status: "active"}); !strings.Contains(where, "status IN ('unprocessed','processing')") || len(args) != 0 {
|
|
t.Fatalf("active SQL filter is not authoritative: where=%s args=%v", where, args)
|
|
}
|
|
}
|
|
|
|
func TestAlertRuleCreationNormalizesBooleanAndChannels(t *testing.T) {
|
|
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{AlertNotificationConfig: AlertNotificationConfig{
|
|
Targets: []AlertNotificationTargetOption{{ID: "night-shift", Label: "夜班负责人", Channels: []string{"sms"}}},
|
|
Channels: []AlertNotificationChannelCapability{{Channel: "in_app", Label: "站内信", Configured: true}, {Channel: "sms", Label: "短信", Configured: true}},
|
|
}})
|
|
truth := true
|
|
rule, err := service.SaveAlertRule(t.Context(), AlertRuleInput{Name: "主电源异常", Severity: "major", ValueType: "boolean", Metric: "alarm_active", Operator: "eq", BooleanThreshold: &truth, ScopeModels: []string{"纯电客车", "纯电客车"}, ScopeCompanies: []string{"示范公交"}, NotificationTargets: []AlertNotificationTarget{{Channel: "sms", RecipientID: "night-shift", Label: "夜班负责人"}}, Enabled: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rule.Threshold != 1 {
|
|
t.Fatalf("boolean threshold should normalize to numeric evaluator value: %+v", rule)
|
|
}
|
|
if len(rule.NotificationChannels) != 2 || rule.NotificationChannels[0] != "in_app" {
|
|
t.Fatalf("channels should include unique in-app truth: %+v", rule.NotificationChannels)
|
|
}
|
|
if len(rule.ScopeModels) != 1 || rule.ScopeModels[0] != "纯电客车" || len(rule.ScopeCompanies) != 1 {
|
|
t.Fatalf("master-data scopes should normalize and round-trip: %+v", rule)
|
|
}
|
|
_, err = service.SaveAlertRule(t.Context(), AlertRuleInput{ID: rule.ID, Version: 99, Name: rule.Name, Severity: rule.Severity, ValueType: rule.ValueType, Metric: rule.Metric, Operator: rule.Operator, BooleanThreshold: &truth})
|
|
clientErr, ok := asClientError(err)
|
|
if !ok || clientErr.Code != "ALERT_RULE_VERSION_CONFLICT" {
|
|
t.Fatalf("expected optimistic conflict, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAlertRuleRollbackCreatesRevisionAndPreservesEnabledState(t *testing.T) {
|
|
store := NewMockStore()
|
|
service := NewService(store)
|
|
rules, err := service.AlertRules(t.Context())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var original AlertRule
|
|
for _, rule := range rules {
|
|
if rule.ID == "rule-speeding" {
|
|
original = rule
|
|
break
|
|
}
|
|
}
|
|
changed := alertRuleInputFromRule(original)
|
|
changed.Name = "车辆持续超速"
|
|
changed.Threshold = 96
|
|
changed.Actor = "editor-a"
|
|
updated, err := service.SaveAlertRule(t.Context(), changed)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
disabled, err := service.SetAlertRuleEnabled(t.Context(), updated.ID, AlertRuleEnabledUpdate{Version: updated.Version, Enabled: false, Actor: "operator-b"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
restored, err := service.RollbackAlertRule(t.Context(), disabled.ID, AlertRuleRollbackRequest{TargetVersion: original.Version, CurrentVersion: disabled.Version, Actor: "reviewer-c"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if restored.Name != original.Name || restored.Threshold != original.Threshold {
|
|
t.Fatalf("target configuration was not restored: %+v", restored)
|
|
}
|
|
if restored.Enabled || restored.Version != disabled.Version+1 {
|
|
t.Fatalf("rollback must preserve disabled state and create a new version: %+v", restored)
|
|
}
|
|
revisions, err := service.AlertRuleRevisions(t.Context(), restored.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(revisions) < 4 || revisions[0].Action != "rollback" || revisions[0].Version != restored.Version || revisions[0].Actor != "reviewer-c" {
|
|
t.Fatalf("rollback revision missing: %+v", revisions)
|
|
}
|
|
_, err = service.RollbackAlertRule(t.Context(), restored.ID, AlertRuleRollbackRequest{TargetVersion: original.Version, CurrentVersion: restored.Version, Actor: "reviewer-c"})
|
|
clientErr, ok := asClientError(err)
|
|
if !ok || clientErr.Code != "ALERT_RULE_ROLLBACK_NO_CHANGES" {
|
|
t.Fatalf("expected no-op rollback rejection, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAlertRuleLibraryArchivesAndRestoresDisabledRuleWithAudit(t *testing.T) {
|
|
store := NewMockStore()
|
|
service := NewService(store)
|
|
admin := WithPrincipal(t.Context(), Principal{Name: "规则管理员", Username: "rule-admin", Role: "admin", UserType: "admin"})
|
|
|
|
current, err := service.AlertRulePage(admin, AlertRuleQuery{Lifecycle: "current", Limit: 10})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if current.Total != 6 || current.Summary.Current != 6 || current.Summary.Archived != 0 {
|
|
t.Fatalf("unexpected initial library: %+v", current)
|
|
}
|
|
|
|
archived, err := service.SetAlertRuleArchived(admin, "rule-alarm", true, AlertRuleLifecycleRequest{Version: 1, Reason: "安全阈值规则已由新版替代", Actor: "rule-admin"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if archived.ArchivedAt == "" || archived.ArchivedBy != "rule-admin" || archived.ArchiveReason == "" || archived.Version != 2 {
|
|
t.Fatalf("archive receipt incomplete: %+v", archived)
|
|
}
|
|
|
|
activeRules, err := service.AlertRules(admin)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, rule := range activeRules {
|
|
if rule.ID == archived.ID {
|
|
t.Fatalf("archived rule leaked into active evaluator list: %+v", rule)
|
|
}
|
|
}
|
|
archivePage, err := service.AlertRulePage(admin, AlertRuleQuery{Lifecycle: "archived", Keyword: "新版替代", Limit: 10})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if archivePage.Total != 1 || archivePage.Summary.Current != 5 || archivePage.Summary.Archived != 1 || archivePage.Items[0].ID != archived.ID {
|
|
t.Fatalf("unexpected archive page: %+v", archivePage)
|
|
}
|
|
revisions, err := service.AlertRuleRevisions(admin, archived.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if revisions[0].Action != "archive" || revisions[0].Reason != "安全阈值规则已由新版替代" {
|
|
t.Fatalf("archive audit missing: %+v", revisions[0])
|
|
}
|
|
|
|
restored, err := service.SetAlertRuleArchived(admin, archived.ID, false, AlertRuleLifecycleRequest{Version: archived.Version, Reason: "恢复复核通知接收人配置", Actor: "rule-admin"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if restored.ArchivedAt != "" || restored.Enabled || restored.Version != 3 {
|
|
t.Fatalf("restored rule must return disabled at next version: %+v", restored)
|
|
}
|
|
revisions, err = service.AlertRuleRevisions(admin, restored.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if revisions[0].Action != "restore" || revisions[0].Reason != "恢复复核通知接收人配置" {
|
|
t.Fatalf("restore audit missing: %+v", revisions[0])
|
|
}
|
|
}
|
|
|
|
func TestAlertRuleGovernanceRequiresAdministrator(t *testing.T) {
|
|
service := NewService(NewMockStore())
|
|
operator := WithPrincipal(t.Context(), Principal{Name: "规则值班员", Role: "operator", UserType: "operator"})
|
|
if _, err := service.AlertRulePage(operator, AlertRuleQuery{Limit: 10}); err == nil {
|
|
t.Fatal("operator must not list the governance rule library")
|
|
} else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "PERMISSION_DENIED" {
|
|
t.Fatalf("unexpected library permission error: %v", err)
|
|
}
|
|
if _, err := service.SetAlertRuleArchived(operator, "rule-alarm", true, AlertRuleLifecycleRequest{Version: 1, Reason: "规则已经由新版替代"}); err == nil {
|
|
t.Fatal("operator must not archive automation rules")
|
|
} else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "PERMISSION_DENIED" {
|
|
t.Fatalf("unexpected archive permission error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAlertRuleRevisionRoutes(t *testing.T) {
|
|
handler := NewHandler(NewService(NewMockStore()))
|
|
list := httptest.NewRecorder()
|
|
handler.ServeHTTP(list, httptest.NewRequest(http.MethodGet, "/api/v2/alerts/rules/rule-speeding/revisions", nil))
|
|
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"version":2`) {
|
|
t.Fatalf("revision route status=%d body=%s", list.Code, list.Body.String())
|
|
}
|
|
rollback := httptest.NewRecorder()
|
|
handler.ServeHTTP(rollback, httptest.NewRequest(http.MethodPost, "/api/v2/alerts/rules/rule-speeding/rollback", strings.NewReader(`{"targetVersion":2,"currentVersion":2}`)))
|
|
if rollback.Code != http.StatusBadRequest || !strings.Contains(rollback.Body.String(), "ALERT_RULE_ROLLBACK_TARGET_INVALID") {
|
|
t.Fatalf("rollback validation status=%d body=%s", rollback.Code, rollback.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAlertRuleRevisionsParsesAuditSnapshots(t *testing.T) {
|
|
db, mock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
store := NewProductionStore(db, nil, "")
|
|
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM vehicle_alert_rule`)).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
|
mock.ExpectQuery("SELECT rule_id,rule_version,actor,action,COALESCE\\(reason,''\\),snapshot_json").WithArgs("rule-1").WillReturnRows(sqlmock.NewRows([]string{"rule_id", "rule_version", "actor", "action", "reason", "snapshot_json", "created_at"}).AddRow("rule-1", 3, "admin-a", "update", "", `{"id":"rule-1","name":"超速","severity":"major","valueType":"numeric","metric":"speed_kmh","operator":"gt","threshold":90,"scopeProtocols":["JT808"],"scopeVins":[],"scopeOems":[],"scopeModels":[],"scopeCompanies":[],"notificationChannels":["in_app"],"enabled":true}`, "2026-07-23T10:00:00.000000+08:00"))
|
|
revisions, err := store.AlertRuleRevisions(t.Context(), "rule-1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(revisions) != 1 || revisions[0].Snapshot.Version != 3 || revisions[0].Snapshot.Threshold != 90 || revisions[0].Snapshot.UpdatedBy != "admin-a" {
|
|
t.Fatalf("unexpected revisions: %+v", revisions)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestAlertNotificationsReadUpdatesUnreadSummary(t *testing.T) {
|
|
service := NewService(NewMockStore())
|
|
before, err := service.AlertSummary(t.Context(), AlertQuery{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
page, err := service.AlertNotifications(t.Context(), AlertNotificationQuery{UnreadOnly: true, Limit: 20})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if page.Total != before.UnreadNotifications || page.Total == 0 {
|
|
t.Fatalf("unread truth differs: summary=%+v page=%+v", before, page)
|
|
}
|
|
count, err := service.MarkAlertNotificationsRead(t.Context(), AlertNotificationReadRequest{IDs: []int64{page.Items[0].ID}, Actor: "operator-a"})
|
|
if err != nil || count != 1 {
|
|
t.Fatalf("mark read count=%d err=%v", count, err)
|
|
}
|
|
after, _ := service.AlertSummary(t.Context(), AlertQuery{})
|
|
if after.UnreadNotifications != before.UnreadNotifications-1 {
|
|
t.Fatalf("unread should recalculate: before=%d after=%d", before.UnreadNotifications, after.UnreadNotifications)
|
|
}
|
|
}
|
|
|
|
func TestAlertNotificationsFiltersTheCompleteRangeBeforePagination(t *testing.T) {
|
|
store := NewMockStore()
|
|
store.alertNotifications = append(store.alertNotifications, AlertNotification{
|
|
ID: 999, EventID: "event-delivery-failed", Title: "夜班短信失败", Content: "短信网关超时",
|
|
Severity: "major", Channel: "sms", Recipient: "夜班负责人", DeliveryStatus: "failed",
|
|
VehiclePlate: "浙A·失败", VehicleVIN: "VIN-DELIVERY-FAILED", Protocol: "JT808",
|
|
CreatedAt: time.Now().Format(time.RFC3339),
|
|
})
|
|
service := NewService(store)
|
|
|
|
page, err := service.AlertNotifications(t.Context(), AlertNotificationQuery{
|
|
Search: "VIN-DELIVERY-FAILED", DeliveryStatus: "failed", Limit: 20,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if page.Total != 1 || len(page.Items) != 1 || page.Items[0].ID != 999 {
|
|
t.Fatalf("server-wide notification filter mismatch: %+v", page)
|
|
}
|
|
delivered, err := service.AlertNotifications(t.Context(), AlertNotificationQuery{
|
|
Search: "短信网关超时", DeliveryStatus: "delivered", Limit: 20,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if delivered.Total != 0 || len(delivered.Items) != 0 {
|
|
t.Fatalf("failed delivery leaked into delivered filter: %+v", delivered)
|
|
}
|
|
if _, err := service.AlertNotifications(t.Context(), AlertNotificationQuery{DeliveryStatus: "unknown", Limit: 20}); err == nil {
|
|
t.Fatal("invalid delivery status should be rejected")
|
|
}
|
|
}
|
|
|
|
func TestAlertNotificationRetryIsPermissionedIdempotentAndAudited(t *testing.T) {
|
|
store := NewMockStore()
|
|
service := NewService(store)
|
|
operator := WithPrincipal(t.Context(), Principal{Name: "通知操作员", Username: "operator-a", Role: "operator", UserType: "operator"})
|
|
request := AlertNotificationRetryRequest{
|
|
ExpectedAttemptCount: 2,
|
|
Reason: "已确认短信网关恢复",
|
|
IdempotencyKey: "notification-retry-10-attempt-3",
|
|
}
|
|
result, err := service.RetryAlertNotification(operator, 10, request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Idempotent || result.Notification.DeliveryStatus != "queued" || result.Notification.AttemptCount != 3 || result.Notification.RetryRequestedBy != "operator-a" {
|
|
t.Fatalf("unexpected retry result: %+v", result)
|
|
}
|
|
if result.Receipt.NotificationID != 10 || result.Receipt.AttemptCount != 3 || result.Receipt.Reason != request.Reason {
|
|
t.Fatalf("unexpected retry receipt: %+v", result.Receipt)
|
|
}
|
|
repeated, err := service.RetryAlertNotification(operator, 10, request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !repeated.Idempotent || repeated.Notification.AttemptCount != 3 || repeated.Receipt.ID != result.Receipt.ID {
|
|
t.Fatalf("same key must reuse the original retry: %+v", repeated)
|
|
}
|
|
audit, err := service.AlertNotificationRetryAudits(operator, 10)
|
|
if err != nil || len(audit) != 1 || audit[0].ID != result.Receipt.ID {
|
|
t.Fatalf("retry audit mismatch: %+v err=%v", audit, err)
|
|
}
|
|
viewer := WithPrincipal(t.Context(), Principal{Name: "只读审计员", Username: "viewer-a", Role: "viewer", UserType: "operator"})
|
|
if _, err := service.RetryAlertNotification(viewer, 10, request); err == nil {
|
|
t.Fatal("read-only role must not retry notifications")
|
|
}
|
|
if _, err := service.RetryAlertNotification(operator, 10, AlertNotificationRetryRequest{ExpectedAttemptCount: 2, Reason: "短", IdempotencyKey: "notification-retry-invalid"}); err == nil {
|
|
t.Fatal("retry reason must be auditable")
|
|
}
|
|
}
|
|
|
|
func TestAlertNotificationsScopeCustomerToGrantedVehicles(t *testing.T) {
|
|
service := NewService(NewMockStore())
|
|
allowed := WithPrincipal(t.Context(), Principal{
|
|
Name: "客户账号", Role: "customer", UserType: "customer",
|
|
VehicleVINs: []string{"LFP23A98V2P012345"},
|
|
})
|
|
page, err := service.AlertNotifications(allowed, AlertNotificationQuery{DeliveryStatus: "failed", Limit: 20})
|
|
if err != nil || page.Total != 1 || len(page.Items) != 1 || page.Items[0].ID != 10 {
|
|
t.Fatalf("customer notification scope mismatch: %+v err=%v", page, err)
|
|
}
|
|
denied := WithPrincipal(t.Context(), Principal{Name: "无授权客户", Role: "customer", UserType: "customer"})
|
|
empty, err := service.AlertNotifications(denied, AlertNotificationQuery{Limit: 20})
|
|
if err != nil || empty.Total != 0 || len(empty.Items) != 0 {
|
|
t.Fatalf("customer without grants must receive an empty scope: %+v err=%v", empty, err)
|
|
}
|
|
}
|
|
|
|
func TestProductionAlertNotificationRetryQueuesOneAuditedAttempt(t *testing.T) {
|
|
db, mock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
store := NewProductionStore(db, nil, "")
|
|
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM vehicle_alert_rule`)).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
|
mock.ExpectBegin()
|
|
mock.ExpectQuery(regexp.QuoteMeta(`SELECT channel,delivery_status,GREATEST(COALESCE(attempt_count,1),1) FROM vehicle_alert_notification WHERE id=? FOR UPDATE`)).
|
|
WithArgs(int64(42)).
|
|
WillReturnRows(sqlmock.NewRows([]string{"channel", "delivery_status", "attempt_count"}).AddRow("sms", "failed", 1))
|
|
mock.ExpectQuery(regexp.QuoteMeta(alertNotificationRetryAuditSelect + `idempotency_key=?`)).
|
|
WithArgs("notification-retry-42-attempt-2").
|
|
WillReturnRows(sqlmock.NewRows([]string{"id", "notification_id", "actor", "reason", "previous_status", "next_status", "attempt_count", "requested_at"}))
|
|
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_alert_notification_retry_audit(notification_id,idempotency_key,actor,reason,previous_status,next_status,attempt_count) VALUES(?,?,?,?,?,?,?)`)).
|
|
WithArgs(int64(42), "notification-retry-42-attempt-2", "operator-a", "短信网关已经恢复", "failed", "reserved", 2).
|
|
WillReturnResult(sqlmock.NewResult(81, 1))
|
|
mock.ExpectExec(regexp.QuoteMeta(`UPDATE vehicle_alert_notification SET delivery_status='reserved',attempt_count=?,provider_message_id='',delivered_at=NULL,last_attempt_at=CURRENT_TIMESTAMP(3),retry_requested_by=?,retry_requested_at=CURRENT_TIMESTAMP(3) WHERE id=?`)).
|
|
WithArgs(2, "operator-a", int64(42)).
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectCommit()
|
|
notificationColumns := []string{"id", "event_id", "title", "content", "severity", "channel", "recipient", "recipient_ref", "delivery_status", "attempt_count", "provider_message_id", "is_read", "created_at", "delivered_at", "read_at", "last_error", "last_attempt_at", "retry_requested_by", "retry_requested_at", "plate", "vin", "protocol"}
|
|
mock.ExpectQuery(regexp.QuoteMeta(alertNotificationSelect + `n.id=?`)).
|
|
WithArgs(int64(42)).
|
|
WillReturnRows(sqlmock.NewRows(notificationColumns).AddRow(42, "event-42", "低电量短信", "SOC 低于 20%", "major", "sms", "夜班负责人", "night-shift", "reserved", 2, "", false, "2026-07-23T10:00:00+08:00", "", "", "provider_timeout", "2026-07-23T10:05:00+08:00", "operator-a", "2026-07-23T10:05:00+08:00", "粤A0042", "VIN42", "GB32960"))
|
|
|
|
result, err := store.RetryAlertNotification(t.Context(), 42, AlertNotificationRetryRequest{
|
|
ExpectedAttemptCount: 1,
|
|
Reason: "短信网关已经恢复",
|
|
IdempotencyKey: "notification-retry-42-attempt-2",
|
|
Actor: "operator-a",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Notification.DeliveryStatus != "queued" || result.Notification.AttemptCount != 2 || result.Receipt.ID != 81 || result.Receipt.NextStatus != "reserved" {
|
|
t.Fatalf("unexpected production retry result: %+v", result)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestAlertEvaluatorComparisonsAndScope(t *testing.T) {
|
|
item := alertEvaluationEvidence{VIN: "VIN1", Protocol: "JT808", OEM: "宇通", Model: "纯电客车", Company: "示范公交", SpeedKmh: 96, AlarmFlag: 1}
|
|
rule := AlertRule{Metric: "speed_kmh", Operator: "gt", Threshold: 80, ScopeProtocols: []string{"JT808"}, ScopeVINs: []string{"VIN1"}}
|
|
value, ok := alertMetricValue(rule.Metric, item)
|
|
if !ok || !compareAlertValue(value, rule.Operator, rule.Threshold) || !alertRuleInScope(rule, item) {
|
|
t.Fatalf("numeric rule should match current evidence")
|
|
}
|
|
if compareAlertValue(75, "gt", 80) || !compareAlertValue(75, "lte", 75) {
|
|
t.Fatal("comparison boundary incorrect")
|
|
}
|
|
rule.ScopeVINs = []string{"OTHER"}
|
|
if alertRuleInScope(rule, item) {
|
|
t.Fatal("VIN scope must prevent cross-vehicle evaluation")
|
|
}
|
|
rule.ScopeVINs = nil
|
|
rule.ScopeOEMs = []string{"其他厂家"}
|
|
if alertRuleInScope(rule, item) {
|
|
t.Fatal("OEM scope must prevent cross-manufacturer evaluation")
|
|
}
|
|
rule.ScopeOEMs = nil
|
|
rule.ScopeModels = []string{"氢燃料重卡"}
|
|
if alertRuleInScope(rule, item) {
|
|
t.Fatal("model scope must use authoritative vehicle profile")
|
|
}
|
|
rule.ScopeModels = []string{"纯电客车"}
|
|
rule.ScopeCompanies = []string{"其他企业"}
|
|
if alertRuleInScope(rule, item) {
|
|
t.Fatal("company scope must use authoritative vehicle profile")
|
|
}
|
|
if !compareAlertRuleValue(80, AlertRule{Operator: "between", Threshold: 70, ThresholdHigh: 90}) || compareAlertRuleValue(95, AlertRule{Operator: "between", Threshold: 70, ThresholdHigh: 90}) {
|
|
t.Fatal("between comparison boundary incorrect")
|
|
}
|
|
if !compareAlertRuleValue(95, AlertRule{Operator: "outside", Threshold: 70, ThresholdHigh: 90}) || compareAlertRuleValue(80, AlertRule{Operator: "outside", Threshold: 70, ThresholdHigh: 90}) {
|
|
t.Fatal("outside comparison boundary incorrect")
|
|
}
|
|
}
|
|
|
|
func TestAlertRuleSQLMatchesMasterDataScopeArguments(t *testing.T) {
|
|
if placeholders := strings.Count(alertRuleInsertSQL, "?"); placeholders != 29 {
|
|
t.Fatalf("alert insert placeholders=%d query=%s", placeholders, alertRuleInsertSQL)
|
|
}
|
|
if placeholders := strings.Count(alertRuleUpdateSQL, "?"); placeholders != 29 {
|
|
t.Fatalf("alert update placeholders=%d query=%s", placeholders, alertRuleUpdateSQL)
|
|
}
|
|
}
|
|
|
|
func TestAlertRuleScopeBoundsProtectEvaluator(t *testing.T) {
|
|
values := make([]string, 501)
|
|
for index := range values {
|
|
values[index] = "company-" + strconv.Itoa(index)
|
|
}
|
|
err := validateAlertRuleScopes(AlertRuleInput{ScopeCompanies: values})
|
|
clientErr, ok := asClientError(err)
|
|
if !ok || clientErr.Code != "ALERT_RULE_SCOPE_TOO_LARGE" {
|
|
t.Fatalf("large scope should be rejected, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAlertCandidateUpsertBatchesRows(t *testing.T) {
|
|
now := time.Now()
|
|
query, args := buildAlertCandidateUpsert([]alertCandidateUpsert{
|
|
{RuleID: "r1", VIN: "v1", Protocol: "JT808", FirstMatchedAt: now, LastMatchedAt: now, LatestValue: 91, SourceEventID: "e1"},
|
|
{RuleID: "r1", VIN: "v2", Protocol: "GB32960", FirstMatchedAt: now, LastMatchedAt: now, LatestValue: 92, SourceEventID: "e2"},
|
|
})
|
|
if strings.Count(query, "(?,?,?,?,?,?,?)") != 2 || len(args) != 14 || !strings.Contains(query, "ON DUPLICATE KEY UPDATE") {
|
|
t.Fatalf("unexpected batch query=%s args=%#v", query, args)
|
|
}
|
|
}
|
|
|
|
func TestAlertStateLoadersPreserveMySQLDatetimeMilliseconds(t *testing.T) {
|
|
db, mock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
mock.ExpectBegin()
|
|
tx, err := db.BeginTx(t.Context(), nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
first := time.Date(2026, 7, 14, 10, 0, 0, 123000000, time.Local)
|
|
last := first.Add(20*time.Second + 333*time.Millisecond)
|
|
candidateSQL := `SELECT c.rule_id,c.vin,c.protocol,c.first_matched_at,c.last_matched_at,c.source_event_id FROM vehicle_alert_candidate c JOIN vehicle_alert_rule r ON r.id=c.rule_id AND r.enabled=1`
|
|
mock.ExpectQuery(regexp.QuoteMeta(candidateSQL)).WillReturnRows(sqlmock.NewRows([]string{"rule_id", "vin", "protocol", "first_matched_at", "last_matched_at", "source_event_id"}).AddRow("r1", "v1", "JT808", first, last, "event-2"))
|
|
candidates, err := loadAlertCandidates(t.Context(), tx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
candidate := candidates["r1|v1|JT808"]
|
|
if !candidate.FirstMatchedAt.Equal(first) || !candidate.LastMatchedAt.Equal(last) || candidate.SourceEventID != "event-2" {
|
|
t.Fatalf("candidate datetime precision lost: %+v", candidate)
|
|
}
|
|
|
|
stateSQL := `SELECT s.rule_id,s.vin,s.protocol,s.observed_value,s.last_observed_at FROM vehicle_alert_rule_state s JOIN vehicle_alert_rule r ON r.id=s.rule_id AND r.enabled=1`
|
|
mock.ExpectQuery(regexp.QuoteMeta(stateSQL)).WillReturnRows(sqlmock.NewRows([]string{"rule_id", "vin", "protocol", "observed_value", "last_observed_at"}).AddRow("r2", "v2", "GB32960", 1.0, last))
|
|
states, err := loadAlertRuleStates(t.Context(), tx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
state := states["r2|v2|GB32960"]
|
|
if state.LastValue != 1 || !state.LastObservedAt.Equal(last) {
|
|
t.Fatalf("rule state datetime precision lost: %+v", state)
|
|
}
|
|
|
|
lastTriggeredSQL := `SELECT e.fingerprint,MAX(e.triggered_at) FROM vehicle_alert_event e JOIN vehicle_alert_rule r ON r.id=e.rule_id AND r.enabled=1 GROUP BY e.fingerprint`
|
|
mock.ExpectQuery(regexp.QuoteMeta(lastTriggeredSQL)).WillReturnRows(sqlmock.NewRows([]string{"fingerprint", "triggered_at"}).AddRow("r1|v1|JT808", last))
|
|
lastTriggered, err := loadAlertLastTriggered(t.Context(), tx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !lastTriggered["r1|v1|JT808"].Equal(last) {
|
|
t.Fatalf("repeat timestamp precision lost: %s", lastTriggered["r1|v1|JT808"])
|
|
}
|
|
mock.ExpectRollback()
|
|
if err := tx.Rollback(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestAlertRuleDisableIsAtomicAndClearsEvaluatorState(t *testing.T) {
|
|
db, mock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
store := NewProductionStore(db, nil, "")
|
|
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM vehicle_alert_rule`)).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
|
mock.ExpectBegin()
|
|
mock.ExpectExec(regexp.QuoteMeta(`UPDATE vehicle_alert_rule SET enabled=?,version=version+1,updated_by=? WHERE id=? AND version=? AND archived_at IS NULL`)).WithArgs(false, "admin-a", "rule-1", 1).WillReturnResult(sqlmock.NewResult(0, 1))
|
|
columns := []string{"id", "name", "description", "trigger_type", "fence_name", "fence_longitude", "fence_latitude", "fence_radius_m", "severity", "value_type", "metric", "operator", "threshold_value", "threshold_high", "boolean_threshold", "duration_sec", "recovery_operator", "recovery_threshold", "repeat_interval_sec", "scope_protocols_json", "scope_vins_json", "scope_oems_json", "scope_models_json", "scope_companies_json", "notification_channels_json", "notification_targets_json", "enabled", "version", "created_by", "updated_by", "created_at", "updated_at"}
|
|
mock.ExpectQuery(regexp.QuoteMeta(alertRuleSelect + `WHERE id=?`)).WithArgs("rule-1").WillReturnRows(sqlmock.NewRows(columns).AddRow("rule-1", "超速", "", "metric", "", 0.0, 0.0, 0.0, "minor", "numeric", "speed_kmh", "gte", 0.0, 0.0, nil, 20, "", 0.0, 3600, `["JT808"]`, `["VIN1"]`, `[]`, `[]`, `[]`, `["in_app"]`, `[{"channel":"in_app","recipientId":"platform-operators","label":"平台值班组"}]`, false, 2, "admin-a", "admin-a", "2026-07-14T07:00:00.000000Z", "2026-07-14T07:01:00.000000Z"))
|
|
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM vehicle_alert_candidate WHERE rule_id=?`)).WithArgs("rule-1").WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM vehicle_alert_rule_state WHERE rule_id=?`)).WithArgs("rule-1").WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_alert_rule_audit(rule_id,rule_version,actor,action,reason,snapshot_json) VALUES(?,?,?,?,?,?)`)).WithArgs("rule-1", 2, "admin-a", "disable", "", sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1))
|
|
mock.ExpectCommit()
|
|
rule, err := store.SetAlertRuleEnabled(t.Context(), "rule-1", AlertRuleEnabledUpdate{Version: 1, Enabled: false, Actor: "admin-a"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rule.Enabled || rule.Version != 2 {
|
|
t.Fatalf("unexpected disabled rule: %+v", rule)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestAlertEvaluationLocksRuleAgainstConcurrentDisable(t *testing.T) {
|
|
db, mock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
mock.ExpectBegin()
|
|
tx, err := db.BeginTx(t.Context(), nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mock.ExpectQuery(regexp.QuoteMeta(`SELECT enabled FROM vehicle_alert_rule WHERE id=? FOR UPDATE`)).WithArgs("rule-1").WillReturnRows(sqlmock.NewRows([]string{"enabled"}).AddRow(true))
|
|
enabled, err := lockAlertRuleForEvaluation(t.Context(), tx, "rule-1")
|
|
if err != nil || !enabled {
|
|
t.Fatalf("rule lock enabled=%t err=%v", enabled, err)
|
|
}
|
|
mock.ExpectRollback()
|
|
if err := tx.Rollback(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestAlertRuleStateUpsertBatchesChangedValues(t *testing.T) {
|
|
now := time.Now()
|
|
query, args := buildAlertRuleStateUpsert([]alertRuleStateUpsert{{RuleID: "r1", VIN: "v1", Protocol: "JT808", LastValue: 1, ObservedAt: now}, {RuleID: "r1", VIN: "v2", Protocol: "GB32960", LastValue: 0, ObservedAt: now}})
|
|
if strings.Count(query, "(?,?,?,?,?)") != 2 || len(args) != 10 || !strings.Contains(query, "ON DUPLICATE KEY UPDATE") {
|
|
t.Fatalf("unexpected state batch query=%s args=%#v", query, args)
|
|
}
|
|
}
|
|
|
|
func TestAlertEventInsertContractMatchesArguments(t *testing.T) {
|
|
rule := AlertRule{ID: "rule-1", Name: "超速", Version: 3, Severity: "major", Metric: "speed_kmh", Operator: "between", Threshold: 60, ThresholdHigh: 90, DurationSec: 30}
|
|
item := alertEvaluationEvidence{VIN: "VIN1", Plate: "川A10001", Protocol: "JT808", SourceEventID: "event-1", EventAt: "2026-07-14 10:00:00.000", ReceivedAt: "2026-07-14 10:00:01.000", Location: "104.1,30.6", Longitude: 104.1, Latitude: 30.6}
|
|
query, args := buildAlertEventInsert("alert-1", "rule-1|VIN1|JT808", rule, item, 75)
|
|
if placeholders := strings.Count(query, "?"); placeholders != len(args) {
|
|
t.Fatalf("event insert placeholders=%d args=%d query=%s", placeholders, len(args), query)
|
|
}
|
|
if len(args) != 23 || !strings.Contains(query, "'unprocessed'") || !strings.Contains(query, "CURRENT_TIMESTAMP(3)") {
|
|
t.Fatalf("unexpected event insert contract args=%#v query=%s", args, query)
|
|
}
|
|
}
|
|
|
|
func TestAlertRuleValidationCoversRangesAndStateChange(t *testing.T) {
|
|
base := AlertRuleInput{Name: "区间", Severity: "major", ValueType: "numeric", Metric: "soc_percent", Operator: "between", Threshold: 20, ThresholdHigh: 80}
|
|
if err := validateAlertRule(base); err != nil {
|
|
t.Fatalf("valid range rejected: %v", err)
|
|
}
|
|
base.ThresholdHigh = 10
|
|
if err := validateAlertRule(base); err == nil {
|
|
t.Fatal("inverted range should be rejected")
|
|
}
|
|
changed := AlertRuleInput{Name: "状态变化", Severity: "major", ValueType: "boolean", Metric: "alarm_active", Operator: "changed"}
|
|
if err := validateAlertRule(changed); err != nil {
|
|
t.Fatalf("boolean state change rejected: %v", err)
|
|
}
|
|
unknown := AlertRuleInput{Name: "未知指标", Severity: "major", ValueType: "numeric", Metric: "client_supplied_sql", Operator: "gt"}
|
|
if err := validateAlertRule(unknown); err == nil {
|
|
t.Fatal("metric outside the server catalog should be rejected")
|
|
}
|
|
mismatched := AlertRuleInput{Name: "类型错误", Severity: "major", ValueType: "boolean", Metric: "speed_kmh", Operator: "eq", BooleanThreshold: new(bool)}
|
|
if err := validateAlertRule(mismatched); err == nil {
|
|
t.Fatal("metric catalog type mismatch should be rejected")
|
|
}
|
|
}
|
|
|
|
func TestAlertRuleValidationAcceptsStreamMappedHydrogenMetric(t *testing.T) {
|
|
var hydrogen MetricDefinition
|
|
for _, definition := range metricDefinitions() {
|
|
if definition.Key == "hydrogen_concentration_percent" {
|
|
hydrogen = definition
|
|
break
|
|
}
|
|
}
|
|
if hydrogen.Key == "" {
|
|
t.Fatal("hydrogen concentration metric missing from catalog")
|
|
}
|
|
input := AlertRuleInput{
|
|
Name: "氢气浓度报警提示", Severity: "critical", ValueType: "numeric",
|
|
Metric: hydrogen.Key, Operator: "gt", Threshold: 0.05,
|
|
}
|
|
if err := validateAlertRule(input, hydrogen); err != nil {
|
|
t.Fatalf("stream-mapped hydrogen rule rejected: %v", err)
|
|
}
|
|
if unit := alertMetricUnit(hydrogen.Key); unit != "%" {
|
|
t.Fatalf("hydrogen alert unit=%q want %%", unit)
|
|
}
|
|
|
|
unmapped := hydrogen
|
|
unmapped.Key = "unmapped_dynamic_metric"
|
|
unmapped.SourceFields = nil
|
|
input.Metric = unmapped.Key
|
|
err := validateAlertRule(input, unmapped)
|
|
clientErr, ok := asClientError(err)
|
|
if !ok || clientErr.Code != "ALERT_RULE_METRIC_UNSUPPORTED" {
|
|
t.Fatalf("unmapped dynamic metric should remain rejected, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAlertAutomationTriggerValidationAndNormalization(t *testing.T) {
|
|
geofence := AlertRuleInput{
|
|
Name: "离开临港停车场", TriggerType: "geofence", FenceName: "临港停车场",
|
|
FenceLongitude: 121.9, FenceLatitude: 30.9, FenceRadiusM: 500,
|
|
Severity: "critical", Operator: "exit", RepeatIntervalSec: 600, ScopeProtocols: []string{"JT808"},
|
|
}
|
|
normalizeAlertRuleTrigger(&geofence)
|
|
if geofence.Metric != "geofence_distance_m" || geofence.ValueType != "numeric" || geofence.Threshold != 500 {
|
|
t.Fatalf("geofence normalization incomplete: %+v", geofence)
|
|
}
|
|
if err := validateAlertRule(geofence); err != nil {
|
|
t.Fatalf("valid geofence rejected: %v", err)
|
|
}
|
|
geofence.ScopeProtocols = []string{"JT808", "GB32960"}
|
|
if err := validateAlertRule(geofence); err == nil {
|
|
t.Fatal("multi-source geofence should be rejected to prevent coordinate drift")
|
|
}
|
|
geofence.ScopeProtocols = []string{"JT808"}
|
|
geofence.FenceRadiusM = 20
|
|
if err := validateAlertRule(geofence); err == nil {
|
|
t.Fatal("unsafe geofence radius should be rejected")
|
|
}
|
|
|
|
stationary := AlertRuleInput{Name: "长时间不动", TriggerType: "stationary", Severity: "major", Operator: "lte", Threshold: 1, DurationSec: 1800}
|
|
normalizeAlertRuleTrigger(&stationary)
|
|
if err := validateAlertRule(stationary); err != nil {
|
|
t.Fatalf("valid stationary rule rejected: %v", err)
|
|
}
|
|
offline := AlertRuleInput{Name: "长时间离线", TriggerType: "offline", Severity: "major", Operator: "gt", Threshold: 36000}
|
|
normalizeAlertRuleTrigger(&offline)
|
|
if err := validateAlertRule(offline); err != nil || offline.RecoveryThreshold != 36000 {
|
|
t.Fatalf("offline rule did not normalize: rule=%+v err=%v", offline, err)
|
|
}
|
|
}
|
|
|
|
func TestGeofenceDistanceAndDirectionalTransitions(t *testing.T) {
|
|
rule := AlertRule{TriggerType: "geofence", Metric: "geofence_distance_m", Operator: "enter", FenceLongitude: 121.4737, FenceLatitude: 31.2304, FenceRadiusM: 500}
|
|
outside := alertEvaluationEvidence{Longitude: 121.49, Latitude: 31.2304, HasLocation: true}
|
|
inside := alertEvaluationEvidence{Longitude: 121.4738, Latitude: 31.2304, HasLocation: true}
|
|
outsideDistance, ok := alertRuleMetricValue(rule, outside)
|
|
if !ok || outsideDistance <= 500 {
|
|
t.Fatalf("outside distance=%f ok=%t", outsideDistance, ok)
|
|
}
|
|
insideDistance, ok := alertRuleMetricValue(rule, inside)
|
|
if !ok || insideDistance >= 500 {
|
|
t.Fatalf("inside distance=%f ok=%t", insideDistance, ok)
|
|
}
|
|
matched, state, stateful := alertRuleMatches(rule, insideDistance, alertRuleState{LastValue: 0}, true)
|
|
if !matched || !stateful || state != 1 {
|
|
t.Fatalf("enter transition not detected: matched=%t state=%f stateful=%t", matched, state, stateful)
|
|
}
|
|
rule.Operator = "exit"
|
|
matched, state, stateful = alertRuleMatches(rule, outsideDistance, alertRuleState{LastValue: 1}, true)
|
|
if !matched || !stateful || state != 0 {
|
|
t.Fatalf("exit transition not detected: matched=%t state=%f stateful=%t", matched, state, stateful)
|
|
}
|
|
}
|
|
|
|
func TestAlertNotificationPolicySupportsRecordOnlyAndHighPriority(t *testing.T) {
|
|
if channels := normalizeAlertChannels(nil); len(channels) != 0 {
|
|
t.Fatalf("record-only automation unexpectedly created channels: %+v", channels)
|
|
}
|
|
if channels := normalizeAlertChannels([]string{"sms"}); len(channels) != 2 || channels[0] != "in_app" {
|
|
t.Fatalf("reserved external channel should retain in-app evidence: %+v", channels)
|
|
}
|
|
rule := AlertRule{Name: "车辆离开围栏", TriggerType: "geofence", FenceName: "临港停车场", Operator: "exit", Severity: "critical"}
|
|
item := alertEvaluationEvidence{VIN: "VIN1", Plate: "沪A00001F"}
|
|
if title := alertNotificationTitle(rule); !strings.HasPrefix(title, "【高优先级】") {
|
|
t.Fatalf("critical notification title=%q", title)
|
|
}
|
|
if content := alertNotificationContent(rule, item, 800, "m"); !strings.Contains(content, "离开电子围栏") || !strings.Contains(content, "临港停车场") {
|
|
t.Fatalf("geofence notification content=%q", content)
|
|
}
|
|
}
|
|
|
|
func TestMetricCatalogAndAlertValidationShareStoreConfiguration(t *testing.T) {
|
|
definitions := metricDefinitions()
|
|
for index := range definitions {
|
|
if definitions[index].Key == "speed_kmh" {
|
|
definitions[index].Label = "数据库配置速度"
|
|
definitions[index].Alertable = false
|
|
}
|
|
}
|
|
store := &configuredMetricStore{MockStore: NewMockStore(), definitions: definitions}
|
|
service := NewService(store)
|
|
catalog, err := service.MetricCatalog(t.Context())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if catalog.Metrics[0].Label != "数据库配置速度" {
|
|
t.Fatalf("catalog did not use store definitions: %+v", catalog.Metrics[0])
|
|
}
|
|
_, err = service.SaveAlertRule(t.Context(), AlertRuleInput{Name: "应被禁用", Severity: "major", ValueType: "numeric", Metric: "speed_kmh", Operator: "gt"})
|
|
clientErr, ok := asClientError(err)
|
|
if !ok || clientErr.Code != "ALERT_RULE_METRIC_INVALID" {
|
|
t.Fatalf("rule validation must share configured catalog, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAlertRepeatIntervalBoundary(t *testing.T) {
|
|
now := time.Now()
|
|
if alertRepeatAllowed(now, now.Add(-59*time.Second), 60) {
|
|
t.Fatal("repeat should remain suppressed inside interval")
|
|
}
|
|
if !alertRepeatAllowed(now, now.Add(-60*time.Second), 60) || !alertRepeatAllowed(now, time.Time{}, 60) {
|
|
t.Fatal("repeat should open at boundary or without history")
|
|
}
|
|
}
|
|
|
|
func TestAlertCandidateDurationOnlyAdvancesWithNewEventTime(t *testing.T) {
|
|
first := time.Date(2026, 7, 14, 10, 0, 0, 0, time.Local)
|
|
candidate, decision := advanceAlertCandidate(alertCandidateState{}, false, first, "event-1", false)
|
|
if decision != alertObservationAdvanced || !candidate.FirstMatchedAt.Equal(first) || !candidate.LastMatchedAt.Equal(first) {
|
|
t.Fatalf("first observation did not create candidate: decision=%v candidate=%+v", decision, candidate)
|
|
}
|
|
|
|
unchanged, decision := advanceAlertCandidate(candidate, true, first.Add(30*time.Second), "event-1", false)
|
|
if decision != alertObservationDuplicate || !unchanged.LastMatchedAt.Equal(first) {
|
|
t.Fatalf("same source event must not invent duration: decision=%v candidate=%+v", decision, unchanged)
|
|
}
|
|
|
|
advanced, decision := advanceAlertCandidate(candidate, true, first.Add(30*time.Second), "event-2", false)
|
|
if decision != alertObservationAdvanced || advanced.LastMatchedAt.Sub(advanced.FirstMatchedAt) != 30*time.Second {
|
|
t.Fatalf("newer event should advance event-time duration: decision=%v candidate=%+v", decision, advanced)
|
|
}
|
|
|
|
late, decision := advanceAlertCandidate(advanced, true, first.Add(20*time.Second), "event-late", false)
|
|
if decision != alertObservationLate || late.SourceEventID != "event-2" {
|
|
t.Fatalf("late event should not regress candidate: decision=%v candidate=%+v", decision, late)
|
|
}
|
|
}
|
|
|
|
func TestAlertCandidateContinuityGapResetsEventTimeWindow(t *testing.T) {
|
|
first := time.Date(2026, 7, 14, 10, 0, 0, 0, time.Local)
|
|
current := alertCandidateState{FirstMatchedAt: first, LastMatchedAt: first.Add(30 * time.Second), SourceEventID: "event-2"}
|
|
nextAt := current.LastMatchedAt.Add(alertCandidateContinuityWindow + time.Second)
|
|
next, decision := advanceAlertCandidate(current, true, nextAt, "event-3", false)
|
|
if decision != alertObservationAdvanced || !next.FirstMatchedAt.Equal(nextAt) || !next.LastMatchedAt.Equal(nextAt) {
|
|
t.Fatalf("continuity gap should start a new window: decision=%v candidate=%+v", decision, next)
|
|
}
|
|
}
|
|
|
|
func TestFreshnessCandidateIsExplicitlyClockDriven(t *testing.T) {
|
|
first := time.Date(2026, 7, 14, 10, 0, 0, 0, time.Local)
|
|
item := alertEvaluationEvidence{SourceEventID: "same-event", EventAt: "2026-07-14 09:55:00.000000"}
|
|
observedAt, clockDriven, ok := alertObservationTime("freshness_sec", item, first)
|
|
if !ok || !clockDriven || !observedAt.Equal(first) {
|
|
t.Fatalf("freshness observation must use evaluator clock: at=%s clock=%t ok=%t", observedAt, clockDriven, ok)
|
|
}
|
|
candidate := alertCandidateState{FirstMatchedAt: first, LastMatchedAt: first, SourceEventID: item.SourceEventID}
|
|
next, decision := advanceAlertCandidate(candidate, true, first.Add(10*time.Second), item.SourceEventID, true)
|
|
if decision != alertObservationAdvanced || next.LastMatchedAt.Sub(next.FirstMatchedAt) != 10*time.Second {
|
|
t.Fatalf("freshness should advance while source event is unchanged: decision=%v candidate=%+v", decision, next)
|
|
}
|
|
}
|
|
|
|
func TestAlertObservationTimePrefersEventTimeAndFallsBackToReceipt(t *testing.T) {
|
|
now := time.Date(2026, 7, 14, 12, 0, 0, 0, time.Local)
|
|
eventItem := alertEvaluationEvidence{EventAt: "2026-07-14 10:00:30.123000", ReceivedAt: "2026-07-14 10:00:31.000000"}
|
|
observedAt, clockDriven, ok := alertObservationTime("speed_kmh", eventItem, now)
|
|
if !ok || clockDriven || observedAt.Second() != 30 || observedAt.Nanosecond() != 123000000 {
|
|
t.Fatalf("event time not selected: at=%s clock=%t ok=%t", observedAt, clockDriven, ok)
|
|
}
|
|
receiptItem := alertEvaluationEvidence{ReceivedAt: "2026-07-14 10:00:31.000000"}
|
|
observedAt, _, ok = alertObservationTime("speed_kmh", receiptItem, now)
|
|
if !ok || observedAt.Second() != 31 {
|
|
t.Fatalf("receipt fallback not selected: at=%s ok=%t", observedAt, ok)
|
|
}
|
|
}
|
|
|
|
func TestSnapshotEvaluatorKeepsOnlyFreshnessRulesWhenStreamIsActive(t *testing.T) {
|
|
rules := []AlertRule{{ID: "speed", Metric: "speed_kmh"}, {ID: "fresh", Metric: "freshness_sec"}, {ID: "delay", Metric: "data_delay_sec"}}
|
|
if got := snapshotAlertRules(rules, "shadow"); len(got) != 3 {
|
|
t.Fatalf("shadow mode filtered rules: %+v", got)
|
|
}
|
|
got := snapshotAlertRules(rules, "active")
|
|
if len(got) != 1 || got[0].ID != "fresh" {
|
|
t.Fatalf("active snapshot ownership is not freshness-only: %+v", got)
|
|
}
|
|
}
|
|
|
|
func itoa(value int) string {
|
|
if value == 0 {
|
|
return "0"
|
|
}
|
|
digits := []byte{}
|
|
for value > 0 {
|
|
digits = append([]byte{byte('0' + value%10)}, digits...)
|
|
value /= 10
|
|
}
|
|
return string(digits)
|
|
}
|