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) }