package stats import ( "context" "errors" "strings" "testing" "time" "github.com/DATA-DOG/go-sqlmock" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" ) func TestSourceKeyUsesProtocolDeviceAndSourceIP(t *testing.T) { got := SourceKey(envelope.ProtocolJT808, "13307765812", "", "115.231.168.135") if got != "JT808:13307765812@115.231.168.135" { t.Fatalf("source key = %q", got) } got = SourceKey(envelope.ProtocolGB32960, "", "iccid-1", "202.98.117.132") if got != "GB32960:iccid-1@202.98.117.132" { t.Fatalf("source key = %q", got) } } 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{ VIN: "LA9GG64L7PBAF4001", StatDate: "2026-07-08", Protocol: envelope.ProtocolJT808, SourceKey: "JT808:13307765812@115.231.168.135", SourceIP: "115.231.168.135", SourceEndpoint: "115.231.168.135:20215", Phone: "13307765812", FirstTotalKM: 4100.8, LatestTotalKM: 4123.9, DailyKM: 23.1, SampleCount: 2, FirstEventTime: time.Date(2026, 7, 8, 0, 1, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)), LatestEventTime: time.Date(2026, 7, 8, 23, 59, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)), QualityStatus: QualityOK, QualityReason: "same_source_continuous", } 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", len(exec.calls)) } sql := exec.calls[0].query for _, want := range []string{ "INSERT INTO vehicle_daily_mileage_source", "ON DUPLICATE KEY UPDATE", "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) { t.Fatalf("candidate upsert missing %q: %s", want, sql) } } if got := exec.calls[0].args[0]; got != "LA9GG64L7PBAF4001" { t.Fatalf("first arg = %#v", got) } } 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", "VALUES(first_event_time) <= first_event_time", "VALUES(first_event_time) < CAST(CONCAT(VALUES(stat_date), ' 00:00:00') AS DATETIME)", "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{ VIN: "LA9GG64L7PBAF4001", StatDate: "2026-07-08", Protocol: envelope.ProtocolJT808, SourceKey: "JT808:13307765812@", SourceIP: " ", Phone: "13307765812", QualityStatus: QualityOK, } if err := UpsertSourceMileage(context.Background(), exec, sample); err != nil { t.Fatalf("UpsertSourceMileage() error = %v", err) } if len(exec.calls) != 0 { t.Fatalf("exec calls = %d, want 0", len(exec.calls)) } } 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 TestNormalizePlatformSourceMileageExcludesIncompleteLegacyTotals(t *testing.T) { for name, query := range map[string]string{ "insert": normalizePlatformSourceMileageInsertSQL, "delete": normalizePlatformSourceMileageDeleteSQL, } { for _, predicate := range []string{ "s.first_total_mileage_km IS NOT NULL", "s.latest_total_mileage_km IS NOT NULL", } { if !strings.Contains(query, predicate) { t.Fatalf("%s query missing incomplete-total guard %q:\n%s", name, predicate, query) } } } } 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) != 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", "LEFT JOIN vehicle_data_source ds", "ds.id", "CASE WHEN COALESCE(s.quality_reason, '') = '" + QualityReasonGPSCoordinate + "' THEN 1 ELSE 0 END", "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))", "SUM(identity_source.sample_count)", "identity_source.vin = s.vin", "COALESCE(NULLIF(TRIM(identity_source.phone), ''), NULLIF(TRIM(identity_source.device_id), ''), identity_source.source_key)", } { if !strings.Contains(projectFinal, want) { t.Fatalf("project query missing %q: %s", want, projectFinal) } } for _, forbidden := range []string{ "trusted_source_key", "trusted_phone", "trusted_source_endpoint", "first_total_mileage_km", "ds.latest_source_endpoint", } { if strings.Contains(projectFinal, forbidden) { 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, "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) } for _, want := range []string{ "JOIN (", "SELECT", "s2.source_key", "s2.quality_status = '" + QualityOK + "'", "s2.daily_mileage_km BETWEEN 0 AND", "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 WHEN COALESCE(s2.quality_reason, '') = '" + QualityReasonGPSCoordinate + "' THEN 1 ELSE 0 END", "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", "SUM(identity_source.sample_count)", "identity_source.vin = s2.vin", "COALESCE(NULLIF(TRIM(identity_source.phone), ''), NULLIF(TRIM(identity_source.device_id), ''), identity_source.source_key)", "LIMIT 1", } { if !strings.Contains(markSelected, want) { t.Fatalf("mark query missing %q: %s", want, markSelected) } } if len(exec.calls[1].args) != 7 { t.Fatalf("mark query args = %d, want 7", len(exec.calls[1].args)) } 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) } } for queryName, query := range map[string]string{ "project": projectFinal, "mark": markSelected, } { identityScore := strings.Index(query, "SUM(identity_source.sample_count)") freshness := strings.Index(query, "latest_event_time DESC") rowSamples := strings.LastIndex(query, "sample_count DESC") if identityScore < 0 || freshness < identityScore || rowSamples < freshness { t.Fatalf("%s query should rank terminal evidence before forwarding freshness and row samples: %s", queryName, query) } } 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{ "DELETE FROM vehicle_daily_mileage", "NOT EXISTS (", "FROM vehicle_daily_mileage_source", "is_selected = 1", } { if !strings.Contains(cleanupFinal, want) { t.Fatalf("cleanup query missing %q: %s", want, cleanupFinal) } } 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)) }