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) } } 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 := NewService(NewMockStore()) 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}) 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 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 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 != 23 { t.Fatalf("alert insert placeholders=%d query=%s", placeholders, alertRuleInsertSQL) } if placeholders := strings.Count(alertRuleUpdateSQL, "?"); placeholders != 23 { 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=?`)).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(`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.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) != 22 || !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 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) }