Files
lingniu-vehicle-ingest/go/vehicle-gateway/internal/identity/resolver_test.go

1103 lines
40 KiB
Go

package identity
import (
"context"
"database/sql"
"errors"
"strings"
"sync"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
func TestCandidateKeysExcludeDeviceIDForBindingLookup(t *testing.T) {
keys := CandidateKeys(envelope.FrameEnvelope{
Phone: "013307795425",
DeviceID: "D1",
Plate: "豫A12345",
VehicleKeyHint: "D1",
})
got := []string{}
for _, key := range keys {
got = append(got, key.Column+"="+key.Value)
}
want := []string{"phone=13307795425", "plate=豫A12345", "vin=D1"}
if len(got) != len(want) {
t.Fatalf("candidate count = %d got=%#v", len(got), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("candidate[%d] = %q, want %q", i, got[i], want[i])
}
}
}
func TestCandidateKeysNormalizesPhone(t *testing.T) {
keys := CandidateKeys(envelope.FrameEnvelope{
Phone: "013079963379",
})
got := []string{}
for _, key := range keys {
got = append(got, key.Column+"="+key.Value)
}
want := []string{"phone=13079963379"}
if len(got) != len(want) {
t.Fatalf("candidate count = %d got=%#v", len(got), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("candidate[%d] = %q, want %q", i, got[i], want[i])
}
}
}
func TestMySQLResolverFillsVINFromPhone(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectVehicleIdentifierHit(mock, "JT808_PHONE", "13307795425", "LNBVIN00000000001")
resolver := NewMySQLResolver(db, "vehicle_identity_binding")
env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "013307795425",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if env.VIN != "LNBVIN00000000001" {
t.Fatalf("vin = %q", env.VIN)
}
identity, ok := env.Parsed["identity"].(map[string]any)
if !ok || identity["source"] != "vehicle_identifier.JT808_PHONE" {
t.Fatalf("identity metadata = %#v", env.Parsed["identity"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverUsesDataSourceCodeForJT808IdentifierLookup(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectSourceCodeHit(mock, "115.231.168.135", "xinda")
expectVehicleIdentifierHitForSource(mock, "xinda", "JT808_PHONE", "13307795425", "LNBVIN00000000001")
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SourceCodeLookup: true,
LookupCacheTTL: time.Hour,
})
env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "013307795425",
SourceEndpoint: "115.231.168.135:43625",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if env.VIN != "LNBVIN00000000001" {
t.Fatalf("vin = %q", env.VIN)
}
if env.SourceCode != "xinda" || env.PlatformName != "G7s" || env.SourceKind != "PLATFORM" {
t.Fatalf("source metadata = code:%q platform:%q kind:%q", env.SourceCode, env.PlatformName, env.SourceKind)
}
identity, ok := env.Parsed["identity"].(map[string]any)
if !ok || identity["source"] != "vehicle_identifier.JT808_PHONE.xinda" {
t.Fatalf("identity metadata = %#v", env.Parsed["identity"])
}
if identity["source_code"] != "xinda" || identity["platform_name"] != "G7s" || identity["source_kind"] != "PLATFORM" {
t.Fatalf("identity source metadata = %#v", identity)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverVehicleIdentifierSourceOverridesDataSourceCode(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectQuery("SELECT source_code").
WithArgs("JT808", "115.159.85.149").
WillReturnRows(sqlmock.NewRows([]string{"source_code", "platform_name", "source_kind"}).AddRow("dongfang_beidou", "G7易流", "PLATFORM"))
expectVehicleIdentifierMissForSource(mock, "dongfang_beidou", "JT808_PHONE", "64341232682")
expectVehicleIdentifierHitWithSource(mock, "JT808_PHONE", "64341232682", "LB9A32A23R0LS1045", "g7s", "G7s")
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SourceCodeLookup: true,
LookupCacheTTL: time.Hour,
})
env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "064341232682",
SourceEndpoint: "115.159.85.149:42823",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if env.VIN != "LB9A32A23R0LS1045" {
t.Fatalf("vin = %q", env.VIN)
}
if env.SourceCode != "g7s" || env.PlatformName != "G7s" || env.SourceKind != "PLATFORM" {
t.Fatalf("source metadata = code:%q platform:%q kind:%q", env.SourceCode, env.PlatformName, env.SourceKind)
}
identity, ok := env.Parsed["identity"].(map[string]any)
if !ok || identity["source"] != "vehicle_identifier.JT808_PHONE.g7s" {
t.Fatalf("identity metadata = %#v", env.Parsed["identity"])
}
if identity["source_code"] != "g7s" || identity["platform_name"] != "G7s" || identity["source_kind"] != "PLATFORM" {
t.Fatalf("identity source metadata = %#v", identity)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverUsesStaleIdentifierCacheWhenLookupFails(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectVehicleIdentifierHit(mock, "JT808_PHONE", "13307795425", "LNBVIN00000000001")
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
LookupCacheTTL: time.Nanosecond,
StaleLookupTTL: time.Hour,
})
first, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "013307795425",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("first Resolve() error = %v", err)
}
if first.VIN != "LNBVIN00000000001" {
t.Fatalf("first vin = %q", first.VIN)
}
time.Sleep(time.Millisecond)
mock.ExpectQuery("SELECT DISTINCT vin").
WithArgs("JT808", "JT808_PHONE", "13307795425").
WillReturnError(errors.New("mysql temporarily unavailable"))
second, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "013307795425",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("second Resolve() error = %v", err)
}
if second.VIN != "LNBVIN00000000001" {
t.Fatalf("second vin = %q, want stale cached vin", second.VIN)
}
identity, ok := second.Parsed["identity"].(map[string]any)
if !ok || identity["source"] != "vehicle_identifier.JT808_PHONE" {
t.Fatalf("identity metadata = %#v", second.Parsed["identity"])
}
if identity["cache_status"] != "stale" {
t.Fatalf("identity cache_status = %#v, want stale", identity["cache_status"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestNormalizeEndpointIPUsesSharedSourceEndpointKey(t *testing.T) {
tests := map[string]string{
"115.231.168.135:43625": "115.231.168.135",
" 115.159.85.149:28316 ": "115.159.85.149",
"mqtt://yutong/ytforward/shln/3": "mqtt",
"MQTT://YUTONG/topic": "mqtt",
}
for input, want := range tests {
if got := normalizeEndpointIP(input); got != want {
t.Fatalf("normalizeEndpointIP(%q) = %q, want %q", input, got, want)
}
}
}
func TestMySQLResolverKeepsDirectSourceKindWithoutSourceCode(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectQuery("SELECT source_code").
WithArgs("JT808", "39.144.3.22").
WillReturnRows(sqlmock.NewRows([]string{"source_code", "platform_name", "source_kind"}).AddRow("", "", "DIRECT"))
expectVehicleIdentifierHit(mock, "JT808_PHONE", "13307765812", "LA9GG64L7PBAF4001")
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SourceCodeLookup: true,
LookupCacheTTL: time.Hour,
})
env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "013307765812",
SourceEndpoint: "39.144.3.22:60177",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if env.VIN != "LA9GG64L7PBAF4001" {
t.Fatalf("vin = %q", env.VIN)
}
if env.SourceCode != "" || env.SourceKind != "DIRECT" {
t.Fatalf("source metadata = code:%q kind:%q", env.SourceCode, env.SourceKind)
}
identity, ok := env.Parsed["identity"].(map[string]any)
if !ok || identity["source_kind"] != "DIRECT" {
t.Fatalf("identity source metadata = %#v", env.Parsed["identity"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverFallsBackToGlobalIdentifierWhenSourceCodeMisses(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectSourceCodeHit(mock, "115.231.168.135", "xinda")
expectVehicleIdentifierMissForSource(mock, "xinda", "JT808_PHONE", "13307795425")
expectVehicleIdentifierHit(mock, "JT808_PHONE", "13307795425", "LNBVIN00000000002")
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SourceCodeLookup: true,
LookupCacheTTL: time.Hour,
})
env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "013307795425",
SourceEndpoint: "115.231.168.135:43625",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if env.VIN != "LNBVIN00000000002" {
t.Fatalf("vin = %q", env.VIN)
}
identity, ok := env.Parsed["identity"].(map[string]any)
if !ok || identity["source"] != "vehicle_identifier.JT808_PHONE" {
t.Fatalf("identity metadata = %#v", env.Parsed["identity"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverUsesSingleGlobalIdentifierSourceCodeForJT808SourceMetadata(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectSourceCodeMiss(mock, "117.132.196.119")
expectVehicleIdentifierHitWithSource(mock, "JT808_PHONE", "41456413943", "LNXNEGRR0SR321372", "xinda", "信达")
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SourceCodeLookup: true,
LookupCacheTTL: time.Hour,
})
env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "41456413943",
SourceEndpoint: "117.132.196.119:3275",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if env.VIN != "LNXNEGRR0SR321372" {
t.Fatalf("vin = %q", env.VIN)
}
if env.SourceCode != "xinda" || env.PlatformName != "信达" || env.SourceKind != "PLATFORM" {
t.Fatalf("source metadata = code:%q platform:%q kind:%q", env.SourceCode, env.PlatformName, env.SourceKind)
}
identity, ok := env.Parsed["identity"].(map[string]any)
if !ok || identity["source"] != "vehicle_identifier.JT808_PHONE.xinda" {
t.Fatalf("identity metadata = %#v", env.Parsed["identity"])
}
if identity["source_code"] != "xinda" || identity["platform_name"] != "信达" || identity["source_kind"] != "PLATFORM" {
t.Fatalf("identity source metadata = %#v", identity)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverLooksUpVINByUniqueKeyWithoutSort(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectVehicleIdentifierMiss(mock, "JT808_PHONE", "13307795425")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\? AND vin IS NOT NULL AND vin <> ''$").
WithArgs("13307795425").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LNBVIN00000000001"))
resolver := NewMySQLResolver(db, "vehicle_identity_binding")
env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "013307795425",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if env.VIN != "LNBVIN00000000001" {
t.Fatalf("vin = %q", env.VIN)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverDoesNotLookupBindingByDeviceID(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectVehicleIdentifierMiss(mock, "JT808_PHONE", "13307795425")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
WithArgs("13307795425").
WillReturnError(sql.ErrNoRows)
resolver := NewMySQLResolver(db, "vehicle_identity_binding")
env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "013307795425",
DeviceID: "D1",
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if env.VIN != "" {
t.Fatalf("vin = %q", env.VIN)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverTracksJT808RegistrationWithoutWritingBinding(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectVehicleIdentifierMiss(mock, "JT808_PHONE", "13079963379")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
WithArgs("13079963379").
WillReturnError(sql.ErrNoRows)
expectVehicleIdentifierMiss(mock, "PLATE", "TEST123")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE plate = \\?").
WithArgs("TEST123").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LKLG7C4E3NA774736"))
mock.ExpectExec("INSERT INTO jt808_registration").
WillReturnResult(sqlmock.NewResult(1, 1))
resolver := NewMySQLResolver(db, "vehicle_identity_binding")
env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0100",
Phone: "013079963379",
DeviceID: "DEV0001",
Plate: "TEST123",
SourceEndpoint: "115.231.168.135:43625",
Parsed: map[string]any{
"registration": map[string]any{
"province": uint16(16),
"city": uint16(32),
"manufacturer": "YUTNG",
"device_type": "ZK6105CHEVNPG4",
"device_id": "DEV0001",
"plate_color": uint8(2),
"plate": "TEST123",
},
},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if env.VIN != "LKLG7C4E3NA774736" {
t.Fatalf("vin = %q", env.VIN)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverTracksFirstJT808LocationThenThrottles(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectVehicleIdentifierMiss(mock, "JT808_PHONE", "13307795425")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
WithArgs("13307795425").
WillReturnError(sql.ErrNoRows)
mock.ExpectQuery("SELECT vin, device_id, plate FROM jt808_registration WHERE phone = \\?").
WithArgs("13307795425").
WillReturnError(sql.ErrNoRows)
mock.ExpectExec("INSERT INTO jt808_registration").
WillReturnResult(sqlmock.NewResult(1, 1))
resolver := NewMySQLResolver(db, "vehicle_identity_binding")
for i := 0; i < 2; i++ {
_, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
Phone: "013307795425",
SourceEndpoint: "115.231.168.135:43625",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverUsesJT808RegistrationPlateForLocationVIN(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectVehicleIdentifierMiss(mock, "JT808_PHONE", "40692934322")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
WithArgs("40692934322").
WillReturnError(sql.ErrNoRows)
mock.ExpectQuery("SELECT vin, device_id, plate FROM jt808_registration WHERE phone = \\?").
WithArgs("40692934322").
WillReturnRows(sqlmock.NewRows([]string{"vin", "device_id", "plate"}).AddRow("unknown", "18285", "粤AG18285"))
expectVehicleIdentifierMiss(mock, "PLATE", "粤AG18285")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE plate = \\?").
WithArgs("粤AG18285").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LNXNEGRR7SR318212"))
mock.ExpectExec("INSERT INTO jt808_registration").
WillReturnResult(sqlmock.NewResult(1, 1))
resolver := NewMySQLResolver(db, "vehicle_identity_binding")
env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
Phone: "40692934322",
SourceEndpoint: "115.231.168.135:47822",
Parsed: map[string]any{},
Fields: map[string]any{
envelope.FieldLatitude: 30.1,
envelope.FieldLongitude: 120.1,
},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if env.VIN != "LNXNEGRR7SR318212" {
t.Fatalf("vin = %q", env.VIN)
}
if env.DeviceID != "18285" || env.Plate != "粤AG18285" {
t.Fatalf("registration identity not copied: device=%q plate=%q", env.DeviceID, env.Plate)
}
identity, ok := env.Parsed["identity"].(map[string]any)
if !ok || identity["source"] != "jt808_registration.plate" {
t.Fatalf("identity metadata = %#v", env.Parsed["identity"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverRefreshesRegistrationCacheAfterRegisterFrame(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectVehicleIdentifierMiss(mock, "JT808_PHONE", "40692934322")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
WithArgs("40692934322").
WillReturnError(sql.ErrNoRows)
mock.ExpectQuery("SELECT vin, device_id, plate FROM jt808_registration WHERE phone = \\?").
WithArgs("40692934322").
WillReturnError(sql.ErrNoRows)
mock.ExpectExec("INSERT INTO jt808_registration").
WillReturnResult(sqlmock.NewResult(1, 1))
expectVehicleIdentifierMiss(mock, "PLATE", "粤AG18285")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE plate = \\?").
WithArgs("粤AG18285").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LNXNEGRR7SR318212"))
mock.ExpectExec("INSERT INTO jt808_registration").
WillReturnResult(sqlmock.NewResult(1, 1))
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
LocationTouchInterval: time.Hour,
LookupCacheTTL: time.Hour,
})
firstLocation, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
Phone: "040692934322",
SourceEndpoint: "115.231.168.135:47822",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("first location Resolve() error = %v", err)
}
if firstLocation.VIN != "" {
t.Fatalf("first location vin = %q, want unresolved", firstLocation.VIN)
}
registered, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0100",
Phone: "040692934322",
DeviceID: "18285",
Plate: "粤AG18285",
SourceEndpoint: "115.231.168.135:47822",
Parsed: map[string]any{
"registration": map[string]any{
"device_id": "18285",
"plate": "粤AG18285",
},
},
})
if err != nil {
t.Fatalf("registration Resolve() error = %v", err)
}
if registered.VIN != "LNXNEGRR7SR318212" {
t.Fatalf("registered vin = %q", registered.VIN)
}
secondLocation, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
Phone: "040692934322",
SourceEndpoint: "115.231.168.135:47822",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("second location Resolve() error = %v", err)
}
if secondLocation.VIN != "LNXNEGRR7SR318212" {
t.Fatalf("second location vin = %q, want cache-refreshed registration vin", secondLocation.VIN)
}
if secondLocation.DeviceID != "18285" || secondLocation.Plate != "粤AG18285" {
t.Fatalf("second location identity not copied: device=%q plate=%q", secondLocation.DeviceID, secondLocation.Plate)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverDelegatesRegistrationPersistenceButKeepsLocalSession(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
var results []RegistrationWriteResult
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
DisableRegistrationWrites: true,
OnRegistrationWriteResult: func(result RegistrationWriteResult) {
results = append(results, result)
},
})
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: JT808RegisterMessageID,
Phone: "013307795425",
VIN: "LTESTVIN000000001",
DeviceID: "DEV-1",
Plate: "粤A00001",
SourceEndpoint: "115.231.168.135:43625",
ParseStatus: envelope.ParseOK,
}
resolved, err := resolver.Resolve(context.Background(), env)
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.VIN != env.VIN {
t.Fatalf("resolved vin = %q, want %q", resolved.VIN, env.VIN)
}
entry, ok := resolver.registrationCache["13307795425"]
if !ok || entry.vin != env.VIN || entry.plate != env.Plate || entry.deviceID != env.DeviceID {
t.Fatalf("local session = %+v exists=%v", entry, ok)
}
if len(results) != 1 || results[0].Mode != "delegated" || results[0].Status != "ok" {
t.Fatalf("registration write results = %#v", results)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unexpected mysql access: %v", err)
}
}
func TestMySQLResolverRetriesRegistrationUpsertTransientFailure(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectExec("INSERT INTO jt808_registration").
WillReturnError(errors.New("driver: bad connection: read tcp: connection reset by peer"))
mock.ExpectExec("INSERT INTO jt808_registration").
WillReturnResult(sqlmock.NewResult(1, 1))
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
LocationTouchInterval: time.Hour,
RegistrationWriteAttempts: 2,
RegistrationWriteRetryDelay: -1,
LookupCacheTTL: time.Hour,
})
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
Phone: "040692934322",
VIN: "LNXNEGRR7SR318212",
SourceEndpoint: "115.231.168.135:47822",
Parsed: map[string]any{},
}
if _, err := resolver.Resolve(context.Background(), env); err != nil {
t.Fatalf("Resolve() error = %v, want internal retry to recover", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverAsyncRegistrationWritesDrainOnClose(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectExec("INSERT INTO jt808_registration").
WillReturnResult(sqlmock.NewResult(1, 1))
results := make(chan RegistrationWriteResult, 2)
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
AsyncRegistrationWrites: true,
RegistrationWriteQueueSize: 8,
RegistrationWriteWorkers: 1,
RegistrationWriteTimeout: time.Second,
OnRegistrationWriteResult: func(result RegistrationWriteResult) {
results <- result
},
LocationTouchInterval: time.Hour,
LookupCacheTTL: time.Hour,
})
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
Phone: "040692934322",
VIN: "LNXNEGRR7SR318212",
SourceEndpoint: "115.231.168.135:47822",
Parsed: map[string]any{},
}
if _, err := resolver.Resolve(context.Background(), env); err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if err := resolver.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
close(results)
got := map[string]int{}
for result := range results {
got[result.Mode+":"+result.Status]++
}
if got["async_enqueue:ok"] != 1 || got["async_background:ok"] != 1 {
t.Fatalf("registration write results = %#v, want enqueue/background ok", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverAsyncRegistrationWriteFailureMarksLocationRetry(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectExec("INSERT INTO jt808_registration").
WillReturnError(errors.New("driver: bad connection"))
errs := make(chan error, 1)
results := make(chan RegistrationWriteResult, 2)
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
AsyncRegistrationWrites: true,
RegistrationWriteQueueSize: 8,
RegistrationWriteWorkers: 1,
RegistrationWriteTimeout: time.Second,
OnRegistrationWriteResult: func(result RegistrationWriteResult) {
results <- result
},
OnRegistrationWriteError: func(err error) {
errs <- err
},
LocationTouchInterval: time.Hour,
LocationTouchRetryInterval: time.Hour,
RegistrationWriteAttempts: 1,
LookupCacheTTL: time.Hour,
})
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
Phone: "040692934322",
VIN: "LNXNEGRR7SR318212",
SourceEndpoint: "115.231.168.135:47822",
Parsed: map[string]any{},
}
if _, err := resolver.Resolve(context.Background(), env); err != nil {
t.Fatalf("Resolve() error = %v, async write failure should be reported out-of-band", err)
}
if err := resolver.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
select {
case err := <-errs:
if err == nil {
t.Fatal("async error callback received nil")
}
default:
t.Fatal("async write failure was not reported")
}
stats := resolver.CacheStats()
if stats.LocationTouchFailureEntries != 1 {
t.Fatalf("location touch failure cache entries = %d, want 1", stats.LocationTouchFailureEntries)
}
close(results)
got := map[string]int{}
for result := range results {
got[result.Mode+":"+result.Status]++
}
if got["async_enqueue:ok"] != 1 || got["async_background:error"] != 1 {
t.Fatalf("registration write results = %#v, want enqueue ok/background error", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverBacksOffLocationTouchAfterExhaustedUpsert(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectExec("INSERT INTO jt808_registration").
WillReturnError(errors.New("driver: bad connection"))
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
LocationTouchInterval: time.Hour,
LocationTouchRetryInterval: time.Hour,
RegistrationWriteAttempts: 1,
LookupCacheTTL: time.Hour,
})
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
Phone: "040692934322",
VIN: "LNXNEGRR7SR318212",
SourceEndpoint: "115.231.168.135:47822",
Parsed: map[string]any{},
}
if _, err := resolver.Resolve(context.Background(), env); err == nil {
t.Fatal("first Resolve() error = nil, want exhausted transient registration upsert failure")
}
stats := resolver.CacheStats()
if stats.LocationTouchFailureEntries != 1 {
t.Fatalf("location touch failure cache entries = %d, want 1", stats.LocationTouchFailureEntries)
}
if _, err := resolver.Resolve(context.Background(), env); err != nil {
t.Fatalf("second Resolve() error = %v, want short backoff to skip immediate retry", err)
}
stats = resolver.CacheStats()
if stats.LocationTouchFailureEntries != 1 {
t.Fatalf("location touch failure cache entries after backoff = %d, want 1", stats.LocationTouchFailureEntries)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestIsTransientMySQLIdentityWriteError(t *testing.T) {
for _, err := range []error{
errors.New("dial tcp 127.0.0.1:3306: connection refused"),
errors.New("read tcp: connection reset by peer"),
errors.New("write tcp: broken pipe"),
errors.New("driver: bad connection"),
errors.New("invalid connection"),
errors.New("i/o timeout"),
errors.New("EOF"),
errors.New("server is down"),
errors.New("network is unreachable"),
} {
if !isTransientMySQLIdentityWriteError(err) {
t.Fatalf("isTransientMySQLIdentityWriteError(%q) = false, want true", err.Error())
}
}
for _, err := range []error{
context.Canceled,
context.DeadlineExceeded,
errors.New("duplicate key conflict"),
nil,
} {
if isTransientMySQLIdentityWriteError(err) {
t.Fatalf("isTransientMySQLIdentityWriteError(%v) = true, want false", err)
}
}
}
func TestTimeoutResolverAppliesDeadlineToDelegate(t *testing.T) {
delegate := &deadlineCheckingResolver{}
resolver := TimeoutResolver{Delegate: delegate, Timeout: 50 * time.Millisecond}
if _, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808}); err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if delegate.deadline.IsZero() {
t.Fatal("delegate did not receive a deadline")
}
if remaining := time.Until(delegate.deadline); remaining <= 0 || remaining > time.Second {
t.Fatalf("deadline remaining = %s", remaining)
}
}
func TestMySQLResolverCachesIdentityMissesForHighFrequencyFrames(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectVehicleIdentifierMiss(mock, "JT808_PHONE", "13307795425")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
WithArgs("13307795425").
WillReturnError(sql.ErrNoRows)
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
LocationTouchInterval: time.Hour,
LookupCacheTTL: time.Hour,
})
for i := 0; i < 2; i++ {
_, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "013307795425",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverBoundsIdentityLookupCaches(t *testing.T) {
db, _ := newMockDB(t)
defer db.Close()
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
LookupCacheTTL: time.Hour,
CacheCleanupInterval: time.Hour,
MaxCacheEntries: 2,
LocationTouchInterval: time.Hour,
})
now := time.Date(2026, 7, 12, 10, 0, 0, 0, time.UTC)
for i, key := range []string{"lookup-1", "lookup-2", "lookup-3"} {
entryNow := now.Add(time.Duration(i) * time.Second)
resolver.cacheLookup(key, lookupCacheEntry{
vin: key,
expiresAt: entryNow.Add(time.Hour),
}, entryNow)
resolver.cacheRegistration("phone-"+key, registrationCacheEntry{
vin: key,
expiresAt: entryNow.Add(time.Hour),
}, entryNow)
resolver.cacheSourceMetadata("source-"+key, sourceMetadata{SourceCode: key}, false, entryNow)
}
stats := resolver.CacheStats()
if stats.LookupEntries != 2 || stats.RegistrationEntries != 2 || stats.SourceCodeEntries != 2 || stats.MaxEntries != 2 {
t.Fatalf("cache stats = %+v, want all identity caches capped at 2", stats)
}
resolver.lookupMu.Lock()
_, hasOldLookup := resolver.lookupCache["lookup-1"]
_, hasOldRegistration := resolver.registrationCache["phone-lookup-1"]
_, hasOldSource := resolver.sourceCodeCache["source-lookup-1"]
resolver.lookupMu.Unlock()
if hasOldLookup || hasOldRegistration || hasOldSource {
t.Fatalf("oldest cache entries should be evicted")
}
}
func TestMySQLResolverBoundsLocationTouchCache(t *testing.T) {
db, _ := newMockDB(t)
defer db.Close()
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
LookupCacheTTL: time.Hour,
CacheCleanupInterval: time.Hour,
MaxCacheEntries: 2,
LocationTouchInterval: time.Hour,
})
now := time.Date(2026, 7, 12, 10, 0, 0, 0, time.UTC)
resolver.touchMu.Lock()
resolver.locationTouches["old-expired"] = now.Add(-2 * time.Hour)
resolver.locationTouches["phone-1"] = now.Add(-2 * time.Minute)
resolver.locationTouches["phone-2"] = now.Add(-time.Minute)
resolver.locationTouches["phone-3"] = now
resolver.locationTouchFailures["old-failure"] = now.Add(-time.Minute)
resolver.locationTouchFailures["phone-2"] = now.Add(time.Minute)
resolver.locationTouchFailures["phone-3"] = now.Add(2 * time.Minute)
resolver.locationTouchFailures["phone-4"] = now.Add(3 * time.Minute)
resolver.cleanupLocationTouchesLocked(now, false)
resolver.touchMu.Unlock()
stats := resolver.CacheStats()
if stats.LocationTouchEntries != 2 || stats.LocationTouchFailureEntries != 2 || stats.MaxEntries != 2 {
t.Fatalf("cache stats = %+v, want location touch caches capped at 2", stats)
}
resolver.touchMu.Lock()
_, hasExpired := resolver.locationTouches["old-expired"]
_, hasOldest := resolver.locationTouches["phone-1"]
_, hasExpiredFailure := resolver.locationTouchFailures["old-failure"]
_, hasOldestFailure := resolver.locationTouchFailures["phone-2"]
resolver.touchMu.Unlock()
if hasExpired || hasOldest || hasExpiredFailure || hasOldestFailure {
t.Fatalf("expired and oldest location touch entries should be evicted")
}
}
type deadlineCheckingResolver struct {
mu sync.Mutex
deadline time.Time
}
func (r *deadlineCheckingResolver) Resolve(ctx context.Context, env envelope.FrameEnvelope) (envelope.FrameEnvelope, error) {
deadline, _ := ctx.Deadline()
r.mu.Lock()
r.deadline = deadline
r.mu.Unlock()
return env, nil
}
func TestMySQLResolverEnsuresMinimalIdentitySchema(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectExec("CREATE TABLE IF NOT EXISTS vehicle_identity_binding").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("ALTER TABLE vehicle_identity_binding ADD COLUMN oem").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("ALTER TABLE vehicle_identity_binding DROP INDEX uk_identity_device").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("ALTER TABLE vehicle_identity_binding DROP COLUMN device_id").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("CREATE TABLE IF NOT EXISTS vehicle \\(").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("CREATE TABLE IF NOT EXISTS vehicle_identifier").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("CREATE TABLE IF NOT EXISTS jt808_registration").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("ALTER TABLE jt808_registration ADD COLUMN source_ip").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("ALTER TABLE jt808_registration ADD KEY idx_jt808_registration_source_ip").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("UPDATE jt808_registration").
WillReturnResult(sqlmock.NewResult(0, 0))
resolver := NewMySQLResolver(db, "vehicle_identity_binding")
if err := resolver.EnsureSchema(context.Background()); err != nil {
t.Fatalf("EnsureSchema() error = %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverIgnoresExistingOEMColumn(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectExec("CREATE TABLE IF NOT EXISTS vehicle_identity_binding").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("ALTER TABLE vehicle_identity_binding ADD COLUMN oem").
WillReturnError(errors.New("Error 1060 (42S21): Duplicate column name 'oem'"))
mock.ExpectExec("ALTER TABLE vehicle_identity_binding DROP INDEX uk_identity_device").
WillReturnError(errors.New("Error 1091 (42000): Can't DROP 'uk_identity_device'; check that column/key exists"))
mock.ExpectExec("ALTER TABLE vehicle_identity_binding DROP COLUMN device_id").
WillReturnError(errors.New("Error 1091 (42000): Can't DROP 'device_id'; check that column/key exists"))
mock.ExpectExec("CREATE TABLE IF NOT EXISTS vehicle \\(").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("CREATE TABLE IF NOT EXISTS vehicle_identifier").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("CREATE TABLE IF NOT EXISTS jt808_registration").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("ALTER TABLE jt808_registration ADD COLUMN source_ip").
WillReturnError(errors.New("Error 1060 (42S21): Duplicate column name 'source_ip'"))
mock.ExpectExec("ALTER TABLE jt808_registration ADD KEY idx_jt808_registration_source_ip").
WillReturnError(errors.New("Error 1061 (42000): Duplicate key name 'idx_jt808_registration_source_ip'"))
mock.ExpectExec("UPDATE jt808_registration").
WillReturnResult(sqlmock.NewResult(0, 0))
resolver := NewMySQLResolver(db, "vehicle_identity_binding")
if err := resolver.EnsureSchema(context.Background()); err != nil {
t.Fatalf("EnsureSchema() error = %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestIdentitySchemaUsesBusinessKeysOnly(t *testing.T) {
binding := identityBindingTableSQL("vehicle_identity_binding")
registration := jt808RegistrationTableSQL
for _, sqlText := range []string{binding, registration} {
for _, column := range []string{"id BIGINT", "AUTO_INCREMENT", "created_at"} {
if strings.Contains(sqlText, column) {
t.Fatalf("identity schema should not contain %s:\n%s", column, sqlText)
}
}
}
if !strings.Contains(binding, "vin VARCHAR(32) PRIMARY KEY") {
t.Fatalf("binding table should key by vin:\n%s", binding)
}
if strings.Contains(binding, "device_id") {
t.Fatalf("binding table should not contain device_id:\n%s", binding)
}
if !strings.Contains(registration, "phone VARCHAR(32) PRIMARY KEY") {
t.Fatalf("registration table should key by phone:\n%s", registration)
}
if !strings.Contains(registration, "source_ip VARCHAR(64)") || !strings.Contains(registration, "idx_jt808_registration_source_ip") {
t.Fatalf("registration table should keep indexed source_ip:\n%s", registration)
}
if !strings.Contains(vehicleIdentifierTableSQL, "PRIMARY KEY (protocol, source_code, identifier_type, identifier_value)") {
t.Fatalf("vehicle identifier should use protocol/source/type/value as key:\n%s", vehicleIdentifierTableSQL)
}
if strings.Contains(vehicleIdentifierTableSQL, "AUTO_INCREMENT") {
t.Fatalf("vehicle identifier should not use surrogate auto increment id:\n%s", vehicleIdentifierTableSQL)
}
}
func newMockDB(t *testing.T) (*sql.DB, sqlmock.Sqlmock) {
t.Helper()
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
return db, mock
}
func expectVehicleIdentifierMiss(mock sqlmock.Sqlmock, identifierType string, value string) {
mock.ExpectQuery("SELECT DISTINCT vin").
WithArgs("JT808", identifierType, value).
WillReturnRows(sqlmock.NewRows([]string{"vin", "source_code", "platform_name"}))
}
func expectVehicleIdentifierHit(mock sqlmock.Sqlmock, identifierType string, value string, vin string) {
expectVehicleIdentifierHitWithSource(mock, identifierType, value, vin, "", "")
}
func expectVehicleIdentifierHitWithSource(mock sqlmock.Sqlmock, identifierType string, value string, vin string, sourceCode string, platformName string) {
mock.ExpectQuery("SELECT DISTINCT vin").
WithArgs("JT808", identifierType, value).
WillReturnRows(sqlmock.NewRows([]string{"vin", "source_code", "platform_name"}).AddRow(vin, sourceCode, platformName))
}
func expectSourceCodeHit(mock sqlmock.Sqlmock, sourceIP string, sourceCode string) {
mock.ExpectQuery("SELECT source_code").
WithArgs("JT808", sourceIP).
WillReturnRows(sqlmock.NewRows([]string{"source_code", "platform_name", "source_kind"}).AddRow(sourceCode, "G7s", "PLATFORM"))
}
func expectSourceCodeMiss(mock sqlmock.Sqlmock, sourceIP string) {
mock.ExpectQuery("SELECT source_code").
WithArgs("JT808", sourceIP).
WillReturnRows(sqlmock.NewRows([]string{"source_code", "platform_name", "source_kind"}))
}
func expectVehicleIdentifierMissForSource(mock sqlmock.Sqlmock, sourceCode string, identifierType string, value string) {
mock.ExpectQuery("SELECT DISTINCT vin").
WithArgs("JT808", identifierType, value, sourceCode).
WillReturnRows(sqlmock.NewRows([]string{"vin", "source_code", "platform_name"}))
}
func expectVehicleIdentifierHitForSource(mock sqlmock.Sqlmock, sourceCode string, identifierType string, value string, vin string) {
mock.ExpectQuery("SELECT DISTINCT vin").
WithArgs("JT808", identifierType, value, sourceCode).
WillReturnRows(sqlmock.NewRows([]string{"vin", "source_code", "platform_name"}).AddRow(vin, sourceCode, "G7s"))
}