Files
lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats/query_test.go

1447 lines
55 KiB
Go

package stats
import (
"context"
"database/sql"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
)
const dailyMetricSelectPattern = "SELECT m.vin, m.stat_date, m.protocol, m.source_id"
const dailyMetricSourceSelectPattern = "SELECT s.vin, s.stat_date, s.protocol, s.source_key, s.source_ip"
const dailyMetricSourceQualitySummarySelectPattern = "SELECT s.stat_date, s.protocol, s.quality_status, s.quality_reason"
const dailyMetricSourceSelectionSummarySelectPattern = "SELECT stat_date, protocol, selection_status, selection_reason"
const dailyMetricDiagnosisSelectPattern = "SELECT rt.vin, \\? AS stat_date, rt.protocol"
const dailyMetricDiagnosisSummarySelectPattern = "SELECT \\? AS stat_date,\\s+rt.protocol"
const dailyMetricDiagnosisReasonSummarySelectPattern = "SELECT stat_date, protocol, diagnosis, reason, COUNT\\(\\*\\) AS count"
const dailyMetricDiagnosisFieldStatusSummarySelectPattern = "SELECT stat_date, protocol, diagnosis, reason, realtime_mileage_field_status, COUNT\\(\\*\\) AS count"
func TestMetricRepositoryQueriesDailyMetricsWithFilters(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery(dailyMetricSelectPattern).
WithArgs("LKLG7C4E3NA774736", "JT808", "2026-07-01", "2026-07-01", 20, 0).
WillReturnRows(sqlmock.NewRows([]string{
"vin", "stat_date", "protocol", "source_id",
"source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind", "daily_mileage_km",
"latest_total_mileage_km", "updated_at",
}).AddRow(
"LKLG7C4E3NA774736", time.Date(2026, 7, 1, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)), "JT808", 3,
"115.231.168.135", "115.231.168.135:41561", "G7 平台", "G7S", "PLATFORM", 12.3,
12357.9, time.Date(2026, 7, 1, 23, 9, 36, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
))
repository := NewMetricRepository(db)
rows, err := repository.Query(context.Background(), MetricQuery{
VIN: "LKLG7C4E3NA774736",
Protocol: "JT808",
DateFrom: "2026-07-01",
DateTo: "2026-07-01",
Limit: 20,
})
if err != nil {
t.Fatalf("Query() error = %v", err)
}
if len(rows) != 1 {
t.Fatalf("row count = %d", len(rows))
}
if rows[0].DailyMileageKM != 12.3 {
t.Fatalf("unexpected row: %#v", rows[0])
}
if rows[0].SourceID == nil || *rows[0].SourceID != 3 {
t.Fatalf("source id = %#v", rows[0].SourceID)
}
if rows[0].SourceIP != "115.231.168.135" || rows[0].PlatformName != "G7 平台" || rows[0].SourceCode != "G7S" || rows[0].SourceKind != "PLATFORM" {
t.Fatalf("unexpected source evidence: %#v", rows[0])
}
if rows[0].StatDate != "2026-07-01" || rows[0].UpdatedAt != "2026-07-01 23:09:36" {
t.Fatalf("unexpected time formatting: %#v", rows[0])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMetricQueryReturnsSelectedSourceID(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery(dailyMetricSelectPattern).
WillReturnRows(sqlmock.NewRows([]string{
"vin", "stat_date", "protocol", "source_id",
"source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind", "daily_mileage_km",
"latest_total_mileage_km", "updated_at",
}).AddRow(
"LA9GG64L7PBAF4001", "2026-07-08", "JT808", 2,
"117.132.194.31", "117.132.194.31:20471", "广安北斗", "guangan_beidou", "PLATFORM", 23.1,
4123.9, "2026-07-08 13:30:57",
))
repo := NewMetricRepository(db)
got, err := repo.Query(context.Background(), MetricQuery{
VIN: "LA9GG64L7PBAF4001",
Protocol: "JT808",
Limit: 1,
})
if err != nil {
t.Fatalf("Query() error = %v", err)
}
if got[0].SourceID == nil || *got[0].SourceID != 2 {
t.Fatalf("source id = %#v", got[0].SourceID)
}
if got[0].SourceKind != "PLATFORM" || got[0].PlatformName != "广安北斗" {
t.Fatalf("source evidence = %#v", got[0])
}
}
func TestMetricRepositoryQueriesDailyMetricSourcesWithEvidence(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery(dailyMetricSourceSelectPattern).
WithArgs("LA9GG64L7PBAF4001", "JT808", "2026-07-08", "2026-07-08", "115.231.168.135", "OK", "historical_source_baseline", 1, 20, 0).
WillReturnRows(sqlmock.NewRows([]string{
"vin", "stat_date", "protocol", "source_key", "source_ip", "source_endpoint", "phone", "device_id",
"platform_name", "source_id", "source_code", "source_kind", "enabled", "trust_priority",
"first_total_mileage_km", "latest_total_mileage_km", "daily_mileage_km", "sample_count",
"first_event_time", "latest_event_time", "quality_status", "quality_reason", "is_selected", "updated_at",
}).AddRow(
"LA9GG64L7PBAF4001", "2026-07-08", "JT808", "JT808:13307795425@115.231.168.135", "115.231.168.135",
"115.231.168.135:41561", "13307795425", nil,
"信达", 5, "xinda", "PLATFORM", 1, 10,
4100.8, 4123.9, 23.1, 128,
"2026-07-08 00:01:00", "2026-07-08 23:59:00", "OK", "historical_source_baseline", 1, "2026-07-08 23:59:10",
))
selected := true
repository := NewMetricRepository(db)
rows, err := repository.QuerySources(context.Background(), MetricSourceQuery{
VIN: "LA9GG64L7PBAF4001",
Protocol: "jt808",
DateFrom: "2026-07-08",
DateTo: "2026-07-08",
SourceIP: "115.231.168.135",
QualityStatus: "ok",
QualityReason: "Historical_Source_Baseline",
Selected: &selected,
Limit: 20,
})
if err != nil {
t.Fatalf("QuerySources() error = %v", err)
}
if len(rows) != 1 {
t.Fatalf("row count = %d", len(rows))
}
row := rows[0]
if row.PlatformName != "信达" || row.SourceCode != "xinda" || row.SourceKind != "PLATFORM" || !row.IsSelected {
t.Fatalf("unexpected source row: %#v", row)
}
if row.SourceEnabled == nil || !*row.SourceEnabled {
t.Fatalf("source enabled = %#v", row.SourceEnabled)
}
if row.TrustPriority == nil || *row.TrustPriority != 10 {
t.Fatalf("trust priority = %#v", row.TrustPriority)
}
if row.FirstTotalMileageKM == nil || *row.FirstTotalMileageKM != 4100.8 {
t.Fatalf("first total mileage = %#v", row.FirstTotalMileageKM)
}
if row.LatestEventTime != "2026-07-08 23:59:00" || row.QualityReason != "historical_source_baseline" {
t.Fatalf("unexpected event/quality fields: %#v", row)
}
if row.SelectionStatus != "selected" || row.SelectionReason != "selected_current_projection" {
t.Fatalf("selection evidence = %#v", row)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMetricSourceSelectionEvidence(t *testing.T) {
disabled := false
sourceID := int64(3)
tests := []struct {
name string
row MetricSourceRow
wantStatus string
wantReason string
}{
{
name: "selected",
row: MetricSourceRow{
IsSelected: true,
QualityStatus: QualityOK,
},
wantStatus: "selected",
wantReason: "selected_current_projection",
},
{
name: "invalid quality",
row: MetricSourceRow{
QualityStatus: QualityInvalidDelta,
QualityReason: "outside_daily_range",
},
wantStatus: "excluded",
wantReason: "quality_outside_daily_range",
},
{
name: "disabled source",
row: MetricSourceRow{
QualityStatus: QualityOK,
SourceEnabled: &disabled,
SourceID: &sourceID,
},
wantStatus: "excluded",
wantReason: "source_disabled",
},
{
name: "unmanaged source",
row: MetricSourceRow{
QualityStatus: QualityOK,
},
wantStatus: "not_selected",
wantReason: "unmanaged_source_lower_priority",
},
{
name: "direct source without source table row",
row: MetricSourceRow{
QualityStatus: QualityOK,
SourceKind: "DIRECT",
},
wantStatus: "not_selected",
wantReason: "lower_trust_priority_or_sample_count",
},
{
name: "unknown source kind",
row: MetricSourceRow{
QualityStatus: QualityOK,
SourceID: &sourceID,
SourceKind: "UNKNOWN",
},
wantStatus: "not_selected",
wantReason: "unknown_source_kind_lower_priority",
},
{
name: "lower priority",
row: MetricSourceRow{
QualityStatus: QualityOK,
SourceID: &sourceID,
SourceKind: "PLATFORM",
},
wantStatus: "not_selected",
wantReason: "lower_trust_priority_or_sample_count",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
status, reason, action := metricSourceSelectionEvidence(tt.row)
if status != tt.wantStatus || reason != tt.wantReason || action == "" {
t.Fatalf("metricSourceSelectionEvidence() = %q, %q, %q", status, reason, action)
}
})
}
}
func TestMetricRepositoryQueriesDailyMetricSourceQualitySummary(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery(dailyMetricSourceQualitySummarySelectPattern).
WithArgs("JT808", "2026-07-12", "2026-07-12", "INVALID_DELTA", "outside_daily_range", 10, 0).
WillReturnRows(sqlmock.NewRows([]string{
"stat_date", "protocol", "quality_status", "quality_reason",
"source_count", "vehicle_count", "selected_count", "sample_count", "daily_mileage_km",
}).AddRow(
"2026-07-12", "JT808", "INVALID_DELTA", "outside_daily_range",
int64(3), int64(3), int64(0), int64(128), 12435.2,
))
repository := NewMetricRepository(db)
rows, err := repository.QuerySourceQualitySummary(context.Background(), MetricSourceQuery{
Protocol: "jt808",
DateFrom: "2026-07-12",
DateTo: "2026-07-12",
QualityStatus: "invalid_delta",
QualityReason: "Outside_Daily_Range",
Limit: 10,
})
if err != nil {
t.Fatalf("QuerySourceQualitySummary() error = %v", err)
}
if len(rows) != 1 {
t.Fatalf("row count = %d", len(rows))
}
row := rows[0]
if row.StatDate != "2026-07-12" || row.Protocol != "JT808" || row.QualityStatus != "INVALID_DELTA" || row.QualityReason != "outside_daily_range" {
t.Fatalf("unexpected summary key: %#v", row)
}
if row.SourceCount != 3 || row.VehicleCount != 3 || row.SelectedCount != 0 || row.SampleCount != 128 || row.DailyMileageKM != 12435.2 {
t.Fatalf("unexpected summary values: %#v", row)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMetricRepositoryQueriesDailyMetricSourceSelectionSummary(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery(dailyMetricSourceSelectionSummarySelectPattern).
WithArgs("JT808", "2026-07-12", "2026-07-12", 10, 0).
WillReturnRows(sqlmock.NewRows([]string{
"stat_date", "protocol", "selection_status", "selection_reason",
"source_count", "vehicle_count", "selected_count", "sample_count", "daily_mileage_km",
}).AddRow(
"2026-07-12", "JT808", "not_selected", "lower_trust_priority_or_sample_count",
int64(36), int64(31), int64(0), int64(3200), 812.5,
))
repository := NewMetricRepository(db)
rows, err := repository.QuerySourceSelectionSummary(context.Background(), MetricSourceQuery{
Protocol: "jt808",
DateFrom: "2026-07-12",
DateTo: "2026-07-12",
Limit: 10,
})
if err != nil {
t.Fatalf("QuerySourceSelectionSummary() error = %v", err)
}
if len(rows) != 1 {
t.Fatalf("row count = %d", len(rows))
}
row := rows[0]
if row.StatDate != "2026-07-12" || row.Protocol != "JT808" || row.SelectionStatus != "not_selected" || row.SelectionReason != "lower_trust_priority_or_sample_count" {
t.Fatalf("unexpected summary key: %#v", row)
}
if row.SourceCount != 36 || row.VehicleCount != 31 || row.SelectedCount != 0 || row.SampleCount != 3200 || row.DailyMileageKM != 812.5 {
t.Fatalf("unexpected summary values: %#v", row)
}
if row.SelectionAction == "" || !strings.Contains(row.SelectionAction, "更高可信优先级") {
t.Fatalf("selection action = %q", row.SelectionAction)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestBuildMetricSourceSelectionSummarySQLGroupsByComputedSelectionEvidence(t *testing.T) {
sqlText, args := buildMetricSourceSelectionSummarySQL(normalizeMetricSourceQuery(MetricSourceQuery{
Protocol: "jt808",
DateFrom: "2026-07-12",
DateTo: "2026-07-12",
Limit: 20,
}))
for _, want := range []string{
"selection_status",
"selection_reason",
"selected_current_projection",
"source_disabled",
"source_key LIKE '%@PLATFORM:%'",
"source_key LIKE '%@DIRECT'",
"ds.id IS NULL AND COALESCE(NULLIF(TRIM(ds.source_kind), ''), CASE WHEN s.source_key LIKE '%@PLATFORM:%' THEN 'PLATFORM' WHEN s.source_key LIKE '%@DIRECT' THEN 'DIRECT' ELSE 'UNKNOWN' END) <> 'DIRECT'",
"unmanaged_source_lower_priority",
"unknown_source_kind_lower_priority",
"lower_trust_priority_or_sample_count",
"GROUP BY stat_date, protocol, selection_status, selection_reason",
} {
if !strings.Contains(sqlText, want) {
t.Fatalf("selection summary SQL missing %q:\n%s", want, sqlText)
}
}
if len(args) != 5 || args[0] != "JT808" || args[1] != "2026-07-12" || args[2] != "2026-07-12" || args[3] != 20 || args[4] != 0 {
t.Fatalf("args = %#v", args)
}
}
func TestBuildMetricSourceSQLCanOrderByDailyMileageDesc(t *testing.T) {
selected := false
sqlText, args := buildMetricSourceSQL(normalizeMetricSourceQuery(MetricSourceQuery{
Protocol: "jt808",
DateFrom: "2026-07-12",
DateTo: "2026-07-12",
Selected: &selected,
OrderBy: "daily_mileage_desc",
Limit: 20,
}))
for _, want := range []string{
"ORDER BY s.daily_mileage_km DESC",
"s.sample_count DESC",
"s.latest_event_time DESC",
} {
if !strings.Contains(sqlText, want) {
t.Fatalf("sql missing %q:\n%s", want, sqlText)
}
}
if strings.Contains(sqlText, "ORDER BY s.stat_date DESC, s.vin ASC") {
t.Fatalf("sql should use mileage ordering:\n%s", sqlText)
}
if len(args) != 6 || args[0] != "JT808" || args[1] != "2026-07-12" || args[2] != "2026-07-12" || args[3] != 0 || args[4] != 20 || args[5] != 0 {
t.Fatalf("args = %#v", args)
}
}
func TestMetricRepositoryQueriesDailyMetricDiagnostics(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery(dailyMetricDiagnosisSelectPattern).
WillReturnRows(sqlmock.NewRows([]string{
"vin", "stat_date", "protocol", "plate", "platform_name", "peer",
"snapshot_event_time", "location_event_time", "snapshot_updated_at", "location_updated_at",
"realtime_total_mileage_km", "realtime_total_mileage_event_time", "daily_mileage_km", "daily_latest_total_mileage_km",
"source_sample_count", "ok_source_count", "selectable_source_count", "latest_stat_event_time", "quality_statuses", "snapshot_parsed_json",
}).AddRow(
"LA9HE60A1PBAF4008", "2026-07-12", "JT808", "粤A12345", "信达", "115.231.168.135:47849",
"2026-07-12 02:23:27", "2026-07-12 02:23:27", "2026-07-12 02:23:29", "2026-07-12 02:23:29",
25246.6, "2026-07-12 02:23:27", nil, nil,
0, 0, 0, nil, "", `{"jt808.location.latitude":"30.1","jt808.location.total_mileage_km":"25246.6"}`,
))
repository := NewMetricRepository(db)
rows, err := repository.QueryDiagnostics(context.Background(), MetricDiagnosisQuery{
Protocol: "jt808",
Date: "2026-07-12",
Diagnosis: "NO_SOURCE_SAMPLE",
Limit: 20,
})
if err != nil {
t.Fatalf("QueryDiagnostics() error = %v", err)
}
if len(rows) != 1 {
t.Fatalf("row count = %d", len(rows))
}
row := rows[0]
if row.Diagnosis != "NO_SOURCE_SAMPLE" || row.Reason != "realtime_location_has_total_mileage_but_no_stat_sample" {
t.Fatalf("unexpected diagnosis: %#v", row)
}
if row.Severity != "pipeline" {
t.Fatalf("severity = %q, want pipeline", row.Severity)
}
if !strings.Contains(row.RecommendedOperatorAction, "字段流 topic") {
t.Fatalf("recommended action = %q", row.RecommendedOperatorAction)
}
if row.RealtimeTotalMileageKM == nil || *row.RealtimeTotalMileageKM != 25246.6 {
t.Fatalf("realtime total mileage = %#v", row.RealtimeTotalMileageKM)
}
if row.DailyMileageKM != nil {
t.Fatalf("daily mileage should be nil: %#v", row.DailyMileageKM)
}
if row.PlatformName != "信达" || row.Peer != "115.231.168.135:47849" {
t.Fatalf("unexpected realtime evidence: %#v", row)
}
if row.RealtimeFieldCount != 2 || len(row.RealtimeSampleFields) != 2 {
t.Fatalf("realtime field evidence = count:%d fields:%#v", row.RealtimeFieldCount, row.RealtimeSampleFields)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMetricDiagnosisWhereSupportsReasonAndSeverityFilters(t *testing.T) {
query := normalizeMetricDiagnosisQuery(MetricDiagnosisQuery{
Date: "2026-07-12",
Reason: "REALTIME_TOTAL_MILEAGE_MISSING",
Severity: "SOURCE_DATA",
})
where, args := buildMetricDiagnosisWhere(query)
joined := strings.Join(where, "\n")
for _, want := range []string{
"CASE",
"l.total_mileage_km IS NULL",
"source_data",
} {
if !strings.Contains(joined, want) {
t.Fatalf("where missing %q:\n%s", want, joined)
}
}
if len(args) < 4 {
t.Fatalf("args too short: %#v", args)
}
var hasReason, hasSeverity bool
for _, arg := range args {
switch arg {
case "realtime_total_mileage_missing":
hasReason = true
case "source_data":
hasSeverity = true
}
}
if !hasReason || !hasSeverity {
t.Fatalf("args = %#v, want reason/severity filters", args)
}
}
func TestMetricRepositoryQueriesDailyMetricDiagnosisSummary(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery(dailyMetricDiagnosisSummarySelectPattern).
WillReturnRows(sqlmock.NewRows([]string{
"stat_date", "protocol", "active_count", "ok_count", "missing_daily_count", "no_source_sample_count", "no_total_mileage_count",
}).AddRow(
"2026-07-12", "JT808", int64(244), int64(244), int64(0), int64(0), int64(0),
))
repository := NewMetricRepository(db)
rows, err := repository.QueryDiagnosisSummary(context.Background(), MetricDiagnosisQuery{
Protocol: "jt808",
Date: "2026-07-12",
})
if err != nil {
t.Fatalf("QueryDiagnosisSummary() error = %v", err)
}
if len(rows) != 1 {
t.Fatalf("row count = %d", len(rows))
}
row := rows[0]
if row.StatDate != "2026-07-12" || row.Protocol != "JT808" || row.ActiveCount != 244 || row.OKCount != 244 || row.ActionableIssueCount != 0 {
t.Fatalf("unexpected summary row: %#v", row)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMetricRepositoryQueriesDailyMetricDiagnosisReasonSummary(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery(dailyMetricDiagnosisReasonSummarySelectPattern).
WillReturnRows(sqlmock.NewRows([]string{
"stat_date", "protocol", "diagnosis", "reason", "count",
}).AddRow(
"2026-07-12", "YUTONG_MQTT", "NO_TOTAL_MILEAGE", "realtime_total_mileage_not_reported_on_stat_date", int64(7),
))
repository := NewMetricRepository(db)
rows, err := repository.QueryDiagnosisReasonSummary(context.Background(), MetricDiagnosisQuery{
Protocol: "yutong_mqtt",
Date: "2026-07-12",
Diagnosis: "no_total_mileage",
})
if err != nil {
t.Fatalf("QueryDiagnosisReasonSummary() error = %v", err)
}
if len(rows) != 1 {
t.Fatalf("row count = %d", len(rows))
}
row := rows[0]
if row.StatDate != "2026-07-12" || row.Protocol != "YUTONG_MQTT" || row.Diagnosis != "NO_TOTAL_MILEAGE" || row.Reason != "realtime_total_mileage_not_reported_on_stat_date" || row.Count != 7 || row.Severity != metricDiagnosisSeveritySourceData {
t.Fatalf("unexpected reason summary row: %#v", row)
}
if !strings.Contains(row.RecommendedOperatorAction, "不是统计日期内上报") {
t.Fatalf("recommended action = %q", row.RecommendedOperatorAction)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMetricRepositoryQueriesDailyMetricDiagnosisFieldStatusSummary(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery(dailyMetricDiagnosisFieldStatusSummarySelectPattern).
WillReturnRows(sqlmock.NewRows([]string{
"stat_date", "protocol", "diagnosis", "reason", "realtime_mileage_field_status", "count",
}).AddRow(
"2026-07-12", "YUTONG_MQTT", "NO_TOTAL_MILEAGE", "realtime_total_mileage_missing", "no_candidate_mileage_field", int64(9),
))
repository := NewMetricRepository(db)
rows, err := repository.QueryDiagnosisFieldStatusSummary(context.Background(), MetricDiagnosisQuery{
Protocol: "yutong_mqtt",
Date: "2026-07-12",
Diagnosis: "no_total_mileage",
Reason: "realtime_total_mileage_missing",
})
if err != nil {
t.Fatalf("QueryDiagnosisFieldStatusSummary() error = %v", err)
}
if len(rows) != 1 {
t.Fatalf("row count = %d", len(rows))
}
row := rows[0]
if row.StatDate != "2026-07-12" || row.Protocol != "YUTONG_MQTT" || row.Diagnosis != "NO_TOTAL_MILEAGE" || row.RealtimeMileageFieldStatus != "no_candidate_mileage_field" || row.Count != 9 {
t.Fatalf("unexpected field status summary row: %#v", row)
}
if row.Severity != metricDiagnosisSeveritySourceData {
t.Fatalf("severity = %q", row.Severity)
}
if row.FieldStatusSeverity != metricDiagnosisSeveritySourceData {
t.Fatalf("field status severity = %q", row.FieldStatusSeverity)
}
if !strings.Contains(row.FieldStatusAction, "源平台") {
t.Fatalf("field status action = %q", row.FieldStatusAction)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMetricDiagnosisFieldStatusSeverityDistinguishesFreshProjectionEvidence(t *testing.T) {
tests := []struct {
status string
want string
}{
{status: "daily_metric_exists", want: metricDiagnosisSeverityOK},
{status: "candidate_field_unmapped", want: metricDiagnosisSeverityPipeline},
{status: "mapped_protocol_field_without_fresh_evidence", want: metricDiagnosisSeveritySourceData},
{status: "standard_field_not_consumed", want: metricDiagnosisSeverityPipeline},
{status: "standard_field_time_missing", want: metricDiagnosisSeverityPipeline},
{status: "no_realtime_fields", want: metricDiagnosisSeverityPipeline},
{status: "standard_field_non_positive", want: metricDiagnosisSeveritySourceData},
{status: "standard_field_stale", want: metricDiagnosisSeveritySourceData},
{status: "no_candidate_mileage_field", want: metricDiagnosisSeveritySourceData},
}
for _, tt := range tests {
t.Run(tt.status, func(t *testing.T) {
if got := metricDiagnosisMileageFieldStatusSeverity(tt.status); got != tt.want {
t.Fatalf("metricDiagnosisMileageFieldStatusSeverity(%q) = %q, want %q", tt.status, got, tt.want)
}
})
}
}
func TestMetricDiagnosisFieldStatusSummaryTotalsPreferDiagnosisSeverity(t *testing.T) {
_, actionable, pipeline, sourceData := metricDiagnosisFieldStatusSummaryIssueTotals([]MetricDiagnosisFieldStatusSummaryRow{
{
Diagnosis: "MISSING_DAILY",
Reason: "source_samples_all_invalid",
Severity: metricDiagnosisSeveritySourceData,
RealtimeMileageFieldStatus: "source_sample_exists",
FieldStatusSeverity: metricDiagnosisSeverityPipeline,
Count: 2,
},
})
if actionable != 2 || pipeline != 0 || sourceData != 2 {
t.Fatalf("totals actionable=%d pipeline=%d source=%d", actionable, pipeline, sourceData)
}
}
func TestMetricDiagnosisIssueSeverity(t *testing.T) {
tests := []struct {
name string
diagnosis string
reason string
want string
}{
{name: "ok", diagnosis: "OK", want: metricDiagnosisSeverityOK},
{name: "missing daily", diagnosis: "MISSING_DAILY", want: metricDiagnosisSeverityPipeline},
{name: "missing daily all invalid samples", diagnosis: "MISSING_DAILY", reason: "source_samples_all_invalid", want: metricDiagnosisSeveritySourceData},
{name: "missing daily all excluded samples", diagnosis: "MISSING_DAILY", reason: "source_samples_all_excluded", want: metricDiagnosisSeveritySourceData},
{name: "no source sample", diagnosis: "NO_SOURCE_SAMPLE", want: metricDiagnosisSeverityPipeline},
{name: "time missing", diagnosis: "NO_TOTAL_MILEAGE", reason: "realtime_total_mileage_time_missing", want: metricDiagnosisSeverityPipeline},
{name: "source no mileage", diagnosis: "NO_TOTAL_MILEAGE", reason: "realtime_total_mileage_non_positive", want: metricDiagnosisSeveritySourceData},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := metricDiagnosisIssueSeverity(tt.diagnosis, tt.reason); got != tt.want {
t.Fatalf("metricDiagnosisIssueSeverity() = %q, want %q", got, tt.want)
}
})
}
}
func TestBuildActiveRealtimeMetricSQLUsesBusinessRealtimeTimestamp(t *testing.T) {
sqlText, args := buildActiveRealtimeMetricSQL(normalizeMetricDiagnosisQuery(MetricDiagnosisQuery{
Protocol: "jt808",
Date: "2026-07-12",
}))
for _, want := range []string{
"vehicle_realtime_snapshot",
"vehicle_realtime_location",
"event_time >= ?",
"received_at >= ?",
"protocol = ?",
} {
if !strings.Contains(sqlText, want) {
t.Fatalf("active realtime SQL missing %q:\n%s", want, sqlText)
}
}
if strings.Contains(sqlText, "updated_at >= ?") {
t.Fatalf("active realtime SQL should not use row mutation time as vehicle activity evidence:\n%s", sqlText)
}
if strings.Contains(sqlText, "COALESCE(event_time") {
t.Fatalf("active realtime SQL should not let stale event_time hide received_at:\n%s", sqlText)
}
if strings.Count(sqlText, "protocol = ?") != 2 {
t.Fatalf("protocol filter should be pushed into both realtime tables:\n%s", sqlText)
}
if len(args) != 10 {
t.Fatalf("args = %d, want 10: %#v", len(args), args)
}
}
func TestBuildMetricDiagnosisSQLRequiresPositiveRealtimeMileage(t *testing.T) {
summarySQL, _ := buildMetricDiagnosisSummarySQL(normalizeMetricDiagnosisQuery(MetricDiagnosisQuery{
Protocol: "jt808",
Date: "2026-07-12",
}))
detailSQL, _ := buildMetricDiagnosisSQL(normalizeMetricDiagnosisQuery(MetricDiagnosisQuery{
Protocol: "jt808",
Date: "2026-07-12",
Diagnosis: "NO_SOURCE_SAMPLE",
}))
noTotalSQL, _ := buildMetricDiagnosisSQL(normalizeMetricDiagnosisQuery(MetricDiagnosisQuery{
Protocol: "jt808",
Date: "2026-07-12",
Diagnosis: "NO_TOTAL_MILEAGE",
}))
for _, want := range []string{
"l.total_mileage_km > 0",
"l.total_mileage_km IS NULL OR l.total_mileage_km <= 0",
} {
combinedSQL := summarySQL + "\n" + detailSQL + "\n" + noTotalSQL
if !strings.Contains(combinedSQL, want) {
t.Fatalf("diagnosis SQL missing %q:\n%s", want, combinedSQL)
}
}
}
func TestBuildMetricDiagnosisFieldStatusSummarySQLClassifiesMileageEvidence(t *testing.T) {
sqlText, args := buildMetricDiagnosisFieldStatusSummarySQL(normalizeMetricDiagnosisQuery(MetricDiagnosisQuery{
Protocol: "yutong_mqtt",
Date: "2026-07-12",
Diagnosis: "NO_TOTAL_MILEAGE",
Reason: "realtime_total_mileage_missing",
Severity: "source_data",
}))
for _, want := range []string{
"realtime_mileage_field_status",
"standard_field_non_positive",
"standard_field_stale",
"mapped_protocol_field_without_fresh_evidence",
"candidate_field_unmapped",
"no_candidate_mileage_field",
"LOWER(s.parsed_json) LIKE '%mileage%'",
"GROUP BY stat_date, protocol, diagnosis, reason, realtime_mileage_field_status",
} {
if !strings.Contains(sqlText, want) {
t.Fatalf("field status summary SQL missing %q:\n%s", want, sqlText)
}
}
for _, want := range []any{"NO_TOTAL_MILEAGE", "realtime_total_mileage_missing", "source_data"} {
var found bool
for _, arg := range args {
if arg == want {
found = true
break
}
}
if !found {
t.Fatalf("args missing %v: %#v", want, args)
}
}
}
func TestMetricDiagnosisTreatsZeroMileageAsNoTotalMileage(t *testing.T) {
diagnosis, reason := classifyMetricDiagnosis(false, 0, 0, 0,
sql.NullFloat64{Float64: 0, Valid: true},
"2026-07-12 02:51:04",
"2026-07-12",
)
if diagnosis != "NO_TOTAL_MILEAGE" || reason != "realtime_total_mileage_non_positive" {
t.Fatalf("diagnosis=%s reason=%s", diagnosis, reason)
}
}
func TestMetricDiagnosisTreatsAllInvalidSourceSamplesAsSourceData(t *testing.T) {
diagnosis, reason := classifyMetricDiagnosis(false, 2, 0, 0,
sql.NullFloat64{Float64: 47724.2, Valid: true},
"2026-07-13 00:07:45",
"2026-07-13",
)
if diagnosis != "MISSING_DAILY" || reason != "source_samples_all_invalid" {
t.Fatalf("diagnosis=%s reason=%s", diagnosis, reason)
}
if got := metricDiagnosisIssueSeverity(diagnosis, reason); got != metricDiagnosisSeveritySourceData {
t.Fatalf("severity=%s, want source_data", got)
}
if action := metricDiagnosisRecommendedOperatorAction(diagnosis, reason); !strings.Contains(action, "质量规则排除") {
t.Fatalf("action=%q", action)
}
}
func TestMetricDiagnosisTreatsAllExcludedSourceSamplesAsSourceData(t *testing.T) {
diagnosis, reason := classifyMetricDiagnosis(false, 1, 1, 0,
sql.NullFloat64{Float64: 33574.6, Valid: true},
"2026-07-13 00:11:56",
"2026-07-13",
)
if diagnosis != "MISSING_DAILY" || reason != "source_samples_all_excluded" {
t.Fatalf("diagnosis=%s reason=%s", diagnosis, reason)
}
if got := metricDiagnosisIssueSeverity(diagnosis, reason); got != metricDiagnosisSeveritySourceData {
t.Fatalf("severity=%s, want source_data", got)
}
if action := metricDiagnosisRecommendedOperatorAction(diagnosis, reason); !strings.Contains(action, "没有可参与选举的来源") {
t.Fatalf("action=%q", action)
}
}
func TestMetricDiagnosisExplainsStaleRealtimeMileage(t *testing.T) {
diagnosis, reason := classifyMetricDiagnosis(false, 0, 0, 0,
sql.NullFloat64{Float64: 85344, Valid: true},
"2026-07-11 23:59:59",
"2026-07-12",
)
if diagnosis != "NO_TOTAL_MILEAGE" || reason != "realtime_total_mileage_not_reported_on_stat_date" {
t.Fatalf("diagnosis=%s reason=%s", diagnosis, reason)
}
}
func TestMetricDiagnosisExplainsMissingRealtimeMileageTime(t *testing.T) {
diagnosis, reason := classifyMetricDiagnosis(false, 0, 0, 0,
sql.NullFloat64{Float64: 85344, Valid: true},
"",
"2026-07-12",
)
if diagnosis != "NO_TOTAL_MILEAGE" || reason != "realtime_total_mileage_time_missing" {
t.Fatalf("diagnosis=%s reason=%s", diagnosis, reason)
}
}
func TestMetricDiagnosisExplainsMissingRealtimeMileage(t *testing.T) {
diagnosis, reason := classifyMetricDiagnosis(false, 0, 0, 0,
sql.NullFloat64{},
"",
"2026-07-12",
)
if diagnosis != "NO_TOTAL_MILEAGE" || reason != "realtime_total_mileage_missing" {
t.Fatalf("diagnosis=%s reason=%s", diagnosis, reason)
}
}
func TestMetricHandlerReturnsDailyMetrics(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM vehicle_daily_mileage m").
WithArgs("LB9A32A21R0LS1707", "GB32960", "2020-07-01", "2020-07-01").
WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(42))
mock.ExpectQuery(dailyMetricSelectPattern).
WithArgs("LB9A32A21R0LS1707", "GB32960", "2020-07-01", "2020-07-01", 50, 0).
WillReturnRows(sqlmock.NewRows([]string{
"vin", "stat_date", "protocol", "source_id",
"source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind", "daily_mileage_km",
"latest_total_mileage_km", "updated_at",
}).AddRow(
"LB9A32A21R0LS1707", "2020-07-01", "GB32960", 1,
"8.134.95.166", "8.134.95.166:32960", "现代 HTWO", "HYUNDAI", "PLATFORM", 0.0,
53490.9, "2026-07-01 22:28:25",
))
handler := NewMetricHandler(NewMetricRepository(db))
request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics?vin=LB9A32A21R0LS1707&protocol=GB32960&dateFrom=2020-07-01&dateTo=2020-07-01&includeTotal=true", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{`"vin":"LB9A32A21R0LS1707"`, `"platform_name":"现代 HTWO"`, `"source_kind":"PLATFORM"`, `"daily_mileage_km":0`, `"total":42`} {
if !strings.Contains(body, want) {
t.Fatalf("response missing %s: %s", want, body)
}
}
if strings.Contains(body, "created_at") {
t.Fatalf("daily mileage response should not expose created_at: %s", body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMetricHandlerReturnsDailyMetricSources(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM vehicle_daily_mileage_source s").
WithArgs("LA9GG64L7PBAF4001", "JT808", "2026-07-08", "2026-07-08", "historical_source_baseline", 1).
WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(2))
mock.ExpectQuery(dailyMetricSourceSelectPattern).
WithArgs("LA9GG64L7PBAF4001", "JT808", "2026-07-08", "2026-07-08", "historical_source_baseline", 1, 50, 0).
WillReturnRows(sqlmock.NewRows([]string{
"vin", "stat_date", "protocol", "source_key", "source_ip", "source_endpoint", "phone", "device_id",
"platform_name", "source_id", "source_code", "source_kind", "enabled", "trust_priority",
"first_total_mileage_km", "latest_total_mileage_km", "daily_mileage_km", "sample_count",
"first_event_time", "latest_event_time", "quality_status", "quality_reason", "is_selected", "updated_at",
}).AddRow(
"LA9GG64L7PBAF4001", "2026-07-08", "JT808", "JT808:13307795425@115.231.168.135", "115.231.168.135",
"115.231.168.135:41561", "13307795425", nil,
"信达", 5, "xinda", "PLATFORM", 1, 10,
4100.8, 4123.9, 23.1, 128,
"2026-07-08 00:01:00", "2026-07-08 23:59:00", "OK", "historical_source_baseline", 1, "2026-07-08 23:59:10",
))
handler := NewMetricHandler(NewMetricRepository(db))
request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics/sources?vin=LA9GG64L7PBAF4001&protocol=JT808&dateFrom=2026-07-08&dateTo=2026-07-08&qualityReason=Historical_Source_Baseline&selected=true&includeTotal=true", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{`"total":2`, `"source_key":"JT808:13307795425@115.231.168.135"`, `"platform_name":"信达"`, `"source_kind":"PLATFORM"`, `"is_selected":true`, `"quality_status":"OK"`, `"selection_status":"selected"`, `"selection_reason":"selected_current_projection"`} {
if !strings.Contains(body, want) {
t.Fatalf("response missing %s: %s", want, body)
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMetricHandlerReturnsDailyMetricSourceQualitySummary(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM \\(").
WithArgs("JT808", "2026-07-12", "2026-07-12", "INVALID_DELTA", "outside_daily_range").
WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(1))
mock.ExpectQuery(dailyMetricSourceQualitySummarySelectPattern).
WithArgs("JT808", "2026-07-12", "2026-07-12", "INVALID_DELTA", "outside_daily_range", 5, 0).
WillReturnRows(sqlmock.NewRows([]string{
"stat_date", "protocol", "quality_status", "quality_reason",
"source_count", "vehicle_count", "selected_count", "sample_count", "daily_mileage_km",
}).AddRow(
"2026-07-12", "JT808", "INVALID_DELTA", "outside_daily_range",
int64(3), int64(3), int64(0), int64(128), 12435.2,
))
handler := NewMetricHandler(NewMetricRepository(db))
request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics/sources/quality?protocol=JT808&dateFrom=2026-07-12&dateTo=2026-07-12&qualityStatus=invalid_delta&qualityReason=Outside_Daily_Range&includeTotal=true&limit=5", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{`"total":1`, `"protocol":"JT808"`, `"quality_status":"INVALID_DELTA"`, `"quality_reason":"outside_daily_range"`, `"source_count":3`, `"vehicle_count":3`, `"selected_count":0`, `"sample_count":128`} {
if !strings.Contains(body, want) {
t.Fatalf("response missing %s: %s", want, body)
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMetricHandlerReturnsDailyMetricSourceSelectionSummary(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM \\(").
WithArgs("JT808", "2026-07-12", "2026-07-12").
WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(2))
mock.ExpectQuery(dailyMetricSourceSelectionSummarySelectPattern).
WithArgs("JT808", "2026-07-12", "2026-07-12", 10, 0).
WillReturnRows(sqlmock.NewRows([]string{
"stat_date", "protocol", "selection_status", "selection_reason",
"source_count", "vehicle_count", "selected_count", "sample_count", "daily_mileage_km",
}).AddRow(
"2026-07-12", "JT808", "excluded", "quality_outside_daily_range",
int64(3), int64(3), int64(0), int64(128), 12435.2,
))
handler := NewMetricHandler(NewMetricRepository(db))
request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics/sources/selection?protocol=JT808&dateFrom=2026-07-12&dateTo=2026-07-12&includeTotal=true&limit=10", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{`"total":2`, `"protocol":"JT808"`, `"selection_status":"excluded"`, `"selection_reason":"quality_outside_daily_range"`, `"selection_action":"来源质量未通过`, `"source_count":3`, `"vehicle_count":3`, `"sample_count":128`} {
if !strings.Contains(body, want) {
t.Fatalf("response missing %s: %s", want, body)
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMetricHandlerReturnsDailyMetricDiagnostics(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery(dailyMetricDiagnosisSelectPattern).
WillReturnRows(sqlmock.NewRows([]string{
"vin", "stat_date", "protocol", "plate", "platform_name", "peer",
"snapshot_event_time", "location_event_time", "snapshot_updated_at", "location_updated_at",
"realtime_total_mileage_km", "realtime_total_mileage_event_time", "daily_mileage_km", "daily_latest_total_mileage_km",
"source_sample_count", "ok_source_count", "selectable_source_count", "latest_stat_event_time", "quality_statuses", "snapshot_parsed_json",
}).AddRow(
"LMRKH9AC2R1004087", "2026-07-12", "YUTONG_MQTT", "粤A99999", "宇通 MQTT", "yutong",
"2026-07-12 10:01:00", nil, "2026-07-12 10:01:01", nil,
nil, nil, nil, nil,
0, 0, 0, nil, "", `{"yutong_mqtt.data.latitude":"30.593478","yutong_mqtt.data.longitude":"121.073451","yutong_mqtt.data.meter_speed":"0"}`,
))
handler := NewMetricHandler(NewMetricRepository(db))
request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics/diagnostics?protocol=YUTONG_MQTT&date=2026-07-12&diagnosis=NO_TOTAL_MILEAGE", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{`"vin":"LMRKH9AC2R1004087"`, `"diagnosis":"NO_TOTAL_MILEAGE"`, `"reason":"realtime_total_mileage_missing"`, `"severity":"source_data"`, `"recommended_operator_action":"当日实时数据缺少总里程字段`, `"realtime_field_count":3`, `"realtime_mileage_field_status":"no_candidate_mileage_field"`, `"realtime_mileage_evidence":"实时快照已有字段`, `"raw_frame_query_path":"/api/history/raw-frames?`, `"source_sample_query_path":"/api/stats/daily-metrics/sources?`, `dateFrom=2026-07-12+00%3A00%3A00`, `protocol=YUTONG_MQTT`, `vin=LMRKH9AC2R1004087`, `"yutong_mqtt.data.latitude"`, `"yutong_mqtt.data.meter_speed"`, `"total":1`} {
if !strings.Contains(body, want) {
t.Fatalf("response missing %s: %s", want, body)
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMetricDiagnosisTraceQueryPaths(t *testing.T) {
row := MetricDiagnosisRow{
VIN: "LMRKH9AC2R1004087",
StatDate: "2026-07-12",
Protocol: "YUTONG_MQTT",
}
rawPath := metricDiagnosisRawFrameQueryPath(row)
for _, want := range []string{
"/api/history/raw-frames?",
"dateFrom=2026-07-12+00%3A00%3A00",
"dateTo=2026-07-13+00%3A00%3A00",
"includeFields=true",
"limit=20",
"orderBy=eventTime",
"protocol=YUTONG_MQTT",
"vin=LMRKH9AC2R1004087",
} {
if !strings.Contains(rawPath, want) {
t.Fatalf("raw path missing %q: %s", want, rawPath)
}
}
sourcePath := metricDiagnosisSourceSampleQueryPath(row)
for _, want := range []string{
"/api/stats/daily-metrics/sources?",
"dateFrom=2026-07-12",
"dateTo=2026-07-12",
"includeTotal=true",
"limit=50",
"protocol=YUTONG_MQTT",
"vin=LMRKH9AC2R1004087",
} {
if !strings.Contains(sourcePath, want) {
t.Fatalf("source path missing %q: %s", want, sourcePath)
}
}
}
func TestMetricRepositoryFlagsCandidateMileageFieldsWhenStandardMileageMissing(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery(dailyMetricDiagnosisSelectPattern).
WillReturnRows(sqlmock.NewRows([]string{
"vin", "stat_date", "protocol", "plate", "platform_name", "peer",
"snapshot_event_time", "location_event_time", "snapshot_updated_at", "location_updated_at",
"realtime_total_mileage_km", "realtime_total_mileage_event_time", "daily_mileage_km", "daily_latest_total_mileage_km",
"source_sample_count", "ok_source_count", "selectable_source_count", "latest_stat_event_time", "quality_statuses", "snapshot_parsed_json",
}).AddRow(
"VIN001", "2026-07-12", "YUTONG_MQTT", "", "宇通 MQTT", "mqtt://yutong/ytforward/shln/3",
"2026-07-12 10:01:00", nil, "2026-07-12 10:01:01", nil,
nil, nil, nil, nil,
0, 0, 0, nil, "", `{"yutong_mqtt.data.latitude":"30.593478","yutong_mqtt.data.odometer":"65423000","yutong_mqtt.data.meter_speed":"12"}`,
))
repository := NewMetricRepository(db)
rows, err := repository.QueryDiagnostics(context.Background(), MetricDiagnosisQuery{
Protocol: "YUTONG_MQTT",
Date: "2026-07-12",
Diagnosis: "NO_TOTAL_MILEAGE",
Limit: 20,
})
if err != nil {
t.Fatalf("QueryDiagnostics() error = %v", err)
}
if len(rows) != 1 {
t.Fatalf("row count = %d", len(rows))
}
row := rows[0]
if row.RealtimeMileageFieldStatus != "candidate_field_unmapped" {
t.Fatalf("mileage field status = %q, row=%#v", row.RealtimeMileageFieldStatus, row)
}
if len(row.RealtimeMileageCandidateFields) != 1 || row.RealtimeMileageCandidateFields[0] != "yutong_mqtt.data.odometer" {
t.Fatalf("candidate fields = %#v", row.RealtimeMileageCandidateFields)
}
if !strings.Contains(row.RealtimeMileageEvidence, "协议字段映射") {
t.Fatalf("evidence = %q", row.RealtimeMileageEvidence)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMetricRepositoryTreatsMergedMappedMileageFieldAsStaleEvidence(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery(dailyMetricDiagnosisSelectPattern).
WillReturnRows(sqlmock.NewRows([]string{
"vin", "stat_date", "protocol", "plate", "platform_name", "peer",
"snapshot_event_time", "location_event_time", "snapshot_updated_at", "location_updated_at",
"realtime_total_mileage_km", "realtime_total_mileage_event_time", "daily_mileage_km", "daily_latest_total_mileage_km",
"source_sample_count", "ok_source_count", "selectable_source_count", "latest_stat_event_time", "quality_statuses", "snapshot_parsed_json",
}).AddRow(
"VIN001", "2026-07-12", "YUTONG_MQTT", "", "宇通 MQTT", "mqtt://yutong/ytforward/shln/3",
"2026-07-12 10:01:00", nil, "2026-07-12 10:01:01", nil,
nil, nil, nil, nil,
0, 0, 0, nil, "", `{"yutong_mqtt.data.latitude":"30.593478","yutong_mqtt.data.total_mileage":"65423000","yutong_mqtt.data.meter_speed":"12"}`,
))
repository := NewMetricRepository(db)
rows, err := repository.QueryDiagnostics(context.Background(), MetricDiagnosisQuery{
Protocol: "YUTONG_MQTT",
Date: "2026-07-12",
Diagnosis: "NO_TOTAL_MILEAGE",
Limit: 20,
})
if err != nil {
t.Fatalf("QueryDiagnostics() error = %v", err)
}
if len(rows) != 1 {
t.Fatalf("row count = %d", len(rows))
}
row := rows[0]
if row.RealtimeMileageFieldStatus != "mapped_protocol_field_without_fresh_evidence" {
t.Fatalf("mileage field status = %q, row=%#v", row.RealtimeMileageFieldStatus, row)
}
if len(row.RealtimeMileageCandidateFields) != 1 || row.RealtimeMileageCandidateFields[0] != "yutong_mqtt.data.total_mileage" {
t.Fatalf("candidate fields = %#v", row.RealtimeMileageCandidateFields)
}
if !strings.Contains(row.RealtimeMileageEvidence, "合并快照") {
t.Fatalf("evidence = %q", row.RealtimeMileageEvidence)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMetricRepositoryReportsStaleStandardMileageBeforeCandidateFields(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery(dailyMetricDiagnosisSelectPattern).
WillReturnRows(sqlmock.NewRows([]string{
"vin", "stat_date", "protocol", "plate", "platform_name", "peer",
"snapshot_event_time", "location_event_time", "snapshot_updated_at", "location_updated_at",
"realtime_total_mileage_km", "realtime_total_mileage_event_time", "daily_mileage_km", "daily_latest_total_mileage_km",
"source_sample_count", "ok_source_count", "selectable_source_count", "latest_stat_event_time", "quality_statuses", "snapshot_parsed_json",
}).AddRow(
"VIN001", "2026-07-13", "YUTONG_MQTT", "", "宇通 MQTT", "mqtt://yutong/ytforward/shln/3",
"2026-07-13 10:01:00", "2026-07-13 10:01:00", "2026-07-13 10:01:01", "2026-07-13 10:01:01",
65423.0, "2026-07-12 23:59:59", nil, nil,
0, 0, 0, nil, "", `{"yutong_mqtt.data.total_mileage":"65423000"}`,
))
repository := NewMetricRepository(db)
rows, err := repository.QueryDiagnostics(context.Background(), MetricDiagnosisQuery{
Protocol: "YUTONG_MQTT",
Date: "2026-07-13",
Diagnosis: "NO_TOTAL_MILEAGE",
Limit: 20,
})
if err != nil {
t.Fatalf("QueryDiagnostics() error = %v", err)
}
if len(rows) != 1 {
t.Fatalf("row count = %d", len(rows))
}
row := rows[0]
if row.Reason != "realtime_total_mileage_not_reported_on_stat_date" {
t.Fatalf("reason = %q", row.Reason)
}
if row.RealtimeMileageFieldStatus != "standard_field_stale" {
t.Fatalf("mileage field status = %q, row=%#v", row.RealtimeMileageFieldStatus, row)
}
if len(row.RealtimeMileageCandidateFields) != 0 {
t.Fatalf("candidate fields should be hidden for standard stale field: %#v", row.RealtimeMileageCandidateFields)
}
if !strings.Contains(row.RealtimeMileageEvidence, "不在统计日") {
t.Fatalf("evidence = %q", row.RealtimeMileageEvidence)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMetricHandlerReturnsDailyMetricDiagnosisSummary(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery(dailyMetricDiagnosisSummarySelectPattern).
WillReturnRows(sqlmock.NewRows([]string{
"stat_date", "protocol", "active_count", "ok_count", "missing_daily_count", "no_source_sample_count", "no_total_mileage_count",
}).AddRow(
"2026-07-12", "YUTONG_MQTT", int64(13), int64(7), int64(0), int64(0), int64(6),
))
handler := NewMetricHandler(NewMetricRepository(db))
request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics/diagnostics/summary?protocol=YUTONG_MQTT&date=2026-07-12", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{`"protocol":"YUTONG_MQTT"`, `"active_count":13`, `"ok_count":7`, `"no_total_mileage_count":6`, `"active_total":13`, `"actionable_issue_total":6`} {
if !strings.Contains(body, want) {
t.Fatalf("response missing %s: %s", want, body)
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMetricHandlerReturnsDailyMetricDiagnosisReasonSummary(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery(dailyMetricDiagnosisReasonSummarySelectPattern).
WillReturnRows(sqlmock.NewRows([]string{
"stat_date", "protocol", "diagnosis", "reason", "count",
}).AddRow(
"2026-07-12", "JT808", "NO_TOTAL_MILEAGE", "realtime_total_mileage_non_positive", int64(2),
))
handler := NewMetricHandler(NewMetricRepository(db))
request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics/diagnostics/reasons?protocol=JT808&date=2026-07-12&diagnosis=NO_TOTAL_MILEAGE", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{`"protocol":"JT808"`, `"diagnosis":"NO_TOTAL_MILEAGE"`, `"reason":"realtime_total_mileage_non_positive"`, `"count":2`, `"severity":"source_data"`, `"recommended_operator_action":"终端上报总里程小于等于 0`, `"vehicle_total":2`, `"actionable_issue_total":2`, `"pipeline_issue_total":0`, `"source_data_issue_total":2`} {
if !strings.Contains(body, want) {
t.Fatalf("response missing %s: %s", want, body)
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMetricHandlerReturnsDailyMetricDiagnosisFieldStatusSummary(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery(dailyMetricDiagnosisFieldStatusSummarySelectPattern).
WillReturnRows(sqlmock.NewRows([]string{
"stat_date", "protocol", "diagnosis", "reason", "realtime_mileage_field_status", "count",
}).AddRow(
"2026-07-12", "JT808", "NO_TOTAL_MILEAGE", "realtime_total_mileage_non_positive", "standard_field_non_positive", int64(2),
))
handler := NewMetricHandler(NewMetricRepository(db))
request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics/diagnostics/field-status?protocol=JT808&date=2026-07-12&diagnosis=NO_TOTAL_MILEAGE", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{`"protocol":"JT808"`, `"diagnosis":"NO_TOTAL_MILEAGE"`, `"reason":"realtime_total_mileage_non_positive"`, `"realtime_mileage_field_status":"standard_field_non_positive"`, `"field_status_severity":"source_data"`, `"field_status_action":"源头总里程小于等于 0`, `"vehicle_total":2`, `"source_data_issue_total":2`} {
if !strings.Contains(body, want) {
t.Fatalf("response missing %s: %s", want, body)
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMetricHandlerReturnsEmptyItemsArrayWhenNoRows(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery(dailyMetricSelectPattern).
WithArgs("YUTONG_MQTT", 50, 0).
WillReturnRows(sqlmock.NewRows([]string{
"vin", "stat_date", "protocol", "source_id",
"source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind", "daily_mileage_km",
"latest_total_mileage_km", "updated_at",
}))
handler := NewMetricHandler(NewMetricRepository(db))
request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics?protocol=YUTONG_MQTT", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
body := response.Body.String()
if !strings.Contains(body, `"items":[]`) || strings.Contains(body, `"items":null`) {
t.Fatalf("expected empty items array, got: %s", body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMetricHandlerSkipsTotalCountByDefault(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery(dailyMetricSelectPattern).
WithArgs("JT808", 1, 0).
WillReturnRows(sqlmock.NewRows([]string{
"vin", "stat_date", "protocol", "source_id",
"source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind", "daily_mileage_km",
"latest_total_mileage_km", "updated_at",
}).AddRow(
"LKLG7C4E3NA774736", "2026-07-02", "JT808", 3,
"115.231.168.135", "115.231.168.135:41561", "G7 平台", "G7S", "PLATFORM", 12.3,
8805.1, "2026-07-02 23:59:59",
))
handler := NewMetricHandler(NewMetricRepository(db))
request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics?protocol=JT808&limit=1", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
body := response.Body.String()
if !strings.Contains(body, `"total":1`) {
t.Fatalf("response should use page size as total when total count is not requested: %s", body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMetricHandlerRejectsInvalidPagination(t *testing.T) {
handler := NewMetricHandler(NewMetricRepository(&sql.DB{}))
request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics?limit=2001", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
}
func TestMetricHandlerRejectsInvalidDiagnosis(t *testing.T) {
handler := NewMetricHandler(NewMetricRepository(&sql.DB{}))
request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics/diagnostics?diagnosis=BAD", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
}
func TestMetricHandlerRejectsInvalidDiagnosisReason(t *testing.T) {
handler := NewMetricHandler(NewMetricRepository(&sql.DB{}))
request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics/diagnostics?reason=unknown_reason", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
}
func TestMetricHandlerRejectsInvalidDiagnosisSeverity(t *testing.T) {
handler := NewMetricHandler(NewMetricRepository(&sql.DB{}))
request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics/diagnostics?severity=critical", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
}