592 lines
19 KiB
Go
592 lines
19 KiB
Go
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 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 TestWriterAppendWritesNoPreviousBaselineCandidateAndCleansProjection(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-07", "JT808", "JT808:13307765812@115.231.168.135").
|
|
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_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,
|
|
QualityNoPreviousBaseline,
|
|
"missing_previous_source",
|
|
).
|
|
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, 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))
|
|
|
|
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-07", "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,
|
|
"same_source_previous_day",
|
|
).
|
|
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) != 12 {
|
|
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", "id BIGINT", "AUTO_INCREMENT", "created_at"} {
|
|
if strings.Contains(exec.calls[2].query, column) {
|
|
t.Fatalf("schema should not 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[9]
|
|
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, "LEFT JOIN vehicle_data_source ds") {
|
|
t.Fatalf("project sql should join data source: %s", projectCall.query)
|
|
}
|
|
if !strings.Contains(projectCall.query, "trusted_source_key") {
|
|
t.Fatalf("upsert should track trusted source: %s", 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[6].query, "INSERT INTO vehicle_data_source") {
|
|
t.Fatalf("source upsert query missing: %s", exec.calls[6].query)
|
|
}
|
|
if !strings.Contains(exec.calls[7].query, "INSERT INTO vehicle_daily_mileage_source") {
|
|
t.Fatalf("candidate upsert query missing: %s", exec.calls[7].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",
|
|
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) != 4 {
|
|
t.Fatalf("exec calls = %d, want 4", 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) != 5 {
|
|
t.Fatalf("exec calls = %d, want 5", 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) != 8 {
|
|
t.Fatalf("exec calls = %d, want 8", 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
|
|
}
|