package main import ( "context" "errors" "fmt" "math" "strings" "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 TestBackfillPlatformBoundarySurvivesRawGapAndRepeatedRuns(t *testing.T) { for _, tc := range []struct { vin, terminal, ip, code string old, previous, end float64 }{ {"LMRKH9AC9R1004099", "64341233471", "115.159.85.149", "g7s", 62749.7, 64432.7, 65136}, {"LMRKH9AC7R1004120", "64341233712", "115.159.85.149", "g7s", 51126.2, 52704.4, 53614.1}, {"LMRKH9AC3R1004129", "64341233795", "122.152.221.156", "dongfang_beidou", 57481.7, 59072.7, 59434.6}, } { t.Run(tc.vin, func(t *testing.T) { mysqlDB, mysql, err := sqlmock.New() if err != nil { t.Fatal(err) } defer mysqlDB.Close() tdDB, td, err := sqlmock.New() if err != nil { t.Fatal(err) } defer tdDB.Close() mysql.ExpectQuery("SELECT protocol, source_ip").WillReturnRows(sqlmock.NewRows([]string{"protocol", "ip", "code", "name", "kind"}). AddRow("JT808", tc.ip, tc.code, tc.code, "PLATFORM")) catalog, err := loadBackfillSourceCatalog(context.Background(), mysqlDB) if err != nil { t.Fatal(err) } loc := time.FixedZone("Asia/Shanghai", 8*3600) oldTS := time.Date(2026, 10, 16, 15, 26, 0, 0, loc) previousTS := time.Date(2026, 10, 18, 23, 59, 0, 0, loc) firstTS := time.Date(2026, 10, 19, 11, 0, 0, 0, loc) endTS := time.Date(2026, 10, 19, 23, 59, 0, 0, loc) parsed := func(km float64) string { return fmt.Sprintf(`{"jt808.location.total_mileage_km":%f}`, km) } key := "JT808:" + tc.terminal + "@PLATFORM:" + tc.code cfg := config{DateFrom: "2026-10-19", DateTo: "2026-10-19", Protocols: []envelope.Protocol{envelope.ProtocolJT808}, VINs: []string{tc.vin}, TDengineDatabase: "lingniu_vehicle_ts", Location: loc, SourceCatalog: catalog} // Run twice against the same durable October 18 boundary. A replay must // neither use October 16 RAW nor subtract today's already-written result. for run := 0; run < 2; run++ { td.ExpectQuery("(?s)SELECT vin.*LAST\\(event_time\\).*event_time < '2026-10-19 00:00:00'.*vin IN \\('" + tc.vin + "'\\)"). WillReturnRows(sqlmock.NewRows([]string{"vin", "phone", "device", "endpoint", "last", "parsed", "raw", "count", "MAX_EVENT_MS"}). AddRow(tc.vin, tc.terminal, "", tc.ip+":1000", oldTS, parsed(tc.old), "", 10, oldTS.UnixMilli())) td.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-10-19 00:00:00'.*vin IN \\('" + tc.vin + "'\\)"). WillReturnRows(sqlmock.NewRows([]string{"vin", "phone", "device", "endpoint", "first", "first_parsed", "first_raw", "last", "last_parsed", "last_raw", "count", "MIN_EVENT_MS", "MAX_EVENT_MS"}). AddRow(tc.vin, tc.terminal, "", tc.ip+":2000", firstTS, parsed(tc.previous+1), "", firstTS.Add(time.Hour), parsed(tc.previous+10), "", 100, firstTS.UnixMilli(), firstTS.Add(time.Hour).UnixMilli()). AddRow(tc.vin, tc.terminal, "", tc.ip+":3000", firstTS.Add(2*time.Hour), parsed(tc.previous+11), "", endTS, parsed(tc.end), "", 200, firstTS.Add(2*time.Hour).UnixMilli(), endTS.UnixMilli())) mysql.ExpectQuery("SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source"). WithArgs(tc.vin, "2026-10-19", "JT808", key). WillReturnRows(sqlmock.NewRows([]string{"total", "time"}).AddRow(tc.previous, previousTS)) aggregates, err := buildLastDiffAggregates(context.Background(), mysqlDB, tdDB, cfg) if err != nil { t.Fatal(err) } a := aggregates[tc.vin+"|2026-10-19|JT808|"+key] if len(aggregates) != 1 || a == nil || a.FirstKM != tc.previous || !a.FirstEventTime.Equal(previousTS) || a.Count != 300 { t.Fatalf("run %d: wrong boundary or connection grouping: %+v", run, a) } if math.Abs((a.LatestKM-a.FirstKM)-(tc.end-tc.previous)) > 0.0001 || a.SourceCode != tc.code { t.Fatalf("run %d: recounted historical mileage: %+v", run, a) } } if err := mysql.ExpectationsWereMet(); err != nil { t.Fatal(err) } if err := td.ExpectationsWereMet(); err != nil { t.Fatal(err) } }) } } func TestBackfillSourceCatalogPreservesIndependentAndUnknownCounters(t *testing.T) { cfg := config{SourceCatalog: map[backfillSourceID]stats.SourceIdentity{ {envelope.ProtocolJT808, "1.2.3.4"}: {SourceCode: "registered", SourceKind: "PLATFORM"}, {envelope.ProtocolJT808, "2.3.4.5"}: {SourceKind: "DIRECT"}, }} key := func(protocol envelope.Protocol, terminal, endpoint string) string { code, _, kind := backfillSourceMetadata(cfg, protocol, endpoint) return normalizedSourceKeyForSource(string(protocol), terminal, "", endpoint, kind, code) } for _, tc := range []struct{ terminal, endpoint, want string }{ {"terminal-a", "1.2.3.4:123", "JT808:terminal-a@PLATFORM:registered"}, {"terminal-b", "1.2.3.4:456", "JT808:terminal-b@PLATFORM:registered"}, {"terminal-a", "2.3.4.5:123", "JT808:terminal-a@DIRECT"}, {"terminal-a", "3.4.5.6:123", "JT808:terminal-a@3.4.5.6"}, } { if got := key(envelope.ProtocolJT808, tc.terminal, tc.endpoint); got != tc.want { t.Fatalf("key=%s want=%s", got, tc.want) } } if got := key(envelope.ProtocolGB32960, "terminal-a", "1.2.3.4:123"); strings.Contains(got, "PLATFORM") { t.Fatalf("JT808 metadata leaked into GB32960: %s", got) } } func TestBackfillSourceCatalogFailureIsNotIgnored(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatal(err) } defer db.Close() mock.ExpectQuery("SELECT protocol, source_ip").WillReturnError(errors.New("database unavailable")) if _, err := loadBackfillSourceCatalog(context.Background(), db); err == nil { t.Fatal("missing source catalog must fail before recomputation") } } func TestBackfillVINScopeIncludesCleanupAndRealtimeFallback(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatal(err) } defer db.Close() cfg := config{VINs: []string{"ONLY-THIS-VIN"}, DateFrom: "2026-10-19", DateTo: "2026-10-19", Protocols: []envelope.Protocol{envelope.ProtocolJT808}} mock.ExpectQuery("(?s)FROM vehicle_daily_mileage_source.*AND vin IN \\('ONLY-THIS-VIN'\\).*GROUP BY"). WithArgs(cfg.DateFrom, cfg.DateTo, stats.QualityReasonGPSCoordinate, stats.GPSMinimumDailyDistanceKM, "JT808"). WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "protocol", "count"})) if _, _, err := cleanupSubthresholdGPSMileageCandidates(context.Background(), db, cfg, true); err != nil { t.Fatal(err) } mock.ExpectQuery("(?s)FROM vehicle_realtime_location.*AND l.vin IN \\('ONLY-THIS-VIN'\\)"). WithArgs("YUTONG_MQTT", cfg.DateFrom, cfg.DateFrom). WillReturnRows(sqlmock.NewRows([]string{"vin", "peer", "time", "total"})) if _, err := queryRealtimeLocationLastRows(context.Background(), db, cfg, envelope.ProtocolYutongMQTT, cfg.DateFrom); err != nil { t.Fatal(err) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatal(err) } } func TestBackfillScopedNormalizationDoesNotScanOtherVehicles(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatal(err) } defer db.Close() vin, date := "ONLY-THIS-VIN", "2026-10-19" mock.ExpectBegin() mock.ExpectQuery("(?s)SELECT source_key.*FOR UPDATE").WithArgs(vin, date).WillReturnRows(gpsLockedRows()) mock.ExpectExec("INSERT INTO vehicle_daily_mileage_source").WithArgs(vin, date, "JT808").WillReturnResult(sqlmock.NewResult(0, 0)) mock.ExpectExec("DELETE s").WithArgs(vin, date, "JT808").WillReturnResult(sqlmock.NewResult(0, 0)) mock.ExpectExec("INSERT INTO vehicle_daily_mileage").WithArgs(vin, date, "JT808", int64(2500)).WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectExec("UPDATE vehicle_daily_mileage_source s").WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectExec("DELETE FROM vehicle_daily_mileage").WillReturnResult(sqlmock.NewResult(0, 0)) mock.ExpectCommit() _, err = normalizeHistoricalPlatformSources(context.Background(), db, config{VINs: []string{vin}, DateFrom: date, DateTo: date, Protocols: []envelope.Protocol{envelope.ProtocolJT808}}) if err != nil { t.Fatal(err) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatal(err) } } func TestMergeDailySourceRowsIsIndependentOfConnectionOrder(t *testing.T) { first := dailySourceLast{FirstTS: time.Unix(100, 0), TS: time.Unix(200, 0), FirstTotalKM: 10, TotalKM: 20, RawSampleCount: 4} last := dailySourceLast{FirstTS: time.Unix(300, 0), TS: time.Unix(400, 0), FirstTotalKM: 30, TotalKM: 40, RawSampleCount: 6} for _, merged := range []dailySourceLast{mergeDailySourceRows(first, last), mergeDailySourceRows(last, first)} { if merged.FirstTotalKM != 10 || merged.TotalKM != 40 || merged.RawSampleCount != 10 { t.Fatalf("lost connection boundary: %+v", merged) } } // Scope quoting is shared by SQL paths, including escaped input. if got := backfillVINPredicate(config{VINs: []string{"A'B"}}, "vin"); got != "vin IN ('A''B')" { t.Fatalf("unexpected predicate: %s", got) } }