Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/api/internal/platform/alert_test.go

500 lines
23 KiB
Go

package platform
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"regexp"
"strconv"
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
)
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)
}