package stats import ( "context" "database/sql" "errors" "strings" "testing" "time" "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 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", 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 !strings.Contains(exec.calls[0].query, "CREATE TABLE IF NOT EXISTS vehicle_daily_mileage") { t.Fatalf("unexpected schema sql: %s", exec.calls[0].query) } for _, column := range []string{"vehicle_key", "id BIGINT", "AUTO_INCREMENT", "created_at"} { if strings.Contains(exec.calls[0].query, column) { t.Fatalf("schema should not include %s: %s", column, exec.calls[0].query) } } if !strings.Contains(exec.calls[0].query, "PRIMARY KEY (vin, stat_date, protocol)") { t.Fatalf("daily mileage table should key by vin/stat_date/protocol: %s", exec.calls[0].query) } if strings.Contains(exec.calls[0].query, "KEY idx_vin (vin)") { t.Fatalf("daily mileage table should not keep redundant vin index covered by the primary key: %s", exec.calls[0].query) } if len(exec.calls) != 2 { t.Fatalf("exec calls = %d", len(exec.calls)) } if !strings.Contains(exec.calls[1].query, "ON DUPLICATE KEY UPDATE") { t.Fatalf("unexpected upsert sql: %s", exec.calls[1].query) } if strings.Contains(exec.calls[1].query, "metric_key") || strings.Contains(exec.calls[1].query, "metric_unit") { t.Fatalf("daily mileage upsert should not use generic metric columns: %s", exec.calls[1].query) } if !strings.Contains(exec.calls[1].query, "first_total_mileage_km <= 0") { t.Fatalf("upsert should ignore legacy zero first mileage: %s", exec.calls[1].query) } if got := exec.calls[1].args[0]; got != "LNBVIN00000000002" { t.Fatalf("first upsert arg should be vin, got %#v", got) } } func TestWriterSkipsConsecutiveDuplicateMileageSamples(t *testing.T) { exec := &recordingExec{} writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600)) event := 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: 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) != 1 { t.Fatalf("exec calls = %d, want 1", 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", 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) != 2 { t.Fatalf("exec calls = %d, want 2", 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", 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) != 2 { t.Fatalf("exec calls = %d, want 2", len(exec.calls)) } if len(writer.lastTotalMileage) != 1 { t.Fatalf("cache entries = %d, want 1", len(writer.lastTotalMileage)) } } 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 }