package main import ( "context" "os" "path/filepath" "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 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 TestChooseTrustedSourceAcceptsSmallNegativeMileageJitter(t *testing.T) { previous := []dailySourceLast{{ VIN: "LB9A32A28R0LS1574", SourceKey: normalizedSourceKey("JT808", "64115156034", "", "115.159.85.149:53330"), Phone: "64115156034", SourceEndpoint: "115.159.85.149:53330", TotalKM: 15355.4, }} current := []dailySourceLast{{ VIN: "LB9A32A28R0LS1574", SourceKey: normalizedSourceKey("JT808", "64115156034", "", "115.159.85.149:53338"), Phone: "64115156034", SourceEndpoint: "115.159.85.149:53338", TotalKM: 15355.3, }} chosen, ok := chooseTrustedSource(current, previous) if !ok { t.Fatal("chooseTrustedSource() should accept tiny negative mileage jitter") } if chosen.current.SourceKey != current[0].SourceKey { t.Fatalf("chosen source = %q", chosen.current.SourceKey) } } func TestChooseTrustedSourceAcceptsPlausibleMultiDayFallbackDelta(t *testing.T) { loc := time.FixedZone("Asia/Shanghai", 8*3600) sourceKey := normalizedSourceKey("GB32960", "", "", "8.134.95.166:37720") previous := []dailySourceLast{{ VIN: "LNXNEGRR1SR319498", SourceKey: sourceKey, SourceEndpoint: "8.134.95.166:37720", TS: time.Date(2026, 7, 4, 11, 7, 58, 0, loc), TotalKM: 8832.1, }} current := []dailySourceLast{{ VIN: "LNXNEGRR1SR319498", SourceKey: sourceKey, SourceEndpoint: "8.134.95.166:37720", TS: time.Date(2026, 7, 12, 2, 52, 47, 0, loc), TotalKM: 16665.6, }} chosen, ok := chooseTrustedSource(current, previous) if !ok { t.Fatal("chooseTrustedSource() should accept delta within the historical baseline window") } if delta := chosen.current.TotalKM - chosen.previous.TotalKM; delta < 7833.4 || delta > 7833.6 { t.Fatalf("delta = %v, want historical gap delta", 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 != stats.QualityReasonHistorical { t.Fatalf("quality = %s/%s", agg.QualityStatus, agg.QualityReason) } if agg.Count != 15 { t.Fatalf("sample count = %d, want 15", agg.Count) } } func TestAggregateFromDailySourceRejectsHistoricalBaselineJump(t *testing.T) { loc := time.FixedZone("Asia/Shanghai", 8*3600) current := dailySourceLast{ VIN: "LNXNEGRR6SR319464", SourceKey: normalizedSourceKey("GB32960", "", "", "8.134.95.166:49206"), SourceEndpoint: "8.134.95.166:49206", FirstTS: time.Date(2026, 7, 12, 8, 5, 42, 0, loc), TS: time.Date(2026, 7, 12, 10, 44, 56, 0, loc), FirstTotalKM: 28004.2, TotalKM: 40009.7, RawSampleCount: 1938, } previous := dailySourceLast{ VIN: current.VIN, SourceKey: current.SourceKey, SourceEndpoint: current.SourceEndpoint, TS: time.Date(2026, 7, 3, 19, 0, 39, 0, loc), TotalKM: 10009.7, } agg := aggregateFromDailySource("2026-07-12", envelope.ProtocolGB32960, current, previous, true) if agg.FirstKM != previous.TotalKM || agg.LatestKM != current.TotalKM { t.Fatalf("km range = %v -> %v", agg.FirstKM, agg.LatestKM) } if !agg.FirstEventTime.Equal(previous.TS) || !agg.LatestEventTime.Equal(current.TS) { t.Fatalf("event range = %v -> %v", agg.FirstEventTime, agg.LatestEventTime) } if agg.QualityStatus != stats.QualityInvalidDelta || agg.QualityReason != "outside_daily_range" { t.Fatalf("quality = %s/%s", agg.QualityStatus, agg.QualityReason) } } func TestAggregateFromDailySourceUsesCurrentDayFirstWhenHistoryIsEmpty(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 != stats.QualityReasonCurrentDayFirst { t.Fatalf("quality = %s/%s", agg.QualityStatus, agg.QualityReason) } } func TestBuildLastDiffAggregatesCarriesNearestHistoryAcrossEmptyDays(t *testing.T) { tdDB, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer tdDB.Close() mock.MatchExpectationsInOrder(true) loc := time.FixedZone("Asia/Shanghai", 8*3600) vin := "LMRKH9AC2R1004087" endpoint := "mqtt://yutong/ytforward/shln/3" dayOneTS := time.Date(2026, 7, 9, 23, 50, 0, 0, loc) dayThreeTS := time.Date(2026, 7, 11, 17, 30, 0, 0, loc) currentRows := func() *sqlmock.Rows { return sqlmock.NewRows([]string{ "vin", "phone", "device_id", "source_endpoint", "FIRST(event_time)", "FIRST(parsed_json)", "FIRST(raw_text)", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)", }) } previousRows := func() *sqlmock.Rows { return sqlmock.NewRows([]string{ "vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)", }) } mock.ExpectQuery("(?s)SELECT vin.*LAST\\(event_time\\).*event_time < '2026-07-09 00:00:00'.*protocol = 'YUTONG_MQTT'"). WillReturnRows(previousRows()) mock.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-07-09 00:00:00'.*protocol = 'YUTONG_MQTT'"). WillReturnRows(currentRows().AddRow(vin, "", vin, endpoint, dayOneTS, `{"data":{"TOTAL_MILEAGE":100000}}`, "", dayOneTS, `{"data":{"TOTAL_MILEAGE":100000}}`, "", int64(4))) mock.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-07-10 00:00:00'.*protocol = 'YUTONG_MQTT'"). WillReturnRows(currentRows()) mock.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-07-11 00:00:00'.*protocol = 'YUTONG_MQTT'"). WillReturnRows(currentRows().AddRow(vin, "", vin, endpoint, dayThreeTS, `{"data":{"TOTAL_MILEAGE":120000}}`, "", dayThreeTS, `{"data":{"TOTAL_MILEAGE":120000}}`, "", int64(6))) aggregates, err := buildLastDiffAggregates(context.Background(), nil, tdDB, config{ TDengineDatabase: "lingniu_vehicle_ts", DateFrom: "2026-07-09", DateTo: "2026-07-11", Protocols: []envelope.Protocol{envelope.ProtocolYutongMQTT}, Location: loc, }) if err != nil { t.Fatalf("buildLastDiffAggregates() error = %v", err) } sourceKey := stats.SourceKeyForSource(envelope.ProtocolYutongMQTT, "", vin, "mqtt", "PLATFORM", "yutong") agg := aggregates[vin+"|2026-07-11|YUTONG_MQTT|"+sourceKey] if agg == nil { t.Fatalf("missing day-three aggregate; keys=%v", aggregateKeys(aggregates)) } if agg.FirstKM != 100 || agg.LatestKM != 120 { t.Fatalf("km range = %v -> %v, want 100 -> 120", agg.FirstKM, agg.LatestKM) } if !agg.FirstEventTime.Equal(dayOneTS) || agg.QualityReason != stats.QualityReasonHistorical { t.Fatalf("baseline = %v reason=%q, want day-one historical baseline", agg.FirstEventTime, agg.QualityReason) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func aggregateKeys(aggregates map[string]*metricAgg) []string { keys := make([]string, 0, len(aggregates)) for key := range aggregates { keys = append(keys, key) } return keys } func TestQueryRealtimeLocationLastRowsBuildsYutongSourceFromPeer(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() loc := time.FixedZone("Asia/Shanghai", 8*3600) eventTime := time.Date(2026, 7, 12, 5, 54, 50, 0, loc) mock.ExpectQuery("(?s)FROM vehicle_realtime_location l.*LEFT JOIN vehicle_realtime_snapshot s"). WithArgs("YUTONG_MQTT", "2026-07-12", "2026-07-12"). WillReturnRows(sqlmock.NewRows([]string{"vin", "peer", "total_mileage_event_time", "total_mileage_km"}). AddRow("LMRKH9AC0R1004086", "mqtt://yutong/ytforward/shln/4", eventTime, 11578.0)) rows, err := queryRealtimeLocationLastRows(context.Background(), db, config{Location: loc}, envelope.ProtocolYutongMQTT, "2026-07-12") if err != nil { t.Fatalf("queryRealtimeLocationLastRows() error = %v", err) } sourceRows := rows["LMRKH9AC0R1004086"] if len(sourceRows) != 1 { t.Fatalf("rows = %d, want 1", len(sourceRows)) } row := sourceRows[0] wantSourceKey := stats.SourceKeyForSource(envelope.ProtocolYutongMQTT, "", "LMRKH9AC0R1004086", "mqtt", "PLATFORM", "yutong") if row.SourceKey != wantSourceKey { t.Fatalf("source key = %q", row.SourceKey) } if row.SourceCode != "yutong" || row.PlatformName != "宇通" || row.SourceKind != "PLATFORM" { t.Fatalf("source metadata = code:%q platform:%q kind:%q", row.SourceCode, row.PlatformName, row.SourceKind) } if row.TotalKM != 11578 || row.DeviceID != "LMRKH9AC0R1004086" || row.RawSampleCount != 1 { t.Fatalf("unexpected realtime fallback row: %#v", row) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestQueryRealtimeLocationLastRowsCanCarryStationaryYutongMileage(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() loc := time.FixedZone("Asia/Shanghai", 8*3600) currentEventTime := time.Date(2026, 7, 19, 6, 5, 42, 0, loc) mock.ExpectQuery("(?s)SELECT.*l\\.event_time.*l\\.total_mileage_event_time < \\?.*COALESCE\\(l\\.speed_kmh, 0\\) = 0"). WithArgs("YUTONG_MQTT", "2026-07-19", "2026-07-19", "2026-07-19"). WillReturnRows(sqlmock.NewRows([]string{"vin", "peer", "event_time", "total_mileage_km"}). AddRow("LMRKH9AC9R1004099", "mqtt://yutong/ytforward/shln/2", currentEventTime, 24245.0)) rows, err := queryRealtimeLocationLastRows(context.Background(), db, config{ Location: loc, StationaryCarry: true, }, envelope.ProtocolYutongMQTT, "2026-07-19") if err != nil { t.Fatalf("queryRealtimeLocationLastRows() error = %v", err) } sourceRows := rows["LMRKH9AC9R1004099"] if len(sourceRows) != 1 { t.Fatalf("rows = %d, want 1", len(sourceRows)) } if !sourceRows[0].TS.Equal(currentEventTime) || sourceRows[0].TotalKM != 24245 { t.Fatalf("stationary carry row = %#v", sourceRows[0]) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestAddRealtimeLocationFallbackStationaryCarryUsesTrustedHistoricalBaseline(t *testing.T) { mysqlDB, mysqlMock, err := sqlmock.New() if err != nil { t.Fatalf("mysql sqlmock.New() error = %v", err) } defer mysqlDB.Close() tdDB, tdMock, err := sqlmock.New() if err != nil { t.Fatalf("td sqlmock.New() error = %v", err) } defer tdDB.Close() loc := time.FixedZone("Asia/Shanghai", 8*3600) vin := "LMRKH9AC5R1004133" currentTS := time.Date(2026, 7, 19, 10, 20, 20, 0, loc) previousTS := time.Date(2026, 7, 17, 8, 49, 55, 0, loc) sourceKey := stats.SourceKeyForSource(envelope.ProtocolYutongMQTT, "", vin, "mqtt", "PLATFORM", "yutong") mysqlMock.ExpectQuery("(?s)FROM vehicle_realtime_location l.*l\\.event_time.*l\\.total_mileage_event_time < \\?"). WithArgs("YUTONG_MQTT", "2026-07-19", "2026-07-19", "2026-07-19"). WillReturnRows(sqlmock.NewRows([]string{"vin", "peer", "event_time", "total_mileage_km"}). AddRow(vin, "mqtt://yutong/ytforward/shln/4", currentTS, 55451.0)) mysqlMock.ExpectQuery("(?s)SELECT latest_total_mileage_km, latest_event_time.*FROM vehicle_daily_mileage_source"). WithArgs(vin, "2026-07-19", "YUTONG_MQTT", sourceKey). WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}).AddRow(55466.0, previousTS)) aggregates := map[string]*metricAgg{} added, err := addRealtimeLocationFallbackAggregates(context.Background(), mysqlDB, tdDB, config{ TDengineDatabase: "lingniu_vehicle_ts", DateFrom: "2026-07-19", DateTo: "2026-07-19", Protocols: []envelope.Protocol{envelope.ProtocolYutongMQTT}, Location: loc, StationaryCarry: true, }, aggregates) if err != nil { t.Fatalf("addRealtimeLocationFallbackAggregates() error = %v", err) } if added != 1 || len(aggregates) != 1 { t.Fatalf("added=%d aggregates=%d", added, len(aggregates)) } for _, agg := range aggregates { if agg.FirstKM != 55466 || agg.LatestKM != 55466 { t.Fatalf("stationary km range = %v -> %v", agg.FirstKM, agg.LatestKM) } if agg.QualityStatus != stats.QualityOK || agg.QualityReason != stats.QualityReasonStationaryCarry { t.Fatalf("quality = %s/%s", agg.QualityStatus, agg.QualityReason) } } if err := mysqlMock.ExpectationsWereMet(); err != nil { t.Fatalf("mysql sql expectations: %v", err) } if err := tdMock.ExpectationsWereMet(); err != nil { t.Fatalf("td sql expectations: %v", err) } } func TestAddRealtimeLocationFallbackAggregatesUsesHistoricalBaseline(t *testing.T) { mysqlDB, mysqlMock, err := sqlmock.New() if err != nil { t.Fatalf("mysql sqlmock.New() error = %v", err) } defer mysqlDB.Close() tdDB, tdMock, err := sqlmock.New() if err != nil { t.Fatalf("td sqlmock.New() error = %v", err) } defer tdDB.Close() loc := time.FixedZone("Asia/Shanghai", 8*3600) currentTS := time.Date(2026, 7, 12, 5, 54, 50, 0, loc) previousTS := time.Date(2026, 7, 11, 23, 58, 0, 0, loc) mysqlMock.ExpectQuery("(?s)FROM vehicle_realtime_location l.*l.protocol = \\?"). WithArgs("YUTONG_MQTT", "2026-07-12", "2026-07-12"). WillReturnRows(sqlmock.NewRows([]string{"vin", "peer", "total_mileage_event_time", "total_mileage_km"}). AddRow("LMRKH9AC0R1004086", "mqtt://yutong/ytforward/shln/4", currentTS, 11578.0)) sourceKey := stats.SourceKeyForSource(envelope.ProtocolYutongMQTT, "", "LMRKH9AC0R1004086", "mqtt", "PLATFORM", "yutong") mysqlMock.ExpectQuery("(?s)SELECT latest_total_mileage_km, latest_event_time.*FROM vehicle_daily_mileage_source"). WithArgs("LMRKH9AC0R1004086", "2026-07-12", "YUTONG_MQTT", sourceKey). WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}).AddRow(11500.0, previousTS)) aggregates := map[string]*metricAgg{} added, err := addRealtimeLocationFallbackAggregates(context.Background(), mysqlDB, tdDB, config{ TDengineDatabase: "lingniu_vehicle_ts", DateFrom: "2026-07-12", DateTo: "2026-07-12", Protocols: []envelope.Protocol{envelope.ProtocolYutongMQTT}, Location: loc, }, aggregates) if err != nil { t.Fatalf("addRealtimeLocationFallbackAggregates() error = %v", err) } if added != 1 || len(aggregates) != 1 { t.Fatalf("added=%d aggregates=%d", added, len(aggregates)) } for _, agg := range aggregates { if agg.FirstKM != 11500 || agg.LatestKM != 11578 { t.Fatalf("km range = %v -> %v", agg.FirstKM, agg.LatestKM) } if agg.SourceKey != sourceKey { t.Fatalf("source key = %q", agg.SourceKey) } if agg.SourceCode != "yutong" || agg.PlatformName != "宇通" || agg.SourceKind != "PLATFORM" { t.Fatalf("source metadata = code:%q platform:%q kind:%q", agg.SourceCode, agg.PlatformName, agg.SourceKind) } if agg.QualityReason != "realtime_location_fallback_historical_baseline" { t.Fatalf("quality reason = %q", agg.QualityReason) } } if err := mysqlMock.ExpectationsWereMet(); err != nil { t.Fatalf("mysql sql expectations: %v", err) } if err := tdMock.ExpectationsWereMet(); err != nil { t.Fatalf("td sql expectations: %v", err) } } func TestAddRealtimeLocationFallbackReusesAggregateHistoryAcrossEmptyDays(t *testing.T) { mysqlDB, mysqlMock, err := sqlmock.New() if err != nil { t.Fatalf("mysql sqlmock.New() error = %v", err) } defer mysqlDB.Close() tdDB, tdMock, err := sqlmock.New() if err != nil { t.Fatalf("td sqlmock.New() error = %v", err) } defer tdDB.Close() loc := time.FixedZone("Asia/Shanghai", 8*3600) vin := "LMRKH9AC2R1004087" endpoint := "mqtt://yutong/ytforward/shln/3" sourceKey := stats.SourceKeyForSource(envelope.ProtocolYutongMQTT, "", vin, "mqtt", "PLATFORM", "yutong") dayOneTS := time.Date(2026, 7, 9, 23, 50, 0, 0, loc) dayThreeTS := time.Date(2026, 7, 11, 17, 30, 0, 0, loc) for _, date := range []string{"2026-07-09", "2026-07-10"} { mysqlMock.ExpectQuery("(?s)FROM vehicle_realtime_location l.*l.protocol = \\?"). WithArgs("YUTONG_MQTT", date, date). WillReturnRows(sqlmock.NewRows([]string{"vin", "peer", "total_mileage_event_time", "total_mileage_km"})) } mysqlMock.ExpectQuery("(?s)FROM vehicle_realtime_location l.*l.protocol = \\?"). WithArgs("YUTONG_MQTT", "2026-07-11", "2026-07-11"). WillReturnRows(sqlmock.NewRows([]string{"vin", "peer", "total_mileage_event_time", "total_mileage_km"}). AddRow(vin, endpoint, dayThreeTS, 120.0)) aggregates := map[string]*metricAgg{ vin + "|2026-07-09|YUTONG_MQTT|" + sourceKey: { VIN: vin, Date: "2026-07-09", Protocol: envelope.ProtocolYutongMQTT, LatestKM: 100, Count: 4, SourceKey: sourceKey, DeviceID: vin, SourceEndpoint: endpoint, SourceCode: "yutong", PlatformName: "宇通", SourceKind: "PLATFORM", LatestEventTime: dayOneTS, }, } added, err := addRealtimeLocationFallbackAggregates(context.Background(), mysqlDB, tdDB, config{ TDengineDatabase: "lingniu_vehicle_ts", DateFrom: "2026-07-09", DateTo: "2026-07-11", Protocols: []envelope.Protocol{envelope.ProtocolYutongMQTT}, Location: loc, }, aggregates) if err != nil { t.Fatalf("addRealtimeLocationFallbackAggregates() error = %v", err) } if added != 1 || len(aggregates) != 2 { t.Fatalf("added=%d aggregates=%d, want 1 and 2", added, len(aggregates)) } agg := aggregates[vin+"|2026-07-11|YUTONG_MQTT|"+sourceKey] if agg == nil { t.Fatal("missing realtime-location fallback aggregate") } if agg.FirstKM != 100 || agg.LatestKM != 120 || !agg.FirstEventTime.Equal(dayOneTS) { t.Fatalf("fallback range = %v@%v -> %v, want 100@day-one -> 120", agg.FirstKM, agg.FirstEventTime, agg.LatestKM) } if agg.QualityReason != "realtime_location_fallback_historical_baseline" { t.Fatalf("quality reason = %q", agg.QualityReason) } if err := mysqlMock.ExpectationsWereMet(); err != nil { t.Fatalf("mysql sql expectations: %v", err) } if err := tdMock.ExpectationsWereMet(); err != nil { t.Fatalf("td sql expectations: %v", err) } } func TestQueryDailyLastSourceRowsFiltersYutongToMileageFrames(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() loc := time.FixedZone("Asia/Shanghai", 8*3600) firstTS := time.Date(2026, 7, 8, 8, 0, 0, 0, loc) lastTS := time.Date(2026, 7, 8, 18, 0, 0, 0, loc) mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*protocol = 'YUTONG_MQTT'.*parsed_json LIKE '%yutong_mqtt\\.data\\.total_mileage%'.*parsed_json LIKE '%TOTAL_MILEAGE%'"). WillReturnRows(sqlmock.NewRows([]string{ "vin", "phone", "device_id", "source_endpoint", "FIRST(event_time)", "FIRST(parsed_json)", "FIRST(raw_text)", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)", }).AddRow( "LMRKH9AC6R1004108", "", "LMRKH9AC6R1004108", "mqtt://yutong/ytforward/shln/3", firstTS, `{"yutong_mqtt.data.latitude":"30.0"}`, `{"data":{"TOTAL_MILEAGE":65422000}}`, lastTS, `{"yutong_mqtt.data.latitude":"30.1"}`, `{"data":{"TOTAL_MILEAGE":65423000}}`, int64(42), )) rows, err := queryDailyLastSourceRows(context.Background(), db, config{ TDengineDatabase: "lingniu_vehicle_ts", Location: loc, }, envelope.ProtocolYutongMQTT, "2026-07-08") if err != nil { t.Fatalf("queryDailyLastSourceRows() error = %v", err) } sourceRows := rows["LMRKH9AC6R1004108"] if len(sourceRows) != 1 { t.Fatalf("rows = %d, want 1", len(sourceRows)) } if sourceRows[0].FirstTotalKM != 65422 || sourceRows[0].TotalKM != 65423 { t.Fatalf("km range = %v -> %v", sourceRows[0].FirstTotalKM, sourceRows[0].TotalKM) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestQueryPreviousLastSourceRowsFiltersYutongToMileageFrames(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() loc := time.FixedZone("Asia/Shanghai", 8*3600) lastTS := time.Date(2026, 7, 7, 23, 58, 0, 0, loc) mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*event_time < '2026-07-08 00:00:00'.*protocol = 'YUTONG_MQTT'.*parsed_json LIKE '%yutong_mqtt\\.data\\.total_mileage%'.*parsed_json LIKE '%TOTAL_MILEAGE%'"). WillReturnRows(sqlmock.NewRows([]string{ "vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)", }).AddRow( "LMRKH9AC6R1004108", "", "LMRKH9AC6R1004108", "mqtt://yutong/ytforward/shln/3", lastTS, `{"yutong_mqtt.data.latitude":"30.0"}`, `{"data":{"TOTAL_MILEAGE":65377000}}`, int64(31), )) rows, err := queryPreviousLastSourceRows(context.Background(), db, config{ TDengineDatabase: "lingniu_vehicle_ts", Location: loc, }, envelope.ProtocolYutongMQTT, "2026-07-08") if err != nil { t.Fatalf("queryPreviousLastSourceRows() error = %v", err) } sourceRows := rows["LMRKH9AC6R1004108"] if len(sourceRows) != 1 { t.Fatalf("rows = %d, want 1", len(sourceRows)) } if sourceRows[0].TotalKM != 65377 { t.Fatalf("previous total = %v, want 65377", sourceRows[0].TotalKM) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestBackfillBeforePredicatesBoundsHistoryScan(t *testing.T) { where := strings.Join(backfillBeforePredicates(config{BaselineLookback: 7}, "2026-07-08"), " AND ") if !strings.Contains(where, "event_time < '2026-07-08 00:00:00'") { t.Fatalf("pre-window predicate missing exclusive upper bound: %s", where) } for _, predicate := range []string{ "event_time >= '2026-07-01 00:00:00'", "ts >= '2026-06-30 00:00:00'", "ts < '2026-07-09 00:00:00'", } { if !strings.Contains(where, predicate) { t.Fatalf("pre-window predicate missing %q: %s", predicate, where) } } } func TestQueryPreviousLastSourceRowsNormalizesScannedTimestampToBusinessTimezone(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() loc := time.FixedZone("Asia/Shanghai", 8*3600) utcInstant := time.Date(2026, 7, 12, 15, 59, 59, 0, time.UTC) mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*protocol = 'YUTONG_MQTT'"). WillReturnRows(sqlmock.NewRows([]string{ "vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)", }).AddRow( "LMRKH9AC7R1004098", "", "LMRKH9AC7R1004098", "mqtt://yutong/ytforward/shln/4", utcInstant, `{"yutong_mqtt.data.total_mileage":"41249000"}`, "", int64(1), )) rows, err := queryPreviousLastSourceRows(context.Background(), db, config{ TDengineDatabase: "lingniu_vehicle_ts", Location: loc, }, envelope.ProtocolYutongMQTT, "2026-07-13") if err != nil { t.Fatalf("queryPreviousLastSourceRows() error = %v", err) } sourceRows := rows["LMRKH9AC7R1004098"] if len(sourceRows) != 1 { t.Fatalf("rows = %d, want 1", len(sourceRows)) } want := time.Date(2026, 7, 12, 23, 59, 59, 0, loc) if !sourceRows[0].TS.Equal(want) || sourceRows[0].TS.Location().String() != loc.String() { t.Fatalf("previous event time = %s (%s), want %s (%s)", sourceRows[0].TS, sourceRows[0].TS.Location(), want, want.Location()) } } func TestFieldsForStatsExtractsRawYutongTotalMileage(t *testing.T) { fields := fieldsForStats(envelope.ProtocolYutongMQTT, "LMRKH9AC6R1004108", `{ "data": { "TOTAL_MILEAGE": 65423000, "METER_SPEED": 12.3 }, "root": { "device": "LMRKH9AC6R1004108" } }`) if got := fields["yutong_mqtt.data.total_mileage"]; got == nil { t.Fatalf("fields missing raw yutong total mileage: %#v", fields) } env := envelope.FrameEnvelope{ Protocol: envelope.ProtocolYutongMQTT, VIN: "LMRKH9AC6R1004108", EventTimeMS: time.Date(2026, 7, 8, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli(), ReceivedAtMS: time.Date(2026, 7, 8, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli(), Fields: fields, } samples, err := stats.SamplesFromEnvelope(env, time.FixedZone("Asia/Shanghai", 8*3600)) if err != nil { t.Fatalf("SamplesFromEnvelope() error = %v", err) } if len(samples) != 1 { t.Fatalf("samples = %d, want 1", len(samples)) } if samples[0].TotalMileageKM != 65423 { t.Fatalf("total mileage km = %v, want 65423", samples[0].TotalMileageKM) } } 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(), sqlmock.AnyArg(), 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.ExpectBegin() mock.ExpectExec(`INSERT INTO vehicle_daily_mileage`). WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", int64(2500)). WillReturnResult(sqlmock.NewResult(1, 0)) mock.ExpectExec(`UPDATE vehicle_daily_mileage_source s`). WithArgs( "LA9GG64L7PBAF4001", "2026-07-08", "JT808", int64(2500), "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)) mock.ExpectCommit() 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_DAYS_BACK", "") t.Setenv("BACKFILL_WINDOW_DAYS", "") 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) } if cfg.EventTimeFullScan { t.Fatal("event-time full scan should be opt-in") } } func TestBackfillTimePredicatesUsePrimaryTimeForCoarseScanAndEventTimeForBusinessDay(t *testing.T) { where := strings.Join(backfillTimePredicates(config{}, "2026-07-13", "2026-07-14"), " AND ") for _, want := range []string{ "ts >= '2026-07-12 00:00:00'", "ts < '2026-07-15 00:00:00'", "event_time >= '2026-07-13 00:00:00'", "event_time < '2026-07-14 00:00:00'", } { if !strings.Contains(where, want) { t.Fatalf("time predicates missing %q: %s", want, where) } } } func TestBackfillTimePredicatesAllowExplicitDeepEventTimeScan(t *testing.T) { where := strings.Join(backfillTimePredicates(config{EventTimeFullScan: true}, "2026-07-13", "2026-07-14"), " AND ") if strings.Contains(where, "ts >=") || strings.Contains(where, "ts <") { t.Fatalf("deep event-time scan must not apply storage-time bounds: %s", where) } for _, want := range []string{ "event_time >= '2026-07-13 00:00:00'", "event_time < '2026-07-14 00:00:00'", } { if !strings.Contains(where, want) { t.Fatalf("deep event-time scan missing %q: %s", want, where) } } } func TestResolveBackfillDateRangeDefaultsToToday(t *testing.T) { t.Setenv("BACKFILL_DATE_FROM", "") t.Setenv("BACKFILL_DATE_TO", "") t.Setenv("BACKFILL_DAYS_BACK", "") t.Setenv("BACKFILL_WINDOW_DAYS", "") loc := time.FixedZone("Asia/Shanghai", 8*3600) dateFrom, dateTo := resolveBackfillDateRange(time.Date(2026, 7, 12, 10, 0, 0, 0, loc), loc) if dateFrom != "2026-07-12" || dateTo != "2026-07-12" { t.Fatalf("date range = %s -> %s", dateFrom, dateTo) } } func TestAddJT808GPSCoordinateFallbackAggregatesOnlyMissingOdometerVehicles(t *testing.T) { mysqlDB, mysqlMock, err := sqlmock.New() if err != nil { t.Fatalf("mysql sqlmock.New() error = %v", err) } defer mysqlDB.Close() tdDB, tdMock, err := sqlmock.New() if err != nil { t.Fatalf("tdengine sqlmock.New() error = %v", err) } defer tdDB.Close() mysqlMock.ExpectQuery("SELECT DISTINCT vin, DATE_FORMAT"). WithArgs("2026-07-19", "2026-07-19", stats.QualityOK, stats.QualityReasonGPSCoordinate). WillReturnRows(sqlmock.NewRows([]string{"vin", "stat_date"}). AddRow("VIN-WITH-ODOMETER", "2026-07-19")) loc := time.FixedZone("Asia/Shanghai", 8*3600) start := time.Date(2026, 7, 19, 8, 0, 0, 0, loc) tdMock.ExpectQuery("SELECT vin, ts, longitude, latitude"). WillReturnRows(sqlmock.NewRows([]string{"vin", "ts", "longitude", "latitude"}). AddRow("VIN-GPS-FALLBACK", start, 121.4737, 31.2304). AddRow("VIN-WITH-ODOMETER", start, 121.4737, 31.2304). AddRow("VIN-GPS-FALLBACK", start.Add(5*time.Minute), 121.4837, 31.2304). AddRow("VIN-WITH-ODOMETER", start.Add(5*time.Minute), 121.4837, 31.2304)) aggregates := map[string]*metricAgg{} added, err := addJT808GPSCoordinateFallbackAggregates(context.Background(), mysqlDB, tdDB, config{ DateFrom: "2026-07-19", DateTo: "2026-07-19", Protocols: []envelope.Protocol{envelope.ProtocolJT808}, JT808GPSFallback: true, Location: loc, TDengineDatabase: "vehicle_ts", }, aggregates) if err != nil { t.Fatalf("addJT808GPSCoordinateFallbackAggregates() error = %v", err) } if added != 1 || len(aggregates) != 1 { t.Fatalf("added=%d aggregates=%d", added, len(aggregates)) } for _, aggregate := range aggregates { if aggregate.VIN != "VIN-GPS-FALLBACK" || aggregate.QualityReason != stats.QualityReasonGPSCoordinate { t.Fatalf("aggregate = %+v", aggregate) } if aggregate.LatestKM < 0.9 || aggregate.LatestKM > 1.1 || aggregate.Count != 2 { t.Fatalf("GPS aggregate distance/count = %.3f/%d", aggregate.LatestKM, aggregate.Count) } } if err := mysqlMock.ExpectationsWereMet(); err != nil { t.Fatalf("mysql expectations: %v", err) } if err := tdMock.ExpectationsWereMet(); err != nil { t.Fatalf("tdengine expectations: %v", err) } } func TestResolveBackfillDateRangeUsesRelativeWindow(t *testing.T) { t.Setenv("BACKFILL_DATE_FROM", "") t.Setenv("BACKFILL_DATE_TO", "") t.Setenv("BACKFILL_DAYS_BACK", "1") t.Setenv("BACKFILL_WINDOW_DAYS", "3") loc := time.FixedZone("Asia/Shanghai", 8*3600) dateFrom, dateTo := resolveBackfillDateRange(time.Date(2026, 7, 12, 10, 0, 0, 0, loc), loc) if dateFrom != "2026-07-09" || dateTo != "2026-07-11" { t.Fatalf("date range = %s -> %s", dateFrom, dateTo) } } func TestResolveBackfillDateRangePrefersExplicitDates(t *testing.T) { t.Setenv("BACKFILL_DATE_FROM", "2026-07-01") t.Setenv("BACKFILL_DATE_TO", "2026-07-03") t.Setenv("BACKFILL_DAYS_BACK", "1") t.Setenv("BACKFILL_WINDOW_DAYS", "3") loc := time.FixedZone("Asia/Shanghai", 8*3600) dateFrom, dateTo := resolveBackfillDateRange(time.Date(2026, 7, 12, 10, 0, 0, 0, loc), loc) if dateFrom != "2026-07-01" || dateTo != "2026-07-03" { t.Fatalf("date range = %s -> %s", dateFrom, dateTo) } } func TestBackfillEnvFilesPrefersExplicitList(t *testing.T) { got := backfillEnvFiles("/tmp/a.env,/tmp/b.env", "/tmp/legacy.env", []string{"/tmp/default.env"}) if got != "/tmp/a.env,/tmp/b.env" { t.Fatalf("env files = %q", got) } } func TestBackfillEnvFilesUsesExistingDefaults(t *testing.T) { dir := t.TempDir() missing := filepath.Join(dir, "missing.env") base := filepath.Join(dir, "base.env") stat := filepath.Join(dir, "stat-writer.env") if err := os.WriteFile(base, []byte("A=1\n"), 0600); err != nil { t.Fatalf("write base env: %v", err) } if err := os.WriteFile(stat, []byte("B=2\n"), 0600); err != nil { t.Fatalf("write stat env: %v", err) } got := backfillEnvFiles("", "", []string{missing, base, stat}) want := base + "," + stat if got != want { t.Fatalf("env files = %q, want %q", got, want) } }