package main import ( "context" "testing" "time" "github.com/DATA-DOG/go-sqlmock" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats" ) func TestChooseTrustedSourceKeepsContinuingSourceAndRejectsNewJump(t *testing.T) { previous := []dailySourceLast{{ VIN: "LA9GG64L7PBAF4001", SourceKey: normalizedSourceKey("JT808", "13307765812", "", "115.231.168.135:42630"), Phone: "13307765812", SourceEndpoint: "115.231.168.135:42630", TotalKM: 4100.8, }} current := []dailySourceLast{ { VIN: "LA9GG64L7PBAF4001", SourceKey: normalizedSourceKey("JT808", "13307765812", "", "115.159.85.149:28316"), Phone: "13307765812", SourceEndpoint: "115.159.85.149:28316", TotalKM: 42447.2, }, { VIN: "LA9GG64L7PBAF4001", SourceKey: normalizedSourceKey("JT808", "13307765812", "", "115.231.168.135:20215"), Phone: "13307765812", SourceEndpoint: "115.231.168.135:20215", TotalKM: 4123.9, }, } chosen, ok := chooseTrustedSource(current, previous) if !ok { t.Fatal("chooseTrustedSource() did not choose a source") } if chosen.current.SourceKey != normalizedSourceKey("JT808", "13307765812", "", "115.231.168.135:20215") { t.Fatalf("chosen source = %q", chosen.current.SourceKey) } if delta := chosen.current.TotalKM - chosen.previous.TotalKM; delta < 23 || delta > 24 { t.Fatalf("delta = %v, want about 23.1", delta) } } func TestDailySourceLastBuildsCandidateKeysBySourceIP(t *testing.T) { sourceA := dailySourceLast{ VIN: "LA9GG64L7PBAF4001", SourceKey: normalizedSourceKey("JT808", "13307765812", "", "115.231.168.135:20215"), Phone: "13307765812", SourceEndpoint: "115.231.168.135:20215", TotalKM: 4123.9, } sourceB := dailySourceLast{ VIN: "LA9GG64L7PBAF4001", SourceKey: normalizedSourceKey("JT808", "13307765812", "", "115.231.168.135:42630"), Phone: "13307765812", SourceEndpoint: "115.231.168.135:42630", TotalKM: 4100.8, } if sourceA.SourceKey != sourceB.SourceKey { t.Fatalf("same source IP should produce same source key: %q vs %q", sourceA.SourceKey, sourceB.SourceKey) } } func TestAggregateFromDailySourceUsesOlderHistoricalBaseline(t *testing.T) { current := dailySourceLast{ VIN: "LMRKH9AC2R1004087", SourceKey: normalizedSourceKey("YUTONG_MQTT", "", "LMRKH9AC2R1004087", "mqtt://yutong/ytforward/shln/3"), DeviceID: "LMRKH9AC2R1004087", SourceEndpoint: "mqtt://yutong/ytforward/shln/3", FirstTS: time.Date(2026, 7, 8, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)), TS: time.Date(2026, 7, 8, 17, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)), FirstTotalKM: 120778, TotalKM: 120788, RawSampleCount: 15, } previous := dailySourceLast{ VIN: "LMRKH9AC2R1004087", SourceKey: current.SourceKey, DeviceID: "LMRKH9AC2R1004087", SourceEndpoint: "mqtt://yutong/ytforward/shln/3", TS: time.Date(2026, 7, 4, 23, 58, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)), TotalKM: 120672, } agg := aggregateFromDailySource("2026-07-08", envelope.ProtocolYutongMQTT, current, previous, true) if agg.FirstKM != 120672 || agg.LatestKM != 120788 { t.Fatalf("km range = %v -> %v", agg.FirstKM, agg.LatestKM) } if agg.FirstEventTime != previous.TS || agg.LatestEventTime != current.TS { t.Fatalf("event range = %v -> %v", agg.FirstEventTime, agg.LatestEventTime) } if agg.QualityStatus != stats.QualityOK || agg.QualityReason != "historical_source_baseline" { t.Fatalf("quality = %s/%s", agg.QualityStatus, agg.QualityReason) } if agg.Count != 15 { t.Fatalf("sample count = %d, want 15", agg.Count) } } func TestAggregateFromDailySourceUsesCurrentFirstSampleWithoutHistory(t *testing.T) { current := dailySourceLast{ VIN: "LMRKH9AC2R1004087", SourceKey: normalizedSourceKey("YUTONG_MQTT", "", "LMRKH9AC2R1004087", "mqtt://yutong/ytforward/shln/3"), DeviceID: "LMRKH9AC2R1004087", SourceEndpoint: "mqtt://yutong/ytforward/shln/3", FirstTS: time.Date(2026, 7, 8, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)), TS: time.Date(2026, 7, 8, 17, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)), FirstTotalKM: 120778, TotalKM: 120788, RawSampleCount: 15, } agg := aggregateFromDailySource("2026-07-08", envelope.ProtocolYutongMQTT, current, dailySourceLast{}, false) if agg.FirstKM != 120778 || agg.LatestKM != 120788 { t.Fatalf("km range = %v -> %v", agg.FirstKM, agg.LatestKM) } if agg.FirstEventTime != current.FirstTS || agg.LatestEventTime != current.TS { t.Fatalf("event range = %v -> %v", agg.FirstEventTime, agg.LatestEventTime) } if agg.QualityStatus != stats.QualityOK || agg.QualityReason != "current_day_first_sample" { t.Fatalf("quality = %s/%s", agg.QualityStatus, agg.QualityReason) } } func TestClearBackfillTargetMileageClearsExactKey(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() mock.ExpectExec(`DELETE FROM vehicle_daily_mileage_source WHERE vin = \? AND stat_date = \? AND protocol = \?`). WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectExec(`DELETE FROM vehicle_daily_mileage WHERE vin = \? AND stat_date = \? AND protocol = \?`). WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808"). WillReturnResult(sqlmock.NewResult(0, 1)) if err := clearBackfillTargetMileage(context.Background(), db, "LA9GG64L7PBAF4001", "2026-07-08", envelope.ProtocolJT808); err != nil { t.Fatalf("clearBackfillTargetMileage() error = %v", err) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestWriteAggregatesClearsTargetRowsBeforeStaleCandidateUpsert(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() mock.MatchExpectationsInOrder(true) projNow := time.Date(2026, 7, 8, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)) aggregates := map[string]*metricAgg{ "LA9GG64L7PBAF4001|2026-07-08|JT808|JT808:13307765812@115.231.168.135": { VIN: "LA9GG64L7PBAF4001", Date: "2026-07-08", Protocol: envelope.ProtocolJT808, FirstKM: 4100, LatestKM: 4101, Count: 1, SourceKey: "JT808:13307765812@115.231.168.135", Phone: "13307765812", SourceEndpoint: "115.231.168.135:20215", FirstEventTime: projNow, LatestEventTime: projNow, QualityStatus: stats.QualityInvalidDelta, QualityReason: "outside_daily_range", }, } mock.ExpectExec(`DELETE FROM vehicle_daily_mileage_source WHERE vin = \? AND stat_date = \? AND protocol = \?`). WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectExec(`DELETE FROM vehicle_daily_mileage WHERE vin = \? AND stat_date = \? AND protocol = \?`). WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectExec(`INSERT INTO vehicle_data_source`). WithArgs("JT808", "115.231.168.135", "115.231.168.135:20215", sqlmock.AnyArg(), sqlmock.AnyArg()). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectExec(`INSERT INTO vehicle_daily_mileage_source`). WithArgs( "LA9GG64L7PBAF4001", "2026-07-08", "JT808", "JT808:13307765812@115.231.168.135", "115.231.168.135", "115.231.168.135:20215", "13307765812", "", "", float64(4100), float64(4101), float64(1), int64(1), projNow, projNow, stats.QualityInvalidDelta, "outside_daily_range", ). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectExec(`UPDATE vehicle_daily_mileage_source`). WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808"). WillReturnResult(sqlmock.NewResult(0, 0)) mock.ExpectExec(`INSERT INTO vehicle_daily_mileage`). WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", int64(1000)). WillReturnResult(sqlmock.NewResult(1, 0)) mock.ExpectExec(`UPDATE vehicle_daily_mileage_source s`). WithArgs( "LA9GG64L7PBAF4001", "2026-07-08", "JT808", int64(1000), "LA9GG64L7PBAF4001", "2026-07-08", "JT808", ). WillReturnResult(sqlmock.NewResult(0, 0)) mock.ExpectExec(`DELETE FROM vehicle_daily_mileage`). WithArgs( "LA9GG64L7PBAF4001", "2026-07-08", "JT808", "LA9GG64L7PBAF4001", "2026-07-08", "JT808", ). WillReturnResult(sqlmock.NewResult(0, 1)) written, err := writeAggregates(context.Background(), db, aggregates, 500) if err != nil { t.Fatalf("writeAggregates() error = %v", err) } if written != 1 { t.Fatalf("written = %d, want 1", written) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestWriteAggregatesSkipsBlankSourceBeforeClearingTarget(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() written, err := writeAggregates(context.Background(), db, map[string]*metricAgg{ "LA9GG64L7PBAF4001|2026-07-08|JT808|JT808:13307765812@": { VIN: "LA9GG64L7PBAF4001", Date: "2026-07-08", Protocol: envelope.ProtocolJT808, FirstKM: 4100, LatestKM: 4101, Count: 1, SourceKey: "JT808:13307765812@", Phone: "13307765812", SourceEndpoint: "", FirstEventTime: time.Date(2026, 7, 8, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)), LatestEventTime: time.Date(2026, 7, 8, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)), QualityStatus: stats.QualityNoPreviousBaseline, QualityReason: "missing_previous_source", }, }, 500) if err != nil { t.Fatalf("writeAggregates() error = %v", err) } if written != 0 { t.Fatalf("written = %d, want 0", written) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestAddSamplesUsesCurrentDayFirstSampleBaseline(t *testing.T) { loc := time.FixedZone("Asia/Shanghai", 8*3600) firstSamples, err := stats.SamplesFromEnvelope(envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, VIN: "LA9GG64L7PBAF4001", Phone: "13307765812", SourceEndpoint: "115.231.168.135:20215", EventTimeMS: time.Date(2026, 7, 8, 12, 0, 0, 0, loc).UnixMilli(), Fields: map[string]any{ "jt808.location.total_mileage_km": 4123.9, }, }, loc) if err != nil { t.Fatalf("SamplesFromEnvelope() error = %v", err) } secondSamples, err := stats.SamplesFromEnvelope(envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, VIN: "LA9GG64L7PBAF4001", Phone: "13307765812", SourceEndpoint: "115.231.168.135:20215", EventTimeMS: time.Date(2026, 7, 8, 13, 0, 0, 0, loc).UnixMilli(), Fields: map[string]any{ "jt808.location.total_mileage_km": 4129.9, }, }, loc) if err != nil { t.Fatalf("SamplesFromEnvelope() error = %v", err) } aggregates := map[string]*metricAgg{} addSamples(aggregates, firstSamples) addSamples(aggregates, secondSamples) if len(aggregates) != 1 { t.Fatalf("aggregate count = %d, want 1", len(aggregates)) } for _, agg := range aggregates { if agg.QualityStatus != stats.QualityOK { t.Fatalf("quality = %q, want %q", agg.QualityStatus, stats.QualityOK) } if agg.QualityReason != "current_day_first_sample" { t.Fatalf("quality reason = %q", agg.QualityReason) } if agg.FirstKM != 4123.9 || agg.LatestKM != 4129.9 { t.Fatalf("km range = %v -> %v", agg.FirstKM, agg.LatestKM) } if agg.FirstEventTime != time.Date(2026, 7, 8, 12, 0, 0, 0, loc) { t.Fatalf("first event time = %v", agg.FirstEventTime) } } } func TestLoadConfigDefaultsBackfillMethodToLastDiff(t *testing.T) { t.Setenv("BACKFILL_METHOD", "") t.Setenv("BACKFILL_DATE_FROM", "2026-07-08") t.Setenv("BACKFILL_DATE_TO", "2026-07-08") t.Setenv("BACKFILL_PROTOCOLS", "JT808") t.Setenv("LOCAL_TZ", "Asia/Shanghai") cfg, err := loadConfig() if err != nil { t.Fatalf("loadConfig() error = %v", err) } if cfg.Method != "last_diff" { t.Fatalf("method = %q, want last_diff", cfg.Method) } }