feat: build vehicle data platform and production pipeline
This commit is contained in:
@@ -2,10 +2,13 @@ package stats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
)
|
||||
|
||||
@@ -21,6 +24,85 @@ func TestSourceKeyUsesProtocolDeviceAndSourceIP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceKeyForDirectUsesStableDeviceIdentity(t *testing.T) {
|
||||
first := SourceKeyForKind(envelope.ProtocolJT808, "13307765812", "", "115.231.168.135", "DIRECT")
|
||||
second := SourceKeyForKind(envelope.ProtocolJT808, "13307765812", "", "39.144.3.22", "DIRECT")
|
||||
if first != "JT808:13307765812@DIRECT" || second != first {
|
||||
t.Fatalf("direct source keys = %q/%q, want stable phone key", first, second)
|
||||
}
|
||||
|
||||
fallback := SourceKeyForKind(envelope.ProtocolJT808, "", "", "39.144.3.22", "DIRECT")
|
||||
if fallback != "JT808:unknown@39.144.3.22" {
|
||||
t.Fatalf("direct fallback source key = %q", fallback)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceKeyForPlatformUsesStableSourceCode(t *testing.T) {
|
||||
first := SourceKeyForSource(envelope.ProtocolJT808, "41456413943", "", "117.132.194.167", "PLATFORM", "guangan_beidou")
|
||||
second := SourceKeyForSource(envelope.ProtocolJT808, "41456413943", "", "117.132.198.90", "PLATFORM", "guangan_beidou")
|
||||
if first != "JT808:41456413943@PLATFORM:guangan_beidou" || second != first {
|
||||
t.Fatalf("platform source keys = %q/%q, want stable source_code key", first, second)
|
||||
}
|
||||
|
||||
withoutSourceCode := SourceKeyForSource(envelope.ProtocolJT808, "41456413943", "", "117.132.198.90", "PLATFORM", "")
|
||||
if withoutSourceCode != "JT808:41456413943@117.132.198.90" {
|
||||
t.Fatalf("platform fallback source key = %q", withoutSourceCode)
|
||||
}
|
||||
|
||||
withoutDeviceIdentity := SourceKeyForSource(envelope.ProtocolJT808, "", "", "117.132.198.90", "PLATFORM", "guangan_beidou")
|
||||
if withoutDeviceIdentity != "JT808:unknown@117.132.198.90" {
|
||||
t.Fatalf("unknown platform source key = %q", withoutDeviceIdentity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceMileageSampleFromMetricUsesDirectSourceKey(t *testing.T) {
|
||||
sample := MetricSample{
|
||||
VIN: "LA9GG64L7PBAF4001",
|
||||
StatDate: "2026-07-12",
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
Phone: "13307765812",
|
||||
TotalMileageKM: 4123.9,
|
||||
EventTime: time.Date(2026, 7, 12, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
|
||||
}
|
||||
candidate := SourceMileageSampleFromMetric(sample, SourceIdentity{
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
SourceIP: "115.231.168.135",
|
||||
SourceEndpoint: "115.231.168.135:43625",
|
||||
SourceKind: "DIRECT",
|
||||
})
|
||||
if candidate.SourceKey != "JT808:13307765812@DIRECT" {
|
||||
t.Fatalf("source key = %q", candidate.SourceKey)
|
||||
}
|
||||
if candidate.SourceIP != "115.231.168.135" {
|
||||
t.Fatalf("source ip = %q", candidate.SourceIP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceMileageSampleFromMetricUsesPlatformSourceCode(t *testing.T) {
|
||||
sample := MetricSample{
|
||||
VIN: "LNXNEGRR0SR321372",
|
||||
StatDate: "2026-07-12",
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
Phone: "41456413943",
|
||||
TotalMileageKM: 46484.2,
|
||||
EventTime: time.Date(2026, 7, 12, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
|
||||
}
|
||||
candidate := SourceMileageSampleFromMetric(sample, SourceIdentity{
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
SourceIP: "117.132.194.167",
|
||||
SourceEndpoint: "117.132.194.167:9806",
|
||||
SourceCode: "guangan_beidou",
|
||||
PlatformName: "广安北斗",
|
||||
SourceKind: "PLATFORM",
|
||||
})
|
||||
if candidate.SourceKey != "JT808:41456413943@PLATFORM:guangan_beidou" {
|
||||
t.Fatalf("source key = %q", candidate.SourceKey)
|
||||
}
|
||||
if candidate.PlatformName != "广安北斗" {
|
||||
t.Fatalf("platform name = %q", candidate.PlatformName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertSourceMileageWritesCandidateRow(t *testing.T) {
|
||||
exec := &recordingExec{}
|
||||
sample := SourceMileageSample{
|
||||
@@ -50,8 +132,13 @@ func TestUpsertSourceMileageWritesCandidateRow(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"INSERT INTO vehicle_daily_mileage_source",
|
||||
"ON DUPLICATE KEY UPDATE",
|
||||
"daily_mileage_km = GREATEST(",
|
||||
"quality_status = VALUES(quality_status)",
|
||||
"daily_mileage_km = CASE",
|
||||
"VALUES(latest_event_time) >= latest_event_time",
|
||||
"VALUES(first_event_time) <= first_event_time",
|
||||
"quality_status = CASE",
|
||||
"THEN '" + QualityInvalidDelta + "'",
|
||||
"quality_reason = CASE",
|
||||
"THEN 'outside_daily_range'",
|
||||
"platform_name = COALESCE(NULLIF(TRIM(VALUES(platform_name)), ''), platform_name)",
|
||||
} {
|
||||
if !strings.Contains(sql, want) {
|
||||
@@ -63,6 +150,114 @@ func TestUpsertSourceMileageWritesCandidateRow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertSourceMileageTruncatesSubsecondEventTimesAtDayBoundary(t *testing.T) {
|
||||
exec := &recordingExec{}
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
previous := time.Date(2026, 7, 12, 23, 59, 59, 999_000_000, loc)
|
||||
current := time.Date(2026, 7, 13, 0, 0, 1, 999_000_000, loc)
|
||||
sample := SourceMileageSample{
|
||||
VIN: "LMRKH9AC7R1004098",
|
||||
StatDate: "2026-07-13",
|
||||
Protocol: envelope.ProtocolYutongMQTT,
|
||||
SourceKey: "YUTONG_MQTT:LMRKH9AC7R1004098@PLATFORM:yutong",
|
||||
SourceIP: "mqtt",
|
||||
FirstTotalKM: 41249,
|
||||
LatestTotalKM: 41250,
|
||||
DailyKM: 1,
|
||||
SampleCount: 1,
|
||||
FirstEventTime: previous,
|
||||
LatestEventTime: current,
|
||||
QualityStatus: QualityOK,
|
||||
QualityReason: "historical_source_baseline",
|
||||
}
|
||||
if err := UpsertSourceMileage(context.Background(), exec, sample); err != nil {
|
||||
t.Fatalf("UpsertSourceMileage() error = %v", err)
|
||||
}
|
||||
if len(exec.calls) != 1 {
|
||||
t.Fatalf("exec calls = %d, want 1", len(exec.calls))
|
||||
}
|
||||
if got := exec.calls[0].args[13]; got != previous.Truncate(time.Second) {
|
||||
t.Fatalf("first event arg = %v, want %v", got, previous.Truncate(time.Second))
|
||||
}
|
||||
if got := exec.calls[0].args[14]; got != current.Truncate(time.Second) {
|
||||
t.Fatalf("latest event arg = %v, want %v", got, current.Truncate(time.Second))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertSourceMileageUsesEventTimeBoundaries(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"first_total_mileage_km = CASE",
|
||||
"VALUES(first_event_time) <= first_event_time",
|
||||
"latest_total_mileage_km = CASE",
|
||||
"VALUES(latest_event_time) >= latest_event_time",
|
||||
"daily_mileage_km = CASE",
|
||||
"VALUES(latest_total_mileage_km)",
|
||||
"VALUES(first_total_mileage_km)",
|
||||
} {
|
||||
if !strings.Contains(upsertSourceMileageSQL, want) {
|
||||
t.Fatalf("event-time boundary upsert SQL missing %q:\n%s", want, upsertSourceMileageSQL)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"GREATEST(latest_total_mileage_km, VALUES(latest_total_mileage_km))",
|
||||
"LEAST(first_total_mileage_km, VALUES(first_total_mileage_km))",
|
||||
"current_day_fallback_after_invalid_baseline",
|
||||
} {
|
||||
if strings.Contains(upsertSourceMileageSQL, forbidden) {
|
||||
t.Fatalf("event-time boundary upsert must not contain %q:\n%s", forbidden, upsertSourceMileageSQL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertSourceMileageRechecksMergedDailyRange(t *testing.T) {
|
||||
if maxSelectedDailyMileageKMSQL != "2500" || maxNegativeMileageJitterKMSQL != "1" {
|
||||
t.Fatalf("SQL mileage limits drifted from Go quality constants: selected=%s jitter=%s", maxSelectedDailyMileageKMSQL, maxNegativeMileageJitterKMSQL)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"quality_status = CASE",
|
||||
">= -" + maxNegativeMileageJitterKMSQL,
|
||||
"DATEDIFF(DATE(",
|
||||
"* " + maxSelectedDailyMileageKMSQL,
|
||||
"THEN '" + QualityInvalidDelta + "'",
|
||||
"quality_reason = CASE",
|
||||
"THEN 'outside_daily_range'",
|
||||
"THEN '" + QualityReasonCurrentDayFirst + "'",
|
||||
"ELSE '" + QualityReasonHistorical + "'",
|
||||
} {
|
||||
if !strings.Contains(upsertSourceMileageSQL, want) {
|
||||
t.Fatalf("merged range guard missing %q:\n%s", want, upsertSourceMileageSQL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviousSourceBaselineUsesLatestEarlierCalendarDay(t *testing.T) {
|
||||
if !strings.Contains(previousSourceBaselineSQL, "stat_date < ?") {
|
||||
t.Fatalf("previous baseline should search earlier calendar days:\n%s", previousSourceBaselineSQL)
|
||||
}
|
||||
if !strings.Contains(previousSourceBaselineSQL, "ORDER BY stat_date DESC, latest_event_time DESC") {
|
||||
t.Fatalf("previous baseline should prefer the nearest earlier sample:\n%s", previousSourceBaselineSQL)
|
||||
}
|
||||
if strings.Contains(previousSourceBaselineSQL, "quality_status =") {
|
||||
t.Fatalf("previous day's last odometer must remain usable even when that day's delta has no baseline:\n%s", previousSourceBaselineSQL)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"latest_total_mileage_km > 0",
|
||||
"latest_event_time >= CAST(CONCAT(stat_date, ' 00:00:00') AS DATETIME)",
|
||||
"latest_event_time < DATE_ADD(CAST(CONCAT(stat_date, ' 00:00:00') AS DATETIME), INTERVAL 1 DAY)",
|
||||
} {
|
||||
if !strings.Contains(previousSourceBaselineSQL, want) {
|
||||
t.Fatalf("previous baseline SQL missing %q:\n%s", want, previousSourceBaselineSQL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyMileageFromDayBoundaryUsesCurrentMinusPrevious(t *testing.T) {
|
||||
got := DailyMileageFromDayBoundary(14989.0, 15369.0)
|
||||
if got != 380.0 {
|
||||
t.Fatalf("daily mileage = %v, want current latest minus previous last = 380", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertSourceMileageSkipsBlankSourceIP(t *testing.T) {
|
||||
exec := &recordingExec{}
|
||||
sample := SourceMileageSample{
|
||||
@@ -82,30 +277,227 @@ func TestUpsertSourceMileageSkipsBlankSourceIP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePlatformSourceMileageMergesLegacyIPKeys(t *testing.T) {
|
||||
exec := &recordingExec{}
|
||||
err := NormalizePlatformSourceMileage(context.Background(), exec, "LNXNEGRR0SR321372", "2026-07-12", envelope.ProtocolJT808)
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizePlatformSourceMileage() error = %v", err)
|
||||
}
|
||||
if len(exec.calls) != 2 {
|
||||
t.Fatalf("exec calls = %d, want insert merge + delete legacy", len(exec.calls))
|
||||
}
|
||||
insertSQL := exec.calls[0].query
|
||||
for _, want := range []string{
|
||||
"INSERT INTO vehicle_daily_mileage_source",
|
||||
"CONCAT(s.protocol, ':', COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')), '@PLATFORM:', COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code))",
|
||||
"LEFT JOIN vehicle_data_source ds",
|
||||
"LEFT JOIN (",
|
||||
"FROM vehicle_identifier",
|
||||
"identifier_type = 'JT808_PHONE'",
|
||||
"HAVING COUNT(DISTINCT TRIM(source_code)) = 1",
|
||||
"ds.source_kind = 'PLATFORM'",
|
||||
"MAX(TRIM(COALESCE(ds.platform_name, vi.platform_name)))",
|
||||
"s.source_key <> CONCAT",
|
||||
"GROUP BY s.vin, s.stat_date, s.protocol, stable_source_key",
|
||||
"ON DUPLICATE KEY UPDATE",
|
||||
"sample_count = sample_count + VALUES(sample_count)",
|
||||
"s.quality_status IN ('" + QualityOK + "', '" + QualityNoPreviousBaseline + "')",
|
||||
"WHEN MIN(s.first_event_time) < CAST(CONCAT(s.stat_date, ' 00:00:00') AS DATETIME)",
|
||||
"THEN '" + QualityInvalidDelta + "'",
|
||||
"ELSE '" + QualityOK + "'",
|
||||
"THEN 'negative_jitter_clamped'",
|
||||
"THEN '" + QualityReasonHistorical + "'",
|
||||
"ELSE '" + QualityReasonCurrentDayFirst + "'",
|
||||
upsertSourceMergedMissingPreviousBaselineSQL,
|
||||
} {
|
||||
if !strings.Contains(insertSQL, want) {
|
||||
t.Fatalf("normalize insert SQL missing %q:\n%s", want, insertSQL)
|
||||
}
|
||||
}
|
||||
deleteSQL := exec.calls[1].query
|
||||
for _, want := range []string{
|
||||
"DELETE s",
|
||||
"FROM vehicle_daily_mileage_source s",
|
||||
"LEFT JOIN vehicle_data_source ds",
|
||||
"FROM vehicle_identifier",
|
||||
"ds.source_kind = 'PLATFORM'",
|
||||
"s.source_key <> CONCAT",
|
||||
} {
|
||||
if !strings.Contains(deleteSQL, want) {
|
||||
t.Fatalf("normalize delete SQL missing %q:\n%s", want, deleteSQL)
|
||||
}
|
||||
}
|
||||
for i, call := range exec.calls {
|
||||
if len(call.args) != 3 || call.args[0] != "LNXNEGRR0SR321372" || call.args[1] != "2026-07-12" || call.args[2] != "JT808" {
|
||||
t.Fatalf("call %d args = %#v", i, call.args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePlatformSourceMileageForDateNormalizesAndProjectsLegacyVINs(t *testing.T) {
|
||||
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
statDate := "2026-07-12"
|
||||
protocol := envelope.ProtocolJT808
|
||||
rows := sqlmock.NewRows([]string{"vin"}).
|
||||
AddRow("LA9HE60A0PBAF4002").
|
||||
AddRow("LA9HE60A1PBAF4008")
|
||||
mock.ExpectQuery(selectPlatformSourceMileageLegacyVINSQL).
|
||||
WithArgs(statDate, string(protocol)).
|
||||
WillReturnRows(rows)
|
||||
for _, vin := range []string{"LA9HE60A0PBAF4002", "LA9HE60A1PBAF4008"} {
|
||||
mock.ExpectExec(normalizePlatformSourceMileageInsertSQL).
|
||||
WithArgs(vin, statDate, string(protocol)).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(normalizePlatformSourceMileageDeleteSQL).
|
||||
WithArgs(vin, statDate, string(protocol)).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
expectProjectDailyMileageSQL(mock, vin, statDate, protocol)
|
||||
mock.ExpectCommit()
|
||||
}
|
||||
|
||||
normalized, err := NormalizePlatformSourceMileageForDate(context.Background(), db, statDate, protocol)
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizePlatformSourceMileageForDate() error = %v", err)
|
||||
}
|
||||
if normalized != 2 {
|
||||
t.Fatalf("normalized = %d, want 2", normalized)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("sql expectations were not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldNormalizePlatformSourceMileage(t *testing.T) {
|
||||
if !ShouldNormalizePlatformSourceMileage(SourceMileageSample{SourceKey: "JT808:41456413943@PLATFORM:guangan_beidou"}) {
|
||||
t.Fatal("platform source key should trigger legacy key normalization")
|
||||
}
|
||||
for _, sourceKey := range []string{"JT808:41456413943@DIRECT", "JT808:41456413943@117.132.194.167", ""} {
|
||||
if ShouldNormalizePlatformSourceMileage(SourceMileageSample{SourceKey: sourceKey}) {
|
||||
t.Fatalf("source key %q should not trigger platform normalization", sourceKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMileageQualityRulesClampsSmallNegativeJitter(t *testing.T) {
|
||||
sample := SourceMileageSample{
|
||||
DailyKM: -0.1,
|
||||
QualityStatus: QualityOK,
|
||||
QualityReason: "historical_source_baseline",
|
||||
}
|
||||
ApplyMileageQualityRules(&sample)
|
||||
if sample.DailyKM != 0 {
|
||||
t.Fatalf("daily km = %v, want 0", sample.DailyKM)
|
||||
}
|
||||
if sample.QualityStatus != QualityOK || sample.QualityReason != "negative_jitter_clamped" {
|
||||
t.Fatalf("quality = %s/%s", sample.QualityStatus, sample.QualityReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMileageQualityRulesRejectsLargeNegativeDelta(t *testing.T) {
|
||||
sample := SourceMileageSample{
|
||||
DailyKM: -55,
|
||||
QualityStatus: QualityOK,
|
||||
QualityReason: "historical_source_baseline",
|
||||
}
|
||||
ApplyMileageQualityRules(&sample)
|
||||
if sample.DailyKM != -55 {
|
||||
t.Fatalf("daily km = %v, want original invalid delta", sample.DailyKM)
|
||||
}
|
||||
if sample.QualityStatus != QualityInvalidDelta || sample.QualityReason != "outside_daily_range" {
|
||||
t.Fatalf("quality = %s/%s", sample.QualityStatus, sample.QualityReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMileageQualityRulesAcceptsPlausibleMultiDayFallbackDelta(t *testing.T) {
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
sample := SourceMileageSample{
|
||||
DailyKM: 7833.5,
|
||||
FirstEventTime: time.Date(2026, 7, 4, 11, 7, 58, 0, loc),
|
||||
LatestEventTime: time.Date(2026, 7, 12, 2, 52, 47, 0, loc),
|
||||
QualityStatus: QualityOK,
|
||||
QualityReason: "historical_source_baseline",
|
||||
}
|
||||
ApplyMileageQualityRules(&sample)
|
||||
if sample.QualityStatus != QualityOK || sample.QualityReason != "historical_source_baseline" {
|
||||
t.Fatalf("quality = %s/%s", sample.QualityStatus, sample.QualityReason)
|
||||
}
|
||||
if sample.DailyKM != 7833.5 {
|
||||
t.Fatalf("daily km = %v", sample.DailyKM)
|
||||
}
|
||||
if got := MileageQualityWindowDays(sample.FirstEventTime, sample.LatestEventTime); got != 8 {
|
||||
t.Fatalf("window days = %d, want 8", got)
|
||||
}
|
||||
if got := MileageQualityLimitKM(sample.FirstEventTime, sample.LatestEventTime); got != 8*maxSelectedDailyMileageKM {
|
||||
t.Fatalf("quality limit = %d, want 8-day limit %d", got, 8*maxSelectedDailyMileageKM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMileageQualityRulesAcceptsContinuousHighUtilizationDay(t *testing.T) {
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
sample := SourceMileageSample{
|
||||
DailyKM: 1895.7,
|
||||
FirstEventTime: time.Date(2026, 7, 12, 23, 59, 37, 0, loc),
|
||||
LatestEventTime: time.Date(2026, 7, 13, 20, 32, 42, 0, loc),
|
||||
QualityStatus: QualityOK,
|
||||
QualityReason: QualityReasonHistorical,
|
||||
}
|
||||
ApplyMileageQualityRules(&sample)
|
||||
if sample.QualityStatus != QualityOK || sample.QualityReason != QualityReasonHistorical {
|
||||
t.Fatalf("quality = %s/%s", sample.QualityStatus, sample.QualityReason)
|
||||
}
|
||||
if got := MileageQualityLimitKM(sample.FirstEventTime, sample.LatestEventTime); got != 2500 {
|
||||
t.Fatalf("quality limit = %d, want 2500", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMileageQualityRulesRejectsImplausibleSingleDayJump(t *testing.T) {
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
sample := SourceMileageSample{
|
||||
DailyKM: 4000,
|
||||
FirstEventTime: time.Date(2026, 7, 12, 23, 59, 37, 0, loc),
|
||||
LatestEventTime: time.Date(2026, 7, 13, 20, 32, 42, 0, loc),
|
||||
QualityStatus: QualityOK,
|
||||
QualityReason: QualityReasonHistorical,
|
||||
}
|
||||
ApplyMileageQualityRules(&sample)
|
||||
if sample.QualityStatus != QualityInvalidDelta || sample.QualityReason != "outside_daily_range" {
|
||||
t.Fatalf("quality = %s/%s", sample.QualityStatus, sample.QualityReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectDailyMileageSelectsCandidateAndMarksSource(t *testing.T) {
|
||||
exec := &recordingExec{}
|
||||
err := ProjectDailyMileage(context.Background(), exec, "LA9GG64L7PBAF4001", "2026-07-08", envelope.ProtocolJT808)
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectDailyMileage() error = %v", err)
|
||||
}
|
||||
if len(exec.calls) != 4 {
|
||||
t.Fatalf("exec calls = %d, want 4", len(exec.calls))
|
||||
}
|
||||
updateSources := exec.calls[0].query
|
||||
projectFinal := exec.calls[1].query
|
||||
markSelected := exec.calls[2].query
|
||||
cleanupFinal := exec.calls[3].query
|
||||
if !strings.Contains(updateSources, "UPDATE vehicle_daily_mileage_source") || !strings.Contains(updateSources, "is_selected = 0") {
|
||||
t.Fatalf("first query should clear selected candidates: %s", updateSources)
|
||||
if len(exec.calls) != 3 {
|
||||
t.Fatalf("exec calls = %d, want 3", len(exec.calls))
|
||||
}
|
||||
projectFinal := exec.calls[0].query
|
||||
markSelected := exec.calls[1].query
|
||||
cleanupFinal := exec.calls[2].query
|
||||
for _, want := range []string{
|
||||
"INSERT INTO vehicle_daily_mileage",
|
||||
"FROM vehicle_daily_mileage_source s",
|
||||
"JOIN vehicle_data_source ds",
|
||||
"LEFT JOIN vehicle_data_source ds",
|
||||
"ds.id",
|
||||
"ORDER BY ds.trust_priority",
|
||||
"CASE COALESCE(NULLIF(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN'), CASE WHEN s.source_key LIKE '%@PLATFORM:%' THEN 'PLATFORM' WHEN s.source_key LIKE '%@DIRECT' THEN 'DIRECT' ELSE 'UNKNOWN' END)",
|
||||
"WHEN 'PLATFORM' THEN 0",
|
||||
"WHEN 'DIRECT' THEN 1",
|
||||
"WHEN 'UNKNOWN' THEN 2",
|
||||
"ds.trust_priority",
|
||||
"s.quality_status = '" + QualityOK + "'",
|
||||
"COALESCE(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN') = 'UNKNOWN'",
|
||||
"AND (ds.source_code IS NULL OR TRIM(ds.source_code) = '')",
|
||||
"AND (ds.platform_name IS NULL OR TRIM(ds.platform_name) = '')",
|
||||
"s.daily_mileage_km BETWEEN 0 AND",
|
||||
"DATEDIFF(DATE(s.latest_event_time), DATE(s.first_event_time))",
|
||||
} {
|
||||
if !strings.Contains(projectFinal, want) {
|
||||
t.Fatalf("project query missing %q: %s", want, projectFinal)
|
||||
@@ -123,8 +515,10 @@ func TestProjectDailyMileageSelectsCandidateAndMarksSource(t *testing.T) {
|
||||
t.Fatalf("project query should not use final-table field %q: %s", forbidden, projectFinal)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(markSelected, "UPDATE vehicle_daily_mileage_source s") || !strings.Contains(markSelected, "SET s.is_selected = 1") {
|
||||
t.Fatalf("mark query should flag the elected source: %s", markSelected)
|
||||
if !strings.Contains(markSelected, "UPDATE vehicle_daily_mileage_source s") ||
|
||||
!strings.Contains(markSelected, "LEFT JOIN (") ||
|
||||
!strings.Contains(markSelected, "SET s.is_selected = CASE WHEN selected_source.source_key IS NULL THEN 0 ELSE 1 END") {
|
||||
t.Fatalf("mark query should reconcile the elected source: %s", markSelected)
|
||||
}
|
||||
if strings.Contains(markSelected, "JOIN vehicle_daily_mileage m") {
|
||||
t.Fatalf("mark query should not rejoin final mileage for candidate selection: %s", markSelected)
|
||||
@@ -135,18 +529,33 @@ func TestProjectDailyMileageSelectsCandidateAndMarksSource(t *testing.T) {
|
||||
"s2.source_key",
|
||||
"s2.quality_status = '" + QualityOK + "'",
|
||||
"s2.daily_mileage_km BETWEEN 0 AND",
|
||||
"ds.enabled = 1",
|
||||
"ORDER BY ds.trust_priority",
|
||||
"DATEDIFF(DATE(s2.latest_event_time), DATE(s2.first_event_time))",
|
||||
"COALESCE(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN') = 'UNKNOWN'",
|
||||
"AND (ds.source_code IS NULL OR TRIM(ds.source_code) = '')",
|
||||
"AND (ds.platform_name IS NULL OR TRIM(ds.platform_name) = '')",
|
||||
"CASE COALESCE(NULLIF(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN'), CASE WHEN s2.source_key LIKE '%@PLATFORM:%' THEN 'PLATFORM' WHEN s2.source_key LIKE '%@DIRECT' THEN 'DIRECT' ELSE 'UNKNOWN' END)",
|
||||
"WHEN 'PLATFORM' THEN 0",
|
||||
"WHEN 'DIRECT' THEN 1",
|
||||
"WHEN 'UNKNOWN' THEN 2",
|
||||
"ds.trust_priority",
|
||||
"LIMIT 1",
|
||||
} {
|
||||
if !strings.Contains(markSelected, want) {
|
||||
t.Fatalf("mark query missing %q: %s", want, markSelected)
|
||||
}
|
||||
}
|
||||
if len(exec.calls[2].args) != 7 {
|
||||
t.Fatalf("mark query args = %d, want 7", len(exec.calls[2].args))
|
||||
if len(exec.calls[1].args) != 7 {
|
||||
t.Fatalf("mark query args = %d, want 7", len(exec.calls[1].args))
|
||||
}
|
||||
if got := exec.calls[2].args[3]; got != maxSelectedDailyMileageKM {
|
||||
for _, forbidden := range []string{
|
||||
"WHEN 'UNKNOWN' THEN 1",
|
||||
"WHEN 'DIRECT' THEN 2",
|
||||
} {
|
||||
if strings.Contains(projectFinal, forbidden) || strings.Contains(markSelected, forbidden) {
|
||||
t.Fatalf("source selection should prefer classified DIRECT before UNKNOWN, found %q", forbidden)
|
||||
}
|
||||
}
|
||||
if got := exec.calls[1].args[3]; got != maxSelectedDailyMileageKM {
|
||||
t.Fatalf("mark query max mileage arg = %#v, want %d", got, maxSelectedDailyMileageKM)
|
||||
}
|
||||
for _, want := range []string{
|
||||
@@ -159,7 +568,70 @@ func TestProjectDailyMileageSelectsCandidateAndMarksSource(t *testing.T) {
|
||||
t.Fatalf("cleanup query missing %q: %s", want, cleanupFinal)
|
||||
}
|
||||
}
|
||||
if len(exec.calls[3].args) != 6 {
|
||||
t.Fatalf("cleanup query args = %d, want 6", len(exec.calls[3].args))
|
||||
if len(exec.calls[2].args) != 6 {
|
||||
t.Fatalf("cleanup query args = %d, want 6", len(exec.calls[2].args))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectDailyMileageCommitsTransactionForSQLDB(t *testing.T) {
|
||||
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
vin := "LA9GG64L7PBAF4001"
|
||||
statDate := "2026-07-08"
|
||||
protocol := envelope.ProtocolJT808
|
||||
expectProjectDailyMileageSQL(mock, vin, statDate, protocol)
|
||||
mock.ExpectCommit()
|
||||
|
||||
if err := ProjectDailyMileage(context.Background(), db, vin, statDate, protocol); err != nil {
|
||||
t.Fatalf("ProjectDailyMileage() error = %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("sql expectations were not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectDailyMileageRollsBackTransactionOnFailure(t *testing.T) {
|
||||
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
vin := "LA9GG64L7PBAF4001"
|
||||
statDate := "2026-07-08"
|
||||
protocol := envelope.ProtocolJT808
|
||||
wantErr := errors.New("mark selected failed")
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec(projectDailyMileageSQL).
|
||||
WithArgs(vin, statDate, string(protocol), maxSelectedDailyMileageKM).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(markSelectedSourceSQL).
|
||||
WithArgs(vin, statDate, string(protocol), maxSelectedDailyMileageKM, vin, statDate, string(protocol)).
|
||||
WillReturnError(wantErr)
|
||||
mock.ExpectRollback()
|
||||
|
||||
err = ProjectDailyMileage(context.Background(), db, vin, statDate, protocol)
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("ProjectDailyMileage() error = %v, want %v", err, wantErr)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("sql expectations were not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func expectProjectDailyMileageSQL(mock sqlmock.Sqlmock, vin string, statDate string, protocol envelope.Protocol) {
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec(projectDailyMileageSQL).
|
||||
WithArgs(vin, statDate, string(protocol), maxSelectedDailyMileageKM).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(markSelectedSourceSQL).
|
||||
WithArgs(vin, statDate, string(protocol), maxSelectedDailyMileageKM, vin, statDate, string(protocol)).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(cleanupProjectedDailyMileageSQL).
|
||||
WithArgs(vin, statDate, string(protocol), vin, statDate, string(protocol)).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user