1103 lines
40 KiB
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"))
|
|
}
|