package stats import ( "context" "database/sql" "database/sql/driver" "errors" "math" "strings" "testing" "time" "github.com/DATA-DOG/go-sqlmock" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" ) func TestSamplesFromEnvelopeDerivesDailyMileageSample(t *testing.T) { loc := time.FixedZone("Asia/Shanghai", 8*3600) env := envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, VIN: "LNBVIN00000000001", EventTimeMS: time.Date(2026, 7, 1, 8, 30, 0, 0, loc).UnixMilli(), Fields: map[string]any{ envelope.FieldTotalMileageKM: 10241.2, }, } samples, err := SamplesFromEnvelope(env, loc) if err != nil { t.Fatalf("SamplesFromEnvelope() error = %v", err) } if len(samples) != 1 { t.Fatalf("sample count = %d", len(samples)) } if samples[0].VIN != "LNBVIN00000000001" { t.Fatalf("vin = %q", samples[0].VIN) } if samples[0].TotalMileageKM != 10241.2 { t.Fatalf("total mileage = %v", samples[0].TotalMileageKM) } if samples[0].StatDate != "2026-07-01" { t.Fatalf("stat date = %q", samples[0].StatDate) } } func TestSamplesFromEnvelopeMapsProtocolMileageFields(t *testing.T) { loc := time.FixedZone("Asia/Shanghai", 8*3600) tests := []struct { name string protocol envelope.Protocol fields map[string]any wantKM float64 }{ { name: "gb32960", protocol: envelope.ProtocolGB32960, fields: map[string]any{"gb32960.vehicle.total_mileage_km": "38587"}, wantKM: 38587, }, { name: "jt808", protocol: envelope.ProtocolJT808, fields: map[string]any{"jt808.location.total_mileage_km": "12432.4"}, wantKM: 12432.4, }, { name: "yutong mqtt meters", protocol: envelope.ProtocolYutongMQTT, fields: map[string]any{"yutong_mqtt.data.total_mileage": "120756000"}, wantKM: 120756, }, { name: "yutong mqtt root meters", protocol: envelope.ProtocolYutongMQTT, fields: map[string]any{"yutong_mqtt.root.data.total_mileage": float64(22858000)}, wantKM: 22858, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { samples, err := SamplesFromEnvelope(envelope.FrameEnvelope{ Protocol: tt.protocol, VIN: "LNBVIN00000000001", EventTimeMS: time.Date(2026, 7, 1, 8, 30, 0, 0, loc).UnixMilli(), Fields: tt.fields, }, loc) if err != nil { t.Fatalf("SamplesFromEnvelope() error = %v", err) } if len(samples) != 1 { t.Fatalf("sample count = %d", len(samples)) } if samples[0].TotalMileageKM != tt.wantKM { t.Fatalf("total mileage = %v, want %v", samples[0].TotalMileageKM, tt.wantKM) } }) } } func TestSamplesFromEnvelopeSkipsMissingVIN(t *testing.T) { loc := time.FixedZone("Asia/Shanghai", 8*3600) samples, err := SamplesFromEnvelope(envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, Phone: "13307811254", EventTimeMS: time.Date(2026, 7, 2, 0, 3, 0, 0, loc).UnixMilli(), Fields: map[string]any{ envelope.FieldTotalMileageKM: 10985.7, }, }, loc) if err != nil { t.Fatalf("SamplesFromEnvelope() error = %v", err) } if len(samples) != 0 { t.Fatalf("expected no samples without VIN, got %#v", samples) } } func TestSamplesFromEnvelopeSkipsMissingVINOrMileage(t *testing.T) { samples, err := SamplesFromEnvelope(envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, Fields: map[string]any{envelope.FieldTotalMileageKM: 1.2}, }, nil) if err != nil { t.Fatalf("SamplesFromEnvelope() error = %v", err) } if len(samples) != 0 { t.Fatalf("expected no samples without VIN, got %#v", samples) } samples, err = SamplesFromEnvelope(envelope.FrameEnvelope{ Protocol: envelope.ProtocolGB32960, VIN: "LNBVIN00000000002", Fields: map[string]any{}, }, nil) if err != nil { t.Fatalf("SamplesFromEnvelope() error = %v", err) } if len(samples) != 0 { t.Fatalf("expected no samples without mileage, got %#v", samples) } } func TestSamplesFromEnvelopeSkipsNonPositiveMileage(t *testing.T) { for _, value := range []any{0, 0.0, -1.0, "0"} { samples, err := SamplesFromEnvelope(envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, VIN: "LNBVIN00000000001", EventTimeMS: time.Date(2026, 7, 1, 9, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli(), Fields: map[string]any{ envelope.FieldTotalMileageKM: value, }, }, nil) if err != nil { t.Fatalf("SamplesFromEnvelope(%#v) error = %v", value, err) } if len(samples) != 0 { t.Fatalf("expected no samples for non-positive mileage %#v, got %#v", value, samples) } } } func TestWriterAppendWritesSourceCandidateAndProjection(t *testing.T) { exec := &recordingExec{} writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600)) event := envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, VIN: "LA9GG64L7PBAF4001", Phone: "13307765812", SourceEndpoint: "115.231.168.135:20215", EventTimeMS: time.Date(2026, 7, 8, 13, 20, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli(), Fields: map[string]any{ "jt808.location.total_mileage_km": 4123.9, }, } if err := writer.Append(context.Background(), event); err != nil { t.Fatalf("Append() error = %v", err) } if len(exec.calls) != 6 { t.Fatalf("exec calls = %d, want source + candidate + clear + project + mark + cleanup", len(exec.calls)) } if !strings.Contains(exec.calls[0].query, "INSERT INTO vehicle_data_source") { t.Fatalf("first call should upsert source: %s", exec.calls[0].query) } if !strings.Contains(exec.calls[1].query, "INSERT INTO vehicle_daily_mileage_source") { t.Fatalf("second call should upsert candidate: %s", exec.calls[1].query) } if !strings.Contains(exec.calls[3].query, "INSERT INTO vehicle_daily_mileage") { t.Fatalf("fourth call should project final: %s", exec.calls[3].query) } } func TestWriterAppendSkipsMileageWithoutSourceIdentity(t *testing.T) { exec := &recordingExec{} writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600)) event := envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, VIN: "LA9GG64L7PBAF4001", Phone: "13307765812", EventTimeMS: time.Date(2026, 7, 8, 13, 20, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli(), Fields: map[string]any{ "jt808.location.total_mileage_km": 4123.9, }, } if err := writer.Append(context.Background(), event); err != nil { t.Fatalf("Append() error = %v", err) } if len(exec.calls) != 0 { t.Fatalf("exec calls = %d, want 0 without source identity", len(exec.calls)) } } func TestWriterAppendTracksSourceWithoutMileageSample(t *testing.T) { exec := &recordingExec{} writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600)) event := envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, Phone: "13307795518", SourceEndpoint: "222.66.200.68:43614", EventTimeMS: time.Date(2026, 7, 8, 16, 30, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli(), Fields: map[string]any{ "jt808.location.speed_kmh": 38.5, }, } if err := writer.Append(context.Background(), event); err != nil { t.Fatalf("Append() error = %v", err) } if len(exec.calls) != 1 { t.Fatalf("exec calls = %d, want source only", len(exec.calls)) } if !strings.Contains(exec.calls[0].query, "INSERT INTO vehicle_data_source") { t.Fatalf("source upsert query missing: %s", exec.calls[0].query) } if got := exec.calls[0].args[1]; got != "222.66.200.68" { t.Fatalf("source ip arg = %#v", got) } } func TestWriterAppendDedupesRealtimeMileagePerSource(t *testing.T) { exec := &recordingExec{} writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600)) base := envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, VIN: "LA9GG64L7PBAF4001", Phone: "13307765812", EventTimeMS: time.Date(2026, 7, 8, 13, 20, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli(), Fields: map[string]any{ "jt808.location.total_mileage_km": 4123.9, }, } sourceA := base sourceA.SourceEndpoint = "115.231.168.135:20215" if err := writer.Append(context.Background(), sourceA); err != nil { t.Fatalf("Append(sourceA) error = %v", err) } sourceB := base sourceB.SourceEndpoint = "115.159.85.149:28316" sourceB.ReceivedAtMS = sourceB.EventTimeMS + 1000 if err := writer.Append(context.Background(), sourceB); err != nil { t.Fatalf("Append(sourceB) error = %v", err) } sourceADuplicate := sourceA sourceADuplicate.ReceivedAtMS = sourceA.EventTimeMS + 2000 if err := writer.Append(context.Background(), sourceADuplicate); err != nil { t.Fatalf("Append(sourceADuplicate) error = %v", err) } if got := countExecQueries(exec.calls, "INSERT INTO vehicle_daily_mileage_source"); got != 2 { t.Fatalf("candidate upserts = %d, want 2", got) } if got := countExecQueries(exec.calls, "INSERT INTO vehicle_data_source"); got != 2 { t.Fatalf("source upserts = %d, want 2", got) } } func TestWriterAppendUsesCurrentSampleWhenNoHistoricalBaseline(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) writer := NewWriter(db, loc) eventTime := time.Date(2026, 7, 8, 13, 20, 0, 0, loc) event := envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, VIN: "LA9GG64L7PBAF4001", Phone: "13307765812", SourceEndpoint: "115.231.168.135:20215", EventTimeMS: eventTime.UnixMilli(), Fields: map[string]any{ "jt808.location.total_mileage_km": 4123.9, }, } 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.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`). WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", "JT808:13307765812@115.231.168.135"). WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"})) mock.ExpectQuery(`SELECT first_total_mileage_km, first_event_time FROM vehicle_daily_mileage_source`). WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", "JT808:13307765812@115.231.168.135"). WillReturnRows(sqlmock.NewRows([]string{"first_total_mileage_km", "first_event_time"})) 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(4123.9), float64(4123.9), float64(0), int64(1), eventTime, eventTime, QualityOK, "current_day_first_sample", ). 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(0, 1)) 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, 1)) mock.ExpectExec(`DELETE FROM vehicle_daily_mileage`). WithArgs( "LA9GG64L7PBAF4001", "2026-07-08", "JT808", "LA9GG64L7PBAF4001", "2026-07-08", "JT808", ). WillReturnResult(sqlmock.NewResult(0, 0)) if err := writer.Append(context.Background(), event); err != nil { t.Fatalf("Append() error = %v", err) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestWriterAppendUsesPreviousSourceBaselineForRealtimeCandidate(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) writer := NewWriter(db, loc) previousTime := time.Date(2026, 7, 7, 23, 58, 0, 0, loc) currentTime := time.Date(2026, 7, 8, 13, 20, 0, 0, loc) event := envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, VIN: "LA9GG64L7PBAF4001", Phone: "13307765812", SourceEndpoint: "115.231.168.135:20215", EventTimeMS: currentTime.UnixMilli(), Fields: map[string]any{ "jt808.location.total_mileage_km": 4123.9, }, } 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.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`). WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", "JT808:13307765812@115.231.168.135"). WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}). AddRow(4100.8, previousTime)) 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.8), float64(4123.9), approxFloat64{want: 23.1, tolerance: 0.000001}, int64(1), previousTime, currentTime, QualityOK, "historical_source_baseline", ). 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(0, 1)) 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, 1)) mock.ExpectExec(`DELETE FROM vehicle_daily_mileage`). WithArgs( "LA9GG64L7PBAF4001", "2026-07-08", "JT808", "LA9GG64L7PBAF4001", "2026-07-08", "JT808", ). WillReturnResult(sqlmock.NewResult(0, 0)) if err := writer.Append(context.Background(), event); err != nil { t.Fatalf("Append() error = %v", err) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestWriterAppendUsesOlderHistoricalSourceBaseline(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) writer := NewWriter(db, loc) historicalTime := time.Date(2026, 7, 4, 23, 58, 0, 0, loc) currentTime := time.Date(2026, 7, 8, 13, 20, 0, 0, loc) event := envelope.FrameEnvelope{ Protocol: envelope.ProtocolYutongMQTT, VIN: "LMRKH9AC2R1004087", DeviceID: "LMRKH9AC2R1004087", SourceEndpoint: "mqtt://yutong/ytforward/shln/3", EventTimeMS: currentTime.UnixMilli(), Fields: map[string]any{ "yutong_mqtt.data.total_mileage": 120788000, }, } mock.ExpectExec(`INSERT INTO vehicle_data_source`). WithArgs("YUTONG_MQTT", "mqtt", "mqtt://yutong/ytforward/shln/3", sqlmock.AnyArg(), sqlmock.AnyArg()). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`). WithArgs("LMRKH9AC2R1004087", "2026-07-08", "YUTONG_MQTT", "YUTONG_MQTT:LMRKH9AC2R1004087@mqtt"). WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}). AddRow(120672.0, historicalTime)) mock.ExpectExec(`INSERT INTO vehicle_daily_mileage_source`). WithArgs( "LMRKH9AC2R1004087", "2026-07-08", "YUTONG_MQTT", "YUTONG_MQTT:LMRKH9AC2R1004087@mqtt", "mqtt", "mqtt://yutong/ytforward/shln/3", "", "LMRKH9AC2R1004087", "", float64(120672.0), float64(120788.0), approxFloat64{want: 116.0, tolerance: 0.000001}, int64(1), historicalTime, currentTime, QualityOK, "historical_source_baseline", ). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectExec(`UPDATE vehicle_daily_mileage_source`). WithArgs("LMRKH9AC2R1004087", "2026-07-08", "YUTONG_MQTT"). WillReturnResult(sqlmock.NewResult(0, 0)) mock.ExpectExec(`INSERT INTO vehicle_daily_mileage`). WithArgs("LMRKH9AC2R1004087", "2026-07-08", "YUTONG_MQTT", int64(1000)). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectExec(`UPDATE vehicle_daily_mileage_source s`). WithArgs( "LMRKH9AC2R1004087", "2026-07-08", "YUTONG_MQTT", int64(1000), "LMRKH9AC2R1004087", "2026-07-08", "YUTONG_MQTT", ). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectExec(`DELETE FROM vehicle_daily_mileage`). WithArgs( "LMRKH9AC2R1004087", "2026-07-08", "YUTONG_MQTT", "LMRKH9AC2R1004087", "2026-07-08", "YUTONG_MQTT", ). WillReturnResult(sqlmock.NewResult(0, 0)) if err := writer.Append(context.Background(), event); err != nil { t.Fatalf("Append() error = %v", err) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestWriterAppendUsesCurrentOKCandidateWhenPreviousBaselineMissing(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) writer := NewWriter(db, loc) firstTime := time.Date(2026, 7, 7, 23, 58, 0, 0, loc) currentTime := time.Date(2026, 7, 8, 15, 56, 0, 0, loc) event := envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, VIN: "LA9GG64L7PBAF4001", Phone: "13307765812", SourceEndpoint: "115.231.168.135:20215", EventTimeMS: currentTime.UnixMilli(), Fields: map[string]any{ "jt808.location.total_mileage_km": 4136.8, }, } 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.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`). WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", "JT808:13307765812@115.231.168.135"). WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"})) mock.ExpectQuery(`SELECT first_total_mileage_km, first_event_time FROM vehicle_daily_mileage_source`). WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", "JT808:13307765812@115.231.168.135"). WillReturnRows(sqlmock.NewRows([]string{"first_total_mileage_km", "first_event_time"}). AddRow(4100.8, firstTime)) 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.8), float64(4136.8), approxFloat64{want: 36.0, tolerance: 0.000001}, int64(1), firstTime, currentTime, QualityOK, "current_day_first_sample", ). 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(0, 1)) 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, 1)) mock.ExpectExec(`DELETE FROM vehicle_daily_mileage`). WithArgs( "LA9GG64L7PBAF4001", "2026-07-08", "JT808", "LA9GG64L7PBAF4001", "2026-07-08", "JT808", ). WillReturnResult(sqlmock.NewResult(0, 0)) if err := writer.Append(context.Background(), event); err != nil { t.Fatalf("Append() error = %v", err) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestWriterEnsuresSchemaAndUpsertsDailyMileage(t *testing.T) { exec := &recordingExec{} writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600)) if err := writer.EnsureSchema(context.Background()); err != nil { t.Fatalf("EnsureSchema() error = %v", err) } if err := writer.Append(context.Background(), envelope.FrameEnvelope{ Protocol: envelope.ProtocolGB32960, VIN: "LNBVIN00000000002", Phone: "13307765812", SourceEndpoint: "115.231.168.135:20215", EventTimeMS: time.Date(2026, 7, 1, 9, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli(), Fields: map[string]any{ envelope.FieldTotalMileageKM: "10000.0", }, }); err != nil { t.Fatalf("Append() error = %v", err) } if len(exec.calls) != 16 { t.Fatalf("exec calls = %d", len(exec.calls)) } for i, want := range []string{ "CREATE TABLE IF NOT EXISTS vehicle_data_source", "CREATE TABLE IF NOT EXISTS vehicle_daily_mileage_source", "CREATE TABLE IF NOT EXISTS vehicle_daily_mileage", } { if !strings.Contains(exec.calls[i].query, want) { t.Fatalf("schema call %d = %s, want %s", i, exec.calls[i].query, want) } } for _, column := range []string{"vehicle_key", "AUTO_INCREMENT", "created_at", "first_total_mileage_km", "trusted_source_key", "trusted_phone", "trusted_source_endpoint", "sample_count"} { if strings.Contains(exec.calls[2].query, column) { t.Fatalf("schema should not include %s: %s", column, exec.calls[2].query) } } for _, column := range []string{"source_id BIGINT NULL", "daily_mileage_km", "latest_total_mileage_km", "KEY idx_source_id (source_id)"} { if !strings.Contains(exec.calls[2].query, column) { t.Fatalf("schema should include %s: %s", column, exec.calls[2].query) } } if !strings.Contains(exec.calls[2].query, "PRIMARY KEY (vin, stat_date, protocol)") { t.Fatalf("daily mileage table should key by vin/stat_date/protocol: %s", exec.calls[2].query) } if strings.Contains(exec.calls[2].query, "KEY idx_vin (vin)") { t.Fatalf("daily mileage table should not keep redundant vin index covered by the primary key: %s", exec.calls[2].query) } projectCall := exec.calls[13] if !strings.Contains(projectCall.query, "INSERT INTO vehicle_daily_mileage") { t.Fatalf("unexpected project sql: %s", projectCall.query) } if !strings.Contains(projectCall.query, "ON DUPLICATE KEY UPDATE") { t.Fatalf("unexpected upsert sql: %s", projectCall.query) } if strings.Contains(projectCall.query, "metric_key") || strings.Contains(projectCall.query, "metric_unit") { t.Fatalf("daily mileage upsert should not use generic metric columns: %s", projectCall.query) } if !strings.Contains(projectCall.query, "JOIN vehicle_data_source ds") { t.Fatalf("project sql should join data source: %s", projectCall.query) } if !strings.Contains(projectCall.query, "ds.id") { t.Fatalf("project sql should write source id: %s", projectCall.query) } for _, forbidden := range []string{"trusted_source_key", "trusted_phone", "trusted_source_endpoint", "first_total_mileage_km", "sample_count)"} { if strings.Contains(projectCall.query, forbidden) { t.Fatalf("project sql should not write final-table field %q: %s", forbidden, projectCall.query) } } if got := projectCall.args[0]; got != "LNBVIN00000000002" { t.Fatalf("first upsert arg should be vin, got %#v", got) } if !strings.Contains(exec.calls[10].query, "INSERT INTO vehicle_data_source") { t.Fatalf("source upsert query missing: %s", exec.calls[10].query) } if !strings.Contains(exec.calls[11].query, "INSERT INTO vehicle_daily_mileage_source") { t.Fatalf("candidate upsert query missing: %s", exec.calls[11].query) } } func TestWriterSkipsConsecutiveDuplicateMileageSamples(t *testing.T) { exec := &recordingExec{} writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600)) event := envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, VIN: "LNBVIN00000000001", SourceEndpoint: "115.231.168.135:20215", EventTimeMS: time.Date(2026, 7, 1, 9, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli(), Fields: map[string]any{ envelope.FieldTotalMileageKM: 10241.2, }, } if err := writer.Append(context.Background(), event); err != nil { t.Fatalf("first Append() error = %v", err) } event.ReceivedAtMS += 1000 if err := writer.Append(context.Background(), event); err != nil { t.Fatalf("duplicate Append() error = %v", err) } if len(exec.calls) != 6 { t.Fatalf("exec calls = %d, want 6", len(exec.calls)) } } func TestWriterDoesNotCacheMileageWhenUpsertFails(t *testing.T) { exec := &recordingExec{errs: []error{errors.New("mysql unavailable"), nil}} writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600)) event := envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, VIN: "LNBVIN00000000001", SourceEndpoint: "115.231.168.135:20215", EventTimeMS: time.Date(2026, 7, 1, 9, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli(), Fields: map[string]any{ envelope.FieldTotalMileageKM: 10241.2, }, } if err := writer.Append(context.Background(), event); err == nil { t.Fatalf("first Append() error = nil, want mysql error") } if err := writer.Append(context.Background(), event); err != nil { t.Fatalf("retry Append() error = %v", err) } if len(exec.calls) != 7 { t.Fatalf("exec calls = %d, want 7", len(exec.calls)) } } func TestWriterKeepsOnlyLatestDateInMileageCache(t *testing.T) { exec := &recordingExec{} writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600)) event := envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, VIN: "LNBVIN00000000001", SourceEndpoint: "115.231.168.135:20215", Fields: map[string]any{ envelope.FieldTotalMileageKM: 10241.2, }, } event.EventTimeMS = time.Date(2026, 7, 1, 23, 59, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli() if err := writer.Append(context.Background(), event); err != nil { t.Fatalf("first Append() error = %v", err) } event.EventTimeMS = time.Date(2026, 7, 2, 0, 1, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli() if err := writer.Append(context.Background(), event); err != nil { t.Fatalf("next-day Append() error = %v", err) } if len(exec.calls) != 12 { t.Fatalf("exec calls = %d, want 12", len(exec.calls)) } if len(writer.lastTotalMileage) != 1 { t.Fatalf("cache entries = %d, want 1", len(writer.lastTotalMileage)) } } func countExecQueries(calls []execCall, fragment string) int { count := 0 for _, call := range calls { if strings.Contains(call.query, fragment) { count++ } } return count } type execCall struct { query string args []any } type recordingExec struct { calls []execCall errs []error } func (e *recordingExec) ExecContext(_ context.Context, query string, args ...any) (sql.Result, error) { e.calls = append(e.calls, execCall{query: query, args: args}) if len(e.errs) > 0 { err := e.errs[0] e.errs = e.errs[1:] return nil, err } return nil, nil } type approxFloat64 struct { want float64 tolerance float64 } func (m approxFloat64) Match(value driver.Value) bool { got, ok := value.(float64) if !ok { return false } return math.Abs(got-m.want) <= m.tolerance }