feat: expand vehicle data platform capabilities
This commit is contained in:
@@ -43,6 +43,36 @@ func TestAlertSchemaProbeRetriesAfterCanceledRequestAndCachesSuccess(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -145,9 +175,12 @@ func TestAlertEventsActiveStatusIncludesOnlyOpenWorkflowStates(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlertRuleCreationNormalizesBooleanAndChannels(t *testing.T) {
|
||||
service := NewService(NewMockStore())
|
||||
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{"示范公交"}, NotificationChannels: []string{"sms", "sms"}, Enabled: 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)
|
||||
}
|
||||
@@ -167,6 +200,167 @@ func TestAlertRuleCreationNormalizesBooleanAndChannels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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{})
|
||||
@@ -190,6 +384,139 @@ func TestAlertNotificationsReadUpdatesUnreadSummary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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"}}
|
||||
@@ -228,10 +555,10 @@ func TestAlertEvaluatorComparisonsAndScope(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlertRuleSQLMatchesMasterDataScopeArguments(t *testing.T) {
|
||||
if placeholders := strings.Count(alertRuleInsertSQL, "?"); placeholders != 23 {
|
||||
if placeholders := strings.Count(alertRuleInsertSQL, "?"); placeholders != 29 {
|
||||
t.Fatalf("alert insert placeholders=%d query=%s", placeholders, alertRuleInsertSQL)
|
||||
}
|
||||
if placeholders := strings.Count(alertRuleUpdateSQL, "?"); placeholders != 23 {
|
||||
if placeholders := strings.Count(alertRuleUpdateSQL, "?"); placeholders != 29 {
|
||||
t.Fatalf("alert update placeholders=%d query=%s", placeholders, alertRuleUpdateSQL)
|
||||
}
|
||||
}
|
||||
@@ -321,12 +648,12 @@ func TestAlertRuleDisableIsAtomicAndClearsEvaluatorState(t *testing.T) {
|
||||
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=?`)).WithArgs(false, "admin-a", "rule-1", 1).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
columns := []string{"id", "name", "description", "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", "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", "超速", "", "minor", "numeric", "speed_kmh", "gte", 0.0, 0.0, nil, 20, "", 0.0, 3600, `["JT808"]`, `["VIN1"]`, `[]`, `[]`, `[]`, `["in_app"]`, false, 2, "admin-a", "admin-a", "2026-07-14T07:00:00.000000Z", "2026-07-14T07:01:00.000000Z"))
|
||||
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,snapshot_json) VALUES(?,?,?,?,?)`)).WithArgs("rule-1", 2, "admin-a", "disable", sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 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 {
|
||||
@@ -380,7 +707,7 @@ func TestAlertEventInsertContractMatchesArguments(t *testing.T) {
|
||||
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) != 22 || !strings.Contains(query, "'unprocessed'") || !strings.Contains(query, "CURRENT_TIMESTAMP(3)") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -408,6 +735,114 @@ func TestAlertRuleValidationCoversRangesAndStateChange(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user