435 lines
15 KiB
Go
435 lines
15 KiB
Go
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 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%'").
|
|
WillReturnRows(sqlmock.NewRows([]string{
|
|
"vin", "phone", "device_id", "source_endpoint", "FIRST(ts)", "FIRST(parsed_json)", "LAST(ts)", "LAST(parsed_json)", "COUNT(*)",
|
|
}).AddRow(
|
|
"LMRKH9AC6R1004108",
|
|
"",
|
|
"LMRKH9AC6R1004108",
|
|
"mqtt://yutong/ytforward/shln/3",
|
|
firstTS,
|
|
`{"yutong_mqtt.data.total_mileage":"65422000"}`,
|
|
lastTS,
|
|
`{"yutong_mqtt.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.*protocol = 'YUTONG_MQTT'.*parsed_json LIKE '%yutong_mqtt\\.data\\.total_mileage%'").
|
|
WillReturnRows(sqlmock.NewRows([]string{
|
|
"vin", "phone", "device_id", "source_endpoint", "LAST(ts)", "LAST(parsed_json)", "COUNT(*)",
|
|
}).AddRow(
|
|
"LMRKH9AC6R1004108",
|
|
"",
|
|
"LMRKH9AC6R1004108",
|
|
"mqtt://yutong/ytforward/shln/3",
|
|
lastTS,
|
|
`{"yutong_mqtt.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 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)
|
|
}
|
|
}
|