From 51961cf47ded327face293ca7872349d9739b162 Mon Sep 17 00:00:00 2001 From: nbry Date: Fri, 19 Dec 2025 09:12:06 -0800 Subject: [PATCH 1/6] feat: make scope and redirect_uri optional params --- lib/smartcar/auth_client.rb | 28 +++++-- lib/smartcar/utils.rb | 11 ++- spec/smartcar/integration/auth_client_spec.rb | 6 +- spec/smartcar/unit/auth_client_spec.rb | 84 +++++++++++++++++++ 4 files changed, 115 insertions(+), 14 deletions(-) diff --git a/lib/smartcar/auth_client.rb b/lib/smartcar/auth_client.rb index f4049e9..c3e43f9 100644 --- a/lib/smartcar/auth_client.rb +++ b/lib/smartcar/auth_client.rb @@ -13,14 +13,16 @@ class AuthClient # @param [Hash] options # @option options[:client_id] [String] - Client ID, if not passed fallsback to ENV['SMARTCAR_CLIENT_ID'] # @option options[:client_secret] [String] - Client Secret, if not passed fallsback to ENV['SMARTCAR_CLIENT_SECRET'] - # @option options[:redirect_uri] [String] - Redirect URI, if not passed fallsback to ENV['SMARTCAR_REDIRECT_URI'] + # @option options[:redirect_uri] [String] - Redirect URI registered in the + # application settings. The given URL must exactly match one of the registered URLs. + # This parameter is optional and should normally be set within the Smartcar Dashboard. # @option options[:test_mode] [Boolean] - [DEPRECATED], please use `mode` instead. # Launch Smartcar Connect in [test mode](https://smartcar.com/docs/guides/testing/). # @option options[:mode] [String] - Determine what mode Smartcar Connect should be launched in. # Should be one of test, live or simulated. # @return [Smartcar::AuthClient] Returns a Smartcar::AuthClient Object that has other methods def initialize(options) - options[:redirect_uri] ||= get_config('SMARTCAR_REDIRECT_URI') + options[:redirect_uri] ||= get_config('SMARTCAR_REDIRECT_URI', { nullable: true}) options[:client_id] ||= get_config('SMARTCAR_CLIENT_ID') options[:client_secret] ||= get_config('SMARTCAR_CLIENT_SECRET') options[:auth_origin] = ENV['SMARTCAR_AUTH_ORIGIN'] || AUTH_ORIGIN @@ -30,9 +32,11 @@ def initialize(options) end # Generate the OAuth authorization URL. - # @param scope [Array] Array of permissions that specify what the user can access + # @param scope_or_options [Array, Hash] Array of permissions that specify what the user can access # EXAMPLE : ['read_odometer', 'read_vehicle_info', 'required:read_location'] - # For further details refer to https://smartcar.com/docs/guides/scope/ + # For further details refer to https://smartcar.com/docs/guides/scope/ + # This parameter is optional and should normally be set within the Smartcar Dashboard. + # Can also be a Hash of options if scope is not needed. # @param [Hash] options # @option options[:force_prompt] [Boolean] - Setting `force_prompt` to # `true` will show the permissions approval screen on every authentication @@ -57,7 +61,15 @@ def initialize(options) # is used to aggregate analytics across Connect sessions for each vehicle owner. # # @return [String] Authorization URL string - def get_auth_url(scope, options = {}) + def get_auth_url(scope_or_options = {}, options = {}) + scope = nil + if scope_or_options.is_a?(Array) + scope = scope_or_options + options = options || {} + elsif scope_or_options.is_a?(Hash) + options = scope_or_options + end + initialize_auth_parameters(scope, options) add_single_select_options(options[:single_select]) connect_client.auth_code.authorize_url(@auth_parameters) @@ -126,10 +138,10 @@ def set_token_url(flags) def initialize_auth_parameters(scope, options) @auth_parameters = { response_type: CODE, - redirect_uri: redirect_uri, - mode: mode, - scope: scope.join(' ') + mode: mode } + @auth_parameters[:redirect_uri] = redirect_uri if redirect_uri + @auth_parameters[:scope] = scope.join(' ') if scope @auth_parameters[:approval_prompt] = options[:force_prompt] ? FORCE : AUTO unless options[:force_prompt].nil? @auth_parameters[:state] = options[:state] if options[:state] @auth_parameters[:make] = options[:make_bypass] if options[:make_bypass] diff --git a/lib/smartcar/utils.rb b/lib/smartcar/utils.rb index c24da36..c3d2f11 100644 --- a/lib/smartcar/utils.rb +++ b/lib/smartcar/utils.rb @@ -15,12 +15,19 @@ def initialize(options = {}) # gets a given env variable, checks for existence and throws exception if not present # @param config_name [String] key of the env variable + # @param options [Hash] options hash, supports :nullable key (default: false) # # @return [String] value of the env variable - def get_config(config_name) + def get_config(config_name, options = {}) # ENV.MODE is set to test by e2e tests. config_name = "E2E_#{config_name}" if ENV['MODE'] == 'test' - raise Smartcar::ConfigNotFound, "Environment variable #{config_name} not found !" unless ENV[config_name] + + unless ENV[config_name] + nullable = options.fetch(:nullable, false) + return nil if nullable + + raise Smartcar::ConfigNotFound, "Environment variable #{config_name} not found !" + end ENV.fetch(config_name, nil) end diff --git a/spec/smartcar/integration/auth_client_spec.rb b/spec/smartcar/integration/auth_client_spec.rb index cfd24d3..3b68c4d 100644 --- a/spec/smartcar/integration/auth_client_spec.rb +++ b/spec/smartcar/integration/auth_client_spec.rb @@ -50,13 +50,11 @@ def token_response end context 'constructor' do - it 'raises error if redirect URL is not present' do + it 'does not raise error if redirect URL is not present (optional parameter)' do redirect_url = ENV.fetch('E2E_SMARTCAR_REDIRECT_URI', nil) ENV.delete('E2E_SMARTCAR_REDIRECT_URI') - expect { Smartcar::AuthClient.new({}) }.to(raise_error do |error| - expect(error.message).to eq('Environment variable E2E_SMARTCAR_REDIRECT_URI not found !') - end) + expect { Smartcar::AuthClient.new({}) }.not_to raise_error ENV['E2E_SMARTCAR_REDIRECT_URI'] = redirect_url end diff --git a/spec/smartcar/unit/auth_client_spec.rb b/spec/smartcar/unit/auth_client_spec.rb index ee8408e..51d9fe2 100644 --- a/spec/smartcar/unit/auth_client_spec.rb +++ b/spec/smartcar/unit/auth_client_spec.rb @@ -161,4 +161,88 @@ subject.send(:auth_client) end end + + context 'optional parameters' do + let(:obj_without_redirect) { double('dummy object without redirect_uri') } + + it 'should work without scope parameter' do + client = Smartcar::AuthClient.new({ + redirect_uri: 'test_url', + client_id: 'SMARTCAR_CLIENT_ID', + client_secret: 'SMARTCAR_CLIENT_SECRET', + mode: 'test' + }) + allow(client).to receive_message_chain(:connect_client, :auth_code).and_return(obj) + + expect(obj).to receive(:authorize_url) do |params| + expect(params[:response_type]).to eq(Smartcar::CODE) + expect(params[:mode]).to eq('test') + expect(params[:redirect_uri]).to eq('test_url') + expect(params[:state]).to eq('test_state') + expect(params).not_to have_key(:scope) + 'result' + end + + expect(client.get_auth_url({ state: 'test_state' })).to eq 'result' + end + + it 'should work without redirect_uri parameter' do + # Temporarily remove the E2E_SMARTCAR_REDIRECT_URI env var to test without redirect_uri + original_redirect = ENV['E2E_SMARTCAR_REDIRECT_URI'] + ENV.delete('E2E_SMARTCAR_REDIRECT_URI') + + client = Smartcar::AuthClient.new({ + client_id: 'SMARTCAR_CLIENT_ID', + client_secret: 'SMARTCAR_CLIENT_SECRET', + mode: 'test' + }) + + expect(client.redirect_uri).to be_nil + + allow(client).to receive_message_chain(:connect_client, :auth_code).and_return(obj_without_redirect) + + expect(obj_without_redirect).to receive(:authorize_url) do |params| + expect(params[:response_type]).to eq(Smartcar::CODE) + expect(params[:mode]).to eq('test') + expect(params[:scope]).to eq('read_odometer') + expect(params).not_to have_key(:redirect_uri) + 'result' + end + + expect(client.get_auth_url(['read_odometer'], {})).to eq 'result' + + # Restore the environment variable + ENV['E2E_SMARTCAR_REDIRECT_URI'] = original_redirect if original_redirect + end + + it 'should work without both scope and redirect_uri' do + # Temporarily remove the E2E_SMARTCAR_REDIRECT_URI env var to test without redirect_uri + original_redirect = ENV['E2E_SMARTCAR_REDIRECT_URI'] + ENV.delete('E2E_SMARTCAR_REDIRECT_URI') + + client = Smartcar::AuthClient.new({ + client_id: 'SMARTCAR_CLIENT_ID', + client_secret: 'SMARTCAR_CLIENT_SECRET', + mode: 'test' + }) + + expect(client.redirect_uri).to be_nil + + allow(client).to receive_message_chain(:connect_client, :auth_code).and_return(obj_without_redirect) + + expect(obj_without_redirect).to receive(:authorize_url) do |params| + expect(params[:response_type]).to eq(Smartcar::CODE) + expect(params[:mode]).to eq('test') + expect(params[:state]).to eq('test_state') + expect(params).not_to have_key(:scope) + expect(params).not_to have_key(:redirect_uri) + 'result' + end + + expect(client.get_auth_url({ state: 'test_state' })).to eq 'result' + + # Restore the environment variable + ENV['E2E_SMARTCAR_REDIRECT_URI'] = original_redirect if original_redirect + end + end end From a51853e903c7f222428ff29460da3cc9543e7fc5 Mon Sep 17 00:00:00 2001 From: nbry Date: Fri, 19 Dec 2025 10:31:19 -0800 Subject: [PATCH 2/6] feat: get_compatibility_matrix --- lib/smartcar.rb | 65 ++++++ spec/smartcar/integration/smartcar_spec.rb | 229 +++++++++++++++++++++ 2 files changed, 294 insertions(+) diff --git a/lib/smartcar.rb b/lib/smartcar.rb index 128563e..05e20e6 100644 --- a/lib/smartcar.rb +++ b/lib/smartcar.rb @@ -101,6 +101,48 @@ def get_compatibility(vin:, scope:, country: 'US', options: {}) )) end + # Module method to retrieve the Smartcar compatibility matrix for a given region. + # Provides the ability to filter by scope, make, and type. + # + # A compatible vehicle is a vehicle that: + # 1. has the hardware required for internet connectivity, + # 2. belongs to the makes and models Smartcar supports, and + # 3. supports the permissions. + # + # API Documentation - https://smartcar.com/docs/api-reference/compatibility/by-region-and-make + # @param region [String] One of the following regions: US, CA, or EUROPE + # @param options [Hash] Optional parameters + # @option options [Array] :scope List of permissions to filter the matrix by + # @option options [String, Array] :make List of makes to filter the matrix by (space-separated string or array) + # @option options [String] :type Engine type to filter the matrix by (e.g., "ICE", "HEV", "PHEV", "BEV") + # @option options [String] :client_id Client ID that overrides ENV + # @option options [String] :client_secret Client Secret that overrides ENV + # @option options [String] :version API version to use, defaults to what is globally set + # @option options [String] :mode Determine what mode Smartcar Connect should be launched in. + # Should be one of test, live or simulated. + # @option options [Faraday::Connection] :service Optional connection object to be used for requests + # + # @return [OpenStruct] An object representing the compatibility matrix organized by make, + # with each make containing an array of compatible models and a meta attribute with response headers. + def get_compatibility_matrix(region, options = {}) + raise Base::InvalidParameterValue.new, 'region is a required field' if region.nil? || region.empty? + + base_object = Base.new( + { + version: options[:version] || Smartcar.get_api_version, + auth_type: Base::BASIC, + service: options[:service] + } + ) + + base_object.token = generate_basic_auth(options, base_object) + + base_object.build_response(*base_object.get( + "#{PATHS[:compatibility]}/matrix", + build_compatibility_matrix_params(region, options) + )) + end + # Module method Used to get user id # # API Documentation - https://smartcar.com/docs/api#get-user @@ -252,6 +294,29 @@ def build_compatibility_params(vin, scope, country, options) query_params end + def build_compatibility_matrix_params(region, options) + query_params = { region: region } + + # Handle scope - convert array to space-separated string + if options[:scope] + query_params[:scope] = options[:scope].is_a?(Array) ? options[:scope].join(' ') : options[:scope] + end + + # Handle make - convert array to space-separated string + if options[:make] + query_params[:make] = options[:make].is_a?(Array) ? options[:make].join(' ') : options[:make] + end + + # Handle type + query_params[:type] = options[:type] if options[:type] + + # Handle mode + mode = determine_mode(options[:test_mode], options[:mode]) + query_params[:mode] = mode unless mode.nil? + + query_params + end + # returns auth token for Basic auth # # @return [String] Base64 encoding of CLIENT:SECRET diff --git a/spec/smartcar/integration/smartcar_spec.rb b/spec/smartcar/integration/smartcar_spec.rb index b550fd0..d13f795 100644 --- a/spec/smartcar/integration/smartcar_spec.rb +++ b/spec/smartcar/integration/smartcar_spec.rb @@ -244,6 +244,235 @@ end end + describe '.get_compatibility_matrix' do + context 'when region is not set' do + it 'should raise error' do + expect { subject.get_compatibility_matrix(nil) }.to(raise_error do |error| + expect(error.message).to eq('region is a required field') + end) + + expect { subject.get_compatibility_matrix('') }.to(raise_error do |error| + expect(error.message).to eq('region is a required field') + end) + end + end + + context 'when client id is not set' do + it 'should raise error' do + client_id = ENV.fetch('E2E_SMARTCAR_CLIENT_ID', nil) + ENV.delete('E2E_SMARTCAR_CLIENT_ID') + + expect { subject.get_compatibility_matrix('US') }.to(raise_error do |error| + expect(error.message).to eq('Environment variable E2E_SMARTCAR_CLIENT_ID not found !') + end) + ENV['E2E_SMARTCAR_CLIENT_ID'] = client_id + end + end + + context 'when client secret is not set' do + it 'should raise error if client secret is not set' do + client_secret = ENV.fetch('E2E_SMARTCAR_CLIENT_SECRET', nil) + ENV.delete('E2E_SMARTCAR_CLIENT_SECRET') + + expect { subject.get_compatibility_matrix('US') }.to(raise_error do |error| + expect(error.message).to eq('Environment variable E2E_SMARTCAR_CLIENT_SECRET not found !') + end) + ENV['E2E_SMARTCAR_CLIENT_SECRET'] = client_secret + end + end + + context 'when only region is provided' do + it 'should make request with only region' do + stub_request(:get, 'https://pizza.pasta.pi/v2.0/compatibility/matrix') + .with( + basic_auth: [ENV.fetch('E2E_SMARTCAR_CLIENT_ID', nil), ENV.fetch('E2E_SMARTCAR_CLIENT_SECRET', nil)], + query: { region: 'US' } + ) + .to_return( + { + status: 200, + headers: { 'content-type' => 'application/json; charset=utf-8' }, + body: { + TESLA: [ + { + model: 'Model S', + startYear: 2015, + endYear: 2023, + type: 'BEV', + endpoints: ['/battery', '/charge'], + permissions: ['read_battery', 'read_charge'] + } + ] + }.to_json + } + ) + + response = subject.get_compatibility_matrix('US') + expect(response.TESLA).to be_an(Array) + expect(response.TESLA.first.model).to eq('Model S') + end + end + + context 'when scope is provided as array' do + it 'should convert scope array to space-separated string' do + stub_request(:get, 'https://pizza.pasta.pi/v2.0/compatibility/matrix') + .with( + basic_auth: [ENV.fetch('E2E_SMARTCAR_CLIENT_ID', nil), ENV.fetch('E2E_SMARTCAR_CLIENT_SECRET', nil)], + query: { region: 'US', scope: 'read_battery read_charge' } + ) + .to_return( + { + status: 200, + headers: { 'content-type' => 'application/json; charset=utf-8' }, + body: { TESLA: [] }.to_json + } + ) + + response = subject.get_compatibility_matrix('US', { scope: %w[read_battery read_charge] }) + expect(response.TESLA).to eq([]) + end + end + + context 'when make is provided as array' do + it 'should convert make array to space-separated string' do + stub_request(:get, 'https://pizza.pasta.pi/v2.0/compatibility/matrix') + .with( + basic_auth: [ENV.fetch('E2E_SMARTCAR_CLIENT_ID', nil), ENV.fetch('E2E_SMARTCAR_CLIENT_SECRET', nil)], + query: { region: 'US', make: 'TESLA NISSAN' } + ) + .to_return( + { + status: 200, + headers: { 'content-type' => 'application/json; charset=utf-8' }, + body: { TESLA: [], NISSAN: [] }.to_json + } + ) + + response = subject.get_compatibility_matrix('US', { make: %w[TESLA NISSAN] }) + expect(response.TESLA).to eq([]) + expect(response.NISSAN).to eq([]) + end + end + + context 'when make is provided as string' do + it 'should use make string as-is' do + stub_request(:get, 'https://pizza.pasta.pi/v2.0/compatibility/matrix') + .with( + basic_auth: [ENV.fetch('E2E_SMARTCAR_CLIENT_ID', nil), ENV.fetch('E2E_SMARTCAR_CLIENT_SECRET', nil)], + query: { region: 'US', make: 'TESLA' } + ) + .to_return( + { + status: 200, + headers: { 'content-type' => 'application/json; charset=utf-8' }, + body: { TESLA: [] }.to_json + } + ) + + response = subject.get_compatibility_matrix('US', { make: 'TESLA' }) + expect(response.TESLA).to eq([]) + end + end + + context 'when type is provided' do + it 'should include type in query params' do + stub_request(:get, 'https://pizza.pasta.pi/v2.0/compatibility/matrix') + .with( + basic_auth: [ENV.fetch('E2E_SMARTCAR_CLIENT_ID', nil), ENV.fetch('E2E_SMARTCAR_CLIENT_SECRET', nil)], + query: { region: 'US', type: 'BEV' } + ) + .to_return( + { + status: 200, + headers: { 'content-type' => 'application/json; charset=utf-8' }, + body: { TESLA: [] }.to_json + } + ) + + response = subject.get_compatibility_matrix('US', { type: 'BEV' }) + expect(response.TESLA).to eq([]) + end + end + + context 'when all parameters are provided' do + it 'should include all parameters in query' do + stub_request(:get, 'https://pizza.pasta.pi/v2.0/compatibility/matrix') + .with( + basic_auth: [ENV.fetch('E2E_SMARTCAR_CLIENT_ID', nil), ENV.fetch('E2E_SMARTCAR_CLIENT_SECRET', nil)], + query: { region: 'US', make: 'TESLA', type: 'BEV', scope: 'read_battery read_charge' } + ) + .to_return( + { + status: 200, + headers: { 'content-type' => 'application/json; charset=utf-8' }, + body: { + TESLA: [ + { + model: 'Model S', + startYear: 2015, + endYear: 2023, + type: 'BEV', + endpoints: ['/battery', '/charge'], + permissions: ['read_battery', 'read_charge'] + } + ] + }.to_json + } + ) + + response = subject.get_compatibility_matrix('US', { + make: 'TESLA', + type: 'BEV', + scope: %w[read_battery read_charge] + }) + expect(response.TESLA).to be_an(Array) + expect(response.TESLA.first.model).to eq('Model S') + expect(response.TESLA.first.type).to eq('BEV') + end + end + + context 'when mode is set to test' do + it 'should add mode=test in query params' do + stub_request(:get, 'https://pizza.pasta.pi/v2.0/compatibility/matrix') + .with( + basic_auth: [ENV.fetch('E2E_SMARTCAR_CLIENT_ID', nil), ENV.fetch('E2E_SMARTCAR_CLIENT_SECRET', nil)], + query: { region: 'US', mode: 'test' } + ) + .to_return( + { + status: 200, + headers: { 'content-type' => 'application/json; charset=utf-8' }, + body: { TESLA: [] }.to_json + } + ) + + response = subject.get_compatibility_matrix('US', { mode: 'test' }) + expect(response.TESLA).to eq([]) + end + end + + context 'when a service object is provided' do + let(:mock_service) { Faraday.new(url: 'https://custom-api.smartcar.com') } + + it 'should use the provided service object' do + stub_request(:get, 'https://custom-api.smartcar.com/v2.0/compatibility/matrix?region=US') + .with( + basic_auth: [ENV.fetch('E2E_SMARTCAR_CLIENT_ID', nil), ENV.fetch('E2E_SMARTCAR_CLIENT_SECRET', nil)] + ) + .to_return( + { + status: 200, + headers: { 'content-type' => 'application/json; charset=utf-8' }, + body: { TESLA: [] }.to_json + } + ) + + response = subject.get_compatibility_matrix('US', { service: mock_service }) + expect(response.TESLA).to eq([]) + end + end + end + describe '.get_user' do context 'when a service object is provided' do let(:mock_service) { Faraday.new(url: 'https://custom-api.smartcar.com') } From 0022481942c52e73c587776ba6216a9cb8394316 Mon Sep 17 00:00:00 2001 From: nbry Date: Wed, 24 Dec 2025 09:31:38 -0800 Subject: [PATCH 3/6] feat: v3 signal requests and helpers --- lib/smartcar.rb | 1 + lib/smartcar/utils.rb | 37 +++++ lib/smartcar/vehicle.rb | 58 +++++++ spec/smartcar/integration/vehicle_spec.rb | 175 ++++++++++++++++++++++ 4 files changed, 271 insertions(+) diff --git a/lib/smartcar.rb b/lib/smartcar.rb index 05e20e6..3373eee 100644 --- a/lib/smartcar.rb +++ b/lib/smartcar.rb @@ -15,6 +15,7 @@ class ConfigNotFound < StandardError; end # Host to connect to smartcar API_ORIGIN = 'https://api.smartcar.com/' + VEHICLE_API_ORIGIN = 'https://vehicle.api.smartcar.com' MANAGEMENT_API_ORIGIN = 'https://management.smartcar.com' PATHS = { compatibility: '/compatibility', diff --git a/lib/smartcar/utils.rb b/lib/smartcar/utils.rb index c3d2f11..04e349c 100644 --- a/lib/smartcar/utils.rb +++ b/lib/smartcar/utils.rb @@ -60,6 +60,30 @@ def convert_to_ostruct_recursively(obj) end end + # Helper method to convert string from camelCase or kebab-case to snake_case + def to_snake_case(str) + str.to_s + .gsub('-', '_') # Convert kebab-case to snake_case + .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') + .gsub(/([a-z\d])([A-Z])/, '\1_\2') + .downcase + end + + # Helper method to recursively convert hash keys to snake_case + def deep_transform_keys_to_snake_case(obj) + case obj + when Array + obj.map { |el| deep_transform_keys_to_snake_case(el) } + when Hash + obj.each_with_object({}) do |(key, value), result| + new_key = to_snake_case(key) + result[new_key] = deep_transform_keys_to_snake_case(value) + end + else + obj + end + end + # Parse date string to DateTime or return nil on error def parse_date_safely(date_string) return nil unless date_string @@ -99,6 +123,19 @@ def build_response(body, headers) response end + def build_v3_response(body, headers) + body_data = body.is_a?(String) ? JSON.parse(body) : body + headers_data = headers.is_a?(String) ? JSON.parse(headers) : headers + + body_snake = deep_transform_keys_to_snake_case(body_data) + headers_snake = deep_transform_keys_to_snake_case(headers_data) + + OpenStruct.new( + body: json_to_ostruct(body_snake), + headers: json_to_ostruct(headers_snake) + ) + end + def build_aliases(response, aliases) (aliases || []).each do |original_name, alias_name| # rubocop:disable Lint/SymbolConversion diff --git a/lib/smartcar/vehicle.rb b/lib/smartcar/vehicle.rb index 1bc0007..4ae0ce1 100644 --- a/lib/smartcar/vehicle.rb +++ b/lib/smartcar/vehicle.rb @@ -371,6 +371,34 @@ def batch(paths) process_batch_response(response, headers) end + # Retrieve a specific signal by signal code from the vehicle. + # Uses the Vehicle API (v3) endpoint. + # + # API Documentation - https://smartcar.com/docs/api-reference/get-signal + # + # @param signal_code [String] The code of the signal to retrieve. + # + # @return [OpenStruct] An object with a "body" attribute containing the signal data + # and a "headers" attribute containing the response headers. + def get_signal(signal_code) + raise InvalidParameterValue.new, 'signal_code is a required field' if signal_code.nil? || signal_code.empty? + + path = "/vehicles/#{id}/signals/#{signal_code}" + request_v3(path) + end + + # Retrieve all available signals from the vehicle. + # Uses the Vehicle API (v3) endpoint. + # + # API Documentation - https://smartcar.com/docs/api-reference/get-signals + # + # @return [OpenStruct] An object with a "body" attribute containing all signals data + # and a "headers" attribute containing the response headers. + def get_signals + path = "/vehicles/#{id}/signals" + request_v3(path) + end + # General purpose method to make requests to the Smartcar API - can be # used to make requests to brand specific endpoints. # @@ -387,5 +415,35 @@ def request(method, path, body = {}, headers = {}) meta = build_meta(headers) json_to_ostruct({ body: raw_response, meta: meta }) end + + private + + # Makes a request to the vehicles API using the vehicle API origin and v3 version. + # + # @param path [String] The API path to request. + # + # @return [OpenStruct] An object with body and headers attributes. + def request_v3(path) + vehicle_service = Faraday.new( + url: ENV['SMARTCAR_VEHICLE_API_ORIGIN'] || VEHICLE_API_ORIGIN, + request: { timeout: DEFAULT_REQUEST_TIMEOUT } + ) + + response = vehicle_service.get do |request| + request.headers['Authorization'] = "Bearer #{token}" + request.headers['Content-Type'] = 'application/json' + request.headers['User-Agent'] = + "Smartcar/#{VERSION} (#{RbConfig::CONFIG['host_os']}; #{RbConfig::CONFIG['arch']}) Ruby v#{RUBY_VERSION}" + + complete_path = "/v3#{path}" + complete_path += "?#{URI.encode_www_form(@query_params.compact)}" unless @query_params.empty? + request.url complete_path + end + + handle_error(response) + + body = response.body.empty? ? '{}' : response.body + build_v3_response(body, response.headers.to_h) + end end end diff --git a/spec/smartcar/integration/vehicle_spec.rb b/spec/smartcar/integration/vehicle_spec.rb index c94c968..8683339 100644 --- a/spec/smartcar/integration/vehicle_spec.rb +++ b/spec/smartcar/integration/vehicle_spec.rb @@ -263,4 +263,179 @@ end end end + + describe '#get_signal' do + context 'when signal_code is nil or empty' do + it 'raises an error' do + expect { subject.get_signal(nil) }.to raise_error(Smartcar::Base::InvalidParameterValue, 'signal_code is a required field') + expect { subject.get_signal('') }.to raise_error(Smartcar::Base::InvalidParameterValue, 'signal_code is a required field') + end + end + + context 'when requesting a specific signal' do + it 'uses the vehicle API origin and v3 version' do + stub_request(:get, 'https://vehicle.api.smartcar.com/v3/vehicles/vehicle_id/signals/odometer-traveleddistance') + .with(headers: { 'Authorization' => 'Bearer token' }) + .to_return( + { + status: 200, + headers: { + 'content-type' => 'application/json', + 'sc-request-id' => 'signal-request-id' + }, + body: { + id: 'odometer-traveleddistance', + type: 'signal', + attributes: { + code: 'odometer-traveleddistance', + name: 'TraveledDistance', + group: 'Odometer', + status: { + value: 'SUCCESS' + }, + body: { + unit: 'kilometers', + value: 12345.6 + } + }, + meta: { + retrievedAt: 1752104218549, + oemUpdatedAt: 1752104118549 + }, + links: { + self: '/vehicles/vehicle_id/signals/odometer-traveleddistance' + } + }.to_json + } + ) + + result = subject.get_signal('odometer-traveleddistance') + + expect(result.body.attributes.body.value).to eq(12345.6) + expect(result.body.attributes.body.unit).to eq('kilometers') + expect(result.headers.content_type).to eq('application/json') + expect(result.headers.sc_request_id).to eq('signal-request-id') + end + end + + context 'when signal includes query parameters with flags' do + it 'includes flags in the request' do + subject = Smartcar::Vehicle.new( + token: 'token', + id: 'vehicle_id', + options: { flags: { country: 'DE' } } + ) + + stub_request(:get, 'https://vehicle.api.smartcar.com/v3/vehicles/vehicle_id/signals/odometer?flags=country%3ADE') + .with(headers: { 'Authorization' => 'Bearer token' }) + .to_return( + { + status: 200, + headers: { 'content-type' => 'application/json' }, + body: { + id: 'odometer-traveleddistance', + type: 'signal', + attributes: { + code: 'odometer-traveleddistance', + name: 'TraveledDistance', + group: 'Odometer', + status: { value: 'SUCCESS' }, + body: { + unit: 'kilometers', + value: 12345.6 + } + } + }.to_json + } + ) + + result = subject.get_signal('odometer') + expect(result.body.attributes.body.value).to eq(12345.6) + end + end + end + + describe '#get_signals' do + context 'when requesting all signals' do + it 'uses the vehicle API origin and v3 version' do + stub_request(:get, 'https://vehicle.api.smartcar.com/v3/vehicles/vehicle_id/signals') + .with(headers: { 'Authorization' => 'Bearer token' }) + .to_return( + { + status: 200, + headers: { + 'content-type' => 'application/json', + 'sc-request-id' => 'signals-request-id', + 'sc-data-age' => '2023-03-15T12:00:00Z' + }, + body: { + signals: [ + { + id: 'odometer-traveleddistance', + type: 'signal', + attributes: { + code: 'odometer-traveleddistance', + name: 'TraveledDistance', + group: 'Odometer', + status: { value: 'SUCCESS' }, + body: { + unit: 'kilometers', + value: 12345.6 + } + } + }, + { + id: 'odometer-traveleddistance', + type: 'signal', + attributes: { + code: 'odometer-traveleddistance', + name: 'TraveledDistance', + group: 'Odometer', + status: { value: 'SUCCESS' }, + body: { + unit: 'kilometers', + value: 12345.6 + } + } + } + ] + }.to_json + } + ) + + result = subject.get_signals + + expect(result.body.signals).to be_an(Array) + expect(result.body.signals.length).to eq(2) + expect(result.body.signals[0].id).to eq('odometer-traveleddistance') + expect(result.body.signals[1].id).to eq('odometer-traveleddistance') + expect(result.body.signals[0].attributes.body.value).to eq(12345.6) + expect(result.headers.content_type).to eq('application/json') + expect(result.headers.sc_request_id).to eq('signals-request-id') + expect(result.headers.sc_data_age).to eq('2023-03-15T12:00:00Z') + end + end + + context 'when using custom vehicle API origin via environment' do + it 'uses the custom origin' do + original_origin = ENV['SMARTCAR_VEHICLE_API_ORIGIN'] + ENV['SMARTCAR_VEHICLE_API_ORIGIN'] = 'https://custom-vehicle-api.smartcar.com' + + stub_request(:get, 'https://custom-vehicle-api.smartcar.com/v3/vehicles/vehicle_id/signals') + .with(headers: { 'Authorization' => 'Bearer token' }) + .to_return( + { + status: 200, + headers: { 'content-type' => 'application/json' }, + body: { signals: [] }.to_json + } + ) + + result = subject.get_signals + expect(result.body.signals).to eq([]) + + ENV['SMARTCAR_VEHICLE_API_ORIGIN'] = original_origin + end + end + end end From 6a1b88952821965613414a57bf6d7c64f66c11b0 Mon Sep 17 00:00:00 2001 From: nbry Date: Wed, 24 Dec 2025 09:48:19 -0800 Subject: [PATCH 4/6] feat: get_vehicle --- lib/smartcar.rb | 39 ++++++++ spec/smartcar/integration/smartcar_spec.rb | 102 +++++++++++++++++++++ 2 files changed, 141 insertions(+) diff --git a/lib/smartcar.rb b/lib/smartcar.rb index 3373eee..97b1522 100644 --- a/lib/smartcar.rb +++ b/lib/smartcar.rb @@ -267,6 +267,45 @@ def delete_connections(amt:, filter: {}, options: {}) )) end + # Module method to retrieve vehicle information using the v3 API. + # + # API Documentation - https://smartcar.com/docs/api-reference/get-vehicle + # @param vehicle_id [String] The vehicle ID + # @param token [String] Access token + # @param options [Hash] Optional parameters + # @option options [Hash] :flags A hash of flag name string as key and a string or boolean value. + # @option options [Faraday::Connection] :service Optional connection object to be used for requests + # + # @return [OpenStruct] An object with a "body" attribute containing the vehicle data + # and a "headers" attribute containing the response headers. + def get_vehicle(vehicle_id:, token:, options: {}) + raise Base::InvalidParameterValue.new, 'vehicle_id is a required field' if vehicle_id.nil? || vehicle_id.empty? + raise Base::InvalidParameterValue.new, 'token is a required field' if token.nil? || token.empty? + + vehicle_service = Faraday.new( + url: ENV['SMARTCAR_VEHICLE_API_ORIGIN'] || VEHICLE_API_ORIGIN, + request: { timeout: DEFAULT_REQUEST_TIMEOUT } + ) + + query_params = { flags: stringify_params(options[:flags]) } + + response = vehicle_service.get do |request| + request.headers['Authorization'] = "Bearer #{token}" + request.headers['Content-Type'] = 'application/json' + request.headers['User-Agent'] = + "Smartcar/#{VERSION} (#{RbConfig::CONFIG['host_os']}; #{RbConfig::CONFIG['arch']}) Ruby v#{RUBY_VERSION}" + + complete_path = "/v3/vehicles/#{vehicle_id}" + complete_path += "?#{URI.encode_www_form(query_params.compact)}" unless query_params.empty? + request.url complete_path + end + + raise build_error(response.status, response.body, response.headers) unless [200, 204].include?(response.status) + + body = response.body.empty? ? '{}' : response.body + build_v3_response(body, response.headers.to_h) + end + # returns auth token for Basic vehicle management auth # # @return [String] Base64 encoding of default:amt diff --git a/spec/smartcar/integration/smartcar_spec.rb b/spec/smartcar/integration/smartcar_spec.rb index d13f795..11b9e67 100644 --- a/spec/smartcar/integration/smartcar_spec.rb +++ b/spec/smartcar/integration/smartcar_spec.rb @@ -712,4 +712,106 @@ expect(response.connections[0].vehicleId).to eq('vehicle_id') end end + + describe '.get_vehicle' do + context 'when vehicle_id is nil or empty' do + it 'raises an error' do + expect { subject.get_vehicle(vehicle_id: nil, token: 'token') }.to raise_error(Smartcar::Base::InvalidParameterValue, 'vehicle_id is a required field') + expect { subject.get_vehicle(vehicle_id: '', token: 'token') }.to raise_error(Smartcar::Base::InvalidParameterValue, 'vehicle_id is a required field') + end + end + + context 'when token is nil or empty' do + it 'raises an error' do + expect { subject.get_vehicle(vehicle_id: 'vehicle_id', token: nil) }.to raise_error(Smartcar::Base::InvalidParameterValue, 'token is a required field') + expect { subject.get_vehicle(vehicle_id: 'vehicle_id', token: '') }.to raise_error(Smartcar::Base::InvalidParameterValue, 'token is a required field') + end + end + + context 'when requesting vehicle information' do + it 'uses the vehicle API origin and v3 version' do + stub_request(:get, 'https://vehicle.api.smartcar.com/v3/vehicles/vehicle_id') + .with(headers: { 'Authorization' => 'Bearer token' }) + .to_return( + { + status: 200, + headers: { + 'content-type' => 'application/json', + 'sc-request-id' => 'vehicle-request-id' + }, + body: { + id: 'vehicle_id', + type: 'vehicle', + attributes: { + make: 'TESLA', + model: 'Model 3', + year: 2021 + } + }.to_json + } + ) + + result = subject.get_vehicle(vehicle_id: 'vehicle_id', token: 'token') + + expect(result.body.id).to eq('vehicle_id') + expect(result.body.type).to eq('vehicle') + expect(result.body.attributes.make).to eq('TESLA') + expect(result.body.attributes.model).to eq('Model 3') + expect(result.body.attributes.year).to eq(2021) + expect(result.headers.content_type).to eq('application/json') + expect(result.headers.sc_request_id).to eq('vehicle-request-id') + end + end + + context 'when using flags' do + it 'includes flags in the request' do + stub_request(:get, 'https://vehicle.api.smartcar.com/v3/vehicles/vehicle_id?flags=country%3ADE') + .with(headers: { 'Authorization' => 'Bearer token' }) + .to_return( + { + status: 200, + headers: { 'content-type' => 'application/json' }, + body: { + id: 'vehicle_id', + type: 'vehicle', + attributes: { + make: 'BMW', + model: 'i3', + year: 2020 + } + }.to_json + } + ) + + result = subject.get_vehicle(vehicle_id: 'vehicle_id', token: 'token', options: { flags: { country: 'DE' } }) + expect(result.body.attributes.make).to eq('BMW') + end + end + + context 'when using custom vehicle API origin via environment' do + it 'uses the custom origin' do + original_origin = ENV['SMARTCAR_VEHICLE_API_ORIGIN'] + ENV['SMARTCAR_VEHICLE_API_ORIGIN'] = 'https://custom-vehicle-api.smartcar.com' + + stub_request(:get, 'https://custom-vehicle-api.smartcar.com/v3/vehicles/vehicle_id') + .with(headers: { 'Authorization' => 'Bearer token' }) + .to_return( + { + status: 200, + headers: { 'content-type' => 'application/json' }, + body: { + id: 'vehicle_id', + type: 'vehicle', + attributes: {} + }.to_json + } + ) + + result = subject.get_vehicle(vehicle_id: 'vehicle_id', token: 'token') + expect(result.body.id).to eq('vehicle_id') + + ENV['SMARTCAR_VEHICLE_API_ORIGIN'] = original_origin + end + end + end end From c322fb415f7631bd5afabd06c7b7a31fc756fe19 Mon Sep 17 00:00:00 2001 From: nbry Date: Wed, 24 Dec 2025 09:52:38 -0800 Subject: [PATCH 5/6] fix: linting --- lib/smartcar.rb | 3 ++- lib/smartcar/auth_client.rb | 4 ++-- lib/smartcar/utils.rb | 2 +- spec/smartcar/integration/smartcar_spec.rb | 26 ++++++++++++++------ spec/smartcar/integration/vehicle_spec.rb | 28 ++++++++++++---------- spec/smartcar/unit/auth_client_spec.rb | 4 ++-- 6 files changed, 42 insertions(+), 25 deletions(-) diff --git a/lib/smartcar.rb b/lib/smartcar.rb index 97b1522..ecf8c93 100644 --- a/lib/smartcar.rb +++ b/lib/smartcar.rb @@ -114,7 +114,8 @@ def get_compatibility(vin:, scope:, country: 'US', options: {}) # @param region [String] One of the following regions: US, CA, or EUROPE # @param options [Hash] Optional parameters # @option options [Array] :scope List of permissions to filter the matrix by - # @option options [String, Array] :make List of makes to filter the matrix by (space-separated string or array) + # @option options [String, Array] :make List of makes to filter the matrix by + # (space-separated string or array) # @option options [String] :type Engine type to filter the matrix by (e.g., "ICE", "HEV", "PHEV", "BEV") # @option options [String] :client_id Client ID that overrides ENV # @option options [String] :client_secret Client Secret that overrides ENV diff --git a/lib/smartcar/auth_client.rb b/lib/smartcar/auth_client.rb index c3e43f9..5aa5f7a 100644 --- a/lib/smartcar/auth_client.rb +++ b/lib/smartcar/auth_client.rb @@ -22,7 +22,7 @@ class AuthClient # Should be one of test, live or simulated. # @return [Smartcar::AuthClient] Returns a Smartcar::AuthClient Object that has other methods def initialize(options) - options[:redirect_uri] ||= get_config('SMARTCAR_REDIRECT_URI', { nullable: true}) + options[:redirect_uri] ||= get_config('SMARTCAR_REDIRECT_URI', { nullable: true }) options[:client_id] ||= get_config('SMARTCAR_CLIENT_ID') options[:client_secret] ||= get_config('SMARTCAR_CLIENT_SECRET') options[:auth_origin] = ENV['SMARTCAR_AUTH_ORIGIN'] || AUTH_ORIGIN @@ -65,7 +65,7 @@ def get_auth_url(scope_or_options = {}, options = {}) scope = nil if scope_or_options.is_a?(Array) scope = scope_or_options - options = options || {} + options ||= {} elsif scope_or_options.is_a?(Hash) options = scope_or_options end diff --git a/lib/smartcar/utils.rb b/lib/smartcar/utils.rb index 04e349c..eb20dd8 100644 --- a/lib/smartcar/utils.rb +++ b/lib/smartcar/utils.rb @@ -63,7 +63,7 @@ def convert_to_ostruct_recursively(obj) # Helper method to convert string from camelCase or kebab-case to snake_case def to_snake_case(str) str.to_s - .gsub('-', '_') # Convert kebab-case to snake_case + .gsub('-', '_') # Convert kebab-case to snake_case .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') .gsub(/([a-z\d])([A-Z])/, '\1_\2') .downcase diff --git a/spec/smartcar/integration/smartcar_spec.rb b/spec/smartcar/integration/smartcar_spec.rb index 11b9e67..6b32758 100644 --- a/spec/smartcar/integration/smartcar_spec.rb +++ b/spec/smartcar/integration/smartcar_spec.rb @@ -300,7 +300,7 @@ endYear: 2023, type: 'BEV', endpoints: ['/battery', '/charge'], - permissions: ['read_battery', 'read_charge'] + permissions: %w[read_battery read_charge] } ] }.to_json @@ -413,7 +413,7 @@ endYear: 2023, type: 'BEV', endpoints: ['/battery', '/charge'], - permissions: ['read_battery', 'read_charge'] + permissions: %w[read_battery read_charge] } ] }.to_json @@ -716,15 +716,27 @@ describe '.get_vehicle' do context 'when vehicle_id is nil or empty' do it 'raises an error' do - expect { subject.get_vehicle(vehicle_id: nil, token: 'token') }.to raise_error(Smartcar::Base::InvalidParameterValue, 'vehicle_id is a required field') - expect { subject.get_vehicle(vehicle_id: '', token: 'token') }.to raise_error(Smartcar::Base::InvalidParameterValue, 'vehicle_id is a required field') + expect do + subject.get_vehicle(vehicle_id: nil, + token: 'token') + end.to raise_error(Smartcar::Base::InvalidParameterValue, 'vehicle_id is a required field') + expect do + subject.get_vehicle(vehicle_id: '', + token: 'token') + end.to raise_error(Smartcar::Base::InvalidParameterValue, 'vehicle_id is a required field') end end context 'when token is nil or empty' do it 'raises an error' do - expect { subject.get_vehicle(vehicle_id: 'vehicle_id', token: nil) }.to raise_error(Smartcar::Base::InvalidParameterValue, 'token is a required field') - expect { subject.get_vehicle(vehicle_id: 'vehicle_id', token: '') }.to raise_error(Smartcar::Base::InvalidParameterValue, 'token is a required field') + expect do + subject.get_vehicle(vehicle_id: 'vehicle_id', + token: nil) + end.to raise_error(Smartcar::Base::InvalidParameterValue, 'token is a required field') + expect do + subject.get_vehicle(vehicle_id: 'vehicle_id', + token: '') + end.to raise_error(Smartcar::Base::InvalidParameterValue, 'token is a required field') end end @@ -790,7 +802,7 @@ context 'when using custom vehicle API origin via environment' do it 'uses the custom origin' do - original_origin = ENV['SMARTCAR_VEHICLE_API_ORIGIN'] + original_origin = ENV.fetch('SMARTCAR_VEHICLE_API_ORIGIN', nil) ENV['SMARTCAR_VEHICLE_API_ORIGIN'] = 'https://custom-vehicle-api.smartcar.com' stub_request(:get, 'https://custom-vehicle-api.smartcar.com/v3/vehicles/vehicle_id') diff --git a/spec/smartcar/integration/vehicle_spec.rb b/spec/smartcar/integration/vehicle_spec.rb index 8683339..f6ae015 100644 --- a/spec/smartcar/integration/vehicle_spec.rb +++ b/spec/smartcar/integration/vehicle_spec.rb @@ -267,8 +267,12 @@ describe '#get_signal' do context 'when signal_code is nil or empty' do it 'raises an error' do - expect { subject.get_signal(nil) }.to raise_error(Smartcar::Base::InvalidParameterValue, 'signal_code is a required field') - expect { subject.get_signal('') }.to raise_error(Smartcar::Base::InvalidParameterValue, 'signal_code is a required field') + expect do + subject.get_signal(nil) + end.to raise_error(Smartcar::Base::InvalidParameterValue, 'signal_code is a required field') + expect do + subject.get_signal('') + end.to raise_error(Smartcar::Base::InvalidParameterValue, 'signal_code is a required field') end end @@ -295,12 +299,12 @@ }, body: { unit: 'kilometers', - value: 12345.6 + value: 12_345.6 } }, meta: { - retrievedAt: 1752104218549, - oemUpdatedAt: 1752104118549 + retrievedAt: 1_752_104_218_549, + oemUpdatedAt: 1_752_104_118_549 }, links: { self: '/vehicles/vehicle_id/signals/odometer-traveleddistance' @@ -311,7 +315,7 @@ result = subject.get_signal('odometer-traveleddistance') - expect(result.body.attributes.body.value).to eq(12345.6) + expect(result.body.attributes.body.value).to eq(12_345.6) expect(result.body.attributes.body.unit).to eq('kilometers') expect(result.headers.content_type).to eq('application/json') expect(result.headers.sc_request_id).to eq('signal-request-id') @@ -342,7 +346,7 @@ status: { value: 'SUCCESS' }, body: { unit: 'kilometers', - value: 12345.6 + value: 12_345.6 } } }.to_json @@ -350,7 +354,7 @@ ) result = subject.get_signal('odometer') - expect(result.body.attributes.body.value).to eq(12345.6) + expect(result.body.attributes.body.value).to eq(12_345.6) end end end @@ -380,7 +384,7 @@ status: { value: 'SUCCESS' }, body: { unit: 'kilometers', - value: 12345.6 + value: 12_345.6 } } }, @@ -394,7 +398,7 @@ status: { value: 'SUCCESS' }, body: { unit: 'kilometers', - value: 12345.6 + value: 12_345.6 } } } @@ -409,7 +413,7 @@ expect(result.body.signals.length).to eq(2) expect(result.body.signals[0].id).to eq('odometer-traveleddistance') expect(result.body.signals[1].id).to eq('odometer-traveleddistance') - expect(result.body.signals[0].attributes.body.value).to eq(12345.6) + expect(result.body.signals[0].attributes.body.value).to eq(12_345.6) expect(result.headers.content_type).to eq('application/json') expect(result.headers.sc_request_id).to eq('signals-request-id') expect(result.headers.sc_data_age).to eq('2023-03-15T12:00:00Z') @@ -418,7 +422,7 @@ context 'when using custom vehicle API origin via environment' do it 'uses the custom origin' do - original_origin = ENV['SMARTCAR_VEHICLE_API_ORIGIN'] + original_origin = ENV.fetch('SMARTCAR_VEHICLE_API_ORIGIN', nil) ENV['SMARTCAR_VEHICLE_API_ORIGIN'] = 'https://custom-vehicle-api.smartcar.com' stub_request(:get, 'https://custom-vehicle-api.smartcar.com/v3/vehicles/vehicle_id/signals') diff --git a/spec/smartcar/unit/auth_client_spec.rb b/spec/smartcar/unit/auth_client_spec.rb index 51d9fe2..aeeaaaf 100644 --- a/spec/smartcar/unit/auth_client_spec.rb +++ b/spec/smartcar/unit/auth_client_spec.rb @@ -188,7 +188,7 @@ it 'should work without redirect_uri parameter' do # Temporarily remove the E2E_SMARTCAR_REDIRECT_URI env var to test without redirect_uri - original_redirect = ENV['E2E_SMARTCAR_REDIRECT_URI'] + original_redirect = ENV.fetch('E2E_SMARTCAR_REDIRECT_URI', nil) ENV.delete('E2E_SMARTCAR_REDIRECT_URI') client = Smartcar::AuthClient.new({ @@ -217,7 +217,7 @@ it 'should work without both scope and redirect_uri' do # Temporarily remove the E2E_SMARTCAR_REDIRECT_URI env var to test without redirect_uri - original_redirect = ENV['E2E_SMARTCAR_REDIRECT_URI'] + original_redirect = ENV.fetch('E2E_SMARTCAR_REDIRECT_URI', nil) ENV.delete('E2E_SMARTCAR_REDIRECT_URI') client = Smartcar::AuthClient.new({ From 20fb9b4d5efafc49fa4bc3110b31b4b6fcbc6148 Mon Sep 17 00:00:00 2001 From: nbry Date: Wed, 31 Dec 2025 09:00:54 -0800 Subject: [PATCH 6/6] test: e2e --- spec/smartcar/e2e/vehicle_spec.rb | 81 +++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/spec/smartcar/e2e/vehicle_spec.rb b/spec/smartcar/e2e/vehicle_spec.rb index 4296b28..643760f 100644 --- a/spec/smartcar/e2e/vehicle_spec.rb +++ b/spec/smartcar/e2e/vehicle_spec.rb @@ -6,6 +6,87 @@ RSpec.describe Smartcar::Vehicle do subject { Smartcar::Vehicle } + describe 'V3' do + before(:context) do + access_token = 'test-data-token' + vehicle_id = 'tst2e255-d3c8-4f90-9fec-e6e68b98e9cb' + @vehicle = Smartcar::Vehicle.new(token: access_token, id: vehicle_id) + end + + describe '#get_signals' do + it 'should return signal data' do + result = @vehicle.get_signals + expect(result.body.data.length).to eq(2) + + odometer_signal = result.body.data.find { |signal| signal.attributes.code == 'odometer-traveleddistance' } + + # Check signal structure + expect(odometer_signal.id).to be_a(String) + expect(odometer_signal.type).to eq('signal') + + # Check attributes + expect(odometer_signal.attributes.code).to eq('odometer-traveleddistance') + expect(odometer_signal.attributes.name).to be_a(String) + expect(odometer_signal.attributes.group).to be_a(String) + expect(odometer_signal.attributes.status.value).to eq('SUCCESS') + + # Check body values + expect(odometer_signal.attributes.body.value.is_a?(Numeric)).to eq(true) + expect(odometer_signal.attributes.body.unit).to be_a(String) + + # Check meta + expect(odometer_signal.meta.retrieved_at.is_a?(Numeric)).to eq(true) + expect(odometer_signal.meta.oem_updated_at.is_a?(Numeric)).to eq(true) + + # Check links + expect(odometer_signal.links.self).to be_a(String) + + # Check top-level meta + expect(result.body.meta.total_count).to eq(2) + expect(result.body.meta.page_size).to eq(2) + expect(result.body.meta.page).to eq(1) + + # Check top-level links + expect(result.body.links.self).to be_a(String) + + # Check included vehicle data + expect(result.body.included.vehicle.id).to be_a(String) + expect(result.body.included.vehicle.type).to eq('vehicle') + expect(result.body.included.vehicle.attributes.make).to be_a(String) + expect(result.body.included.vehicle.attributes.model).to be_a(String) + expect(result.body.included.vehicle.attributes.year).to be_a(String) + end + end + + describe '#get_signal' do + it 'should return signal data' do + signal_code = 'odometer-traveleddistance' + result = @vehicle.get_signal(signal_code) + + # Check signal structure + expect(result.body.id).to be_a(String) + expect(result.body.type).to eq('signal') + + # Check attributes + expect(result.body.attributes.code).to eq('odometer-traveleddistance') + expect(result.body.attributes.name).to be_a(String) + expect(result.body.attributes.group).to be_a(String) + expect(result.body.attributes.status.value).to eq('SUCCESS') + + # Check body values + expect(result.body.attributes.body.value.is_a?(Numeric)).to eq(true) + expect(result.body.attributes.body.unit).to be_a(String) + + # Check meta + expect(result.body.meta.retrieved_at.is_a?(Numeric)).to eq(true) + expect(result.body.meta.oem_updated_at.is_a?(Numeric)).to eq(true) + + # Check links + expect(result.body.links.self).to be_a(String) + end + end + end + describe 'Data methods' do before(:context) do @token = AuthHelper.run_auth_flow_and_get_tokens[:access_token]