1447 lines
56 KiB
Go
1447 lines
56 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",
|
|
"pure_hydrogen_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, 0.0,
|
|
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",
|
|
"pure_hydrogen_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, 0.0,
|
|
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", "pure_hydrogen_mileage_km", "sample_count", "pure_hydrogen_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, 18.6, 128, 96,
|
|
"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",
|
|
"pure_hydrogen_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, 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", "pure_hydrogen_mileage_km", "sample_count", "pure_hydrogen_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, 18.6, 128, 96,
|
|
"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",
|
|
"pure_hydrogen_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",
|
|
"pure_hydrogen_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, 0.0,
|
|
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())
|
|
}
|
|
}
|