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

674 lines
26 KiB
Go

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))
}