diff --git a/lib/smartcar.rb b/lib/smartcar.rb index 128563e..ecf8c93 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', @@ -101,6 +102,49 @@ 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 @@ -224,6 +268,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 @@ -252,6 +335,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/lib/smartcar/auth_client.rb b/lib/smartcar/auth_client.rb index f4049e9..5aa5f7a 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 ||= {} + 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..eb20dd8 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 @@ -53,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 @@ -92,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/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] 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/integration/smartcar_spec.rb b/spec/smartcar/integration/smartcar_spec.rb index b550fd0..6b32758 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: %w[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: %w[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') } @@ -483,4 +712,118 @@ 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 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 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 + + 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.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') + .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 diff --git a/spec/smartcar/integration/vehicle_spec.rb b/spec/smartcar/integration/vehicle_spec.rb index c94c968..f6ae015 100644 --- a/spec/smartcar/integration/vehicle_spec.rb +++ b/spec/smartcar/integration/vehicle_spec.rb @@ -263,4 +263,183 @@ end end end + + describe '#get_signal' do + context 'when signal_code is nil or empty' do + it 'raises an error' do + 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 + + 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: 12_345.6 + } + }, + meta: { + retrievedAt: 1_752_104_218_549, + oemUpdatedAt: 1_752_104_118_549 + }, + links: { + self: '/vehicles/vehicle_id/signals/odometer-traveleddistance' + } + }.to_json + } + ) + + result = subject.get_signal('odometer-traveleddistance') + + 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') + 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: 12_345.6 + } + } + }.to_json + } + ) + + result = subject.get_signal('odometer') + expect(result.body.attributes.body.value).to eq(12_345.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: 12_345.6 + } + } + }, + { + id: 'odometer-traveleddistance', + type: 'signal', + attributes: { + code: 'odometer-traveleddistance', + name: 'TraveledDistance', + group: 'Odometer', + status: { value: 'SUCCESS' }, + body: { + unit: 'kilometers', + value: 12_345.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(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') + end + end + + context 'when using custom vehicle API origin via environment' do + it 'uses the custom origin' do + 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') + .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 diff --git a/spec/smartcar/unit/auth_client_spec.rb b/spec/smartcar/unit/auth_client_spec.rb index ee8408e..aeeaaaf 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.fetch('E2E_SMARTCAR_REDIRECT_URI', nil) + 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.fetch('E2E_SMARTCAR_REDIRECT_URI', nil) + 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