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")) }