From 4f2c5492cdce45df42a7eb11c8c6fc42f9da4de5 Mon Sep 17 00:00:00 2001 From: Baptiste LEVESQUE Date: Wed, 2 Jul 2025 17:52:10 +0200 Subject: [PATCH 1/2] Implements sub account, token and balance api call --- Gemfile | 2 + Gemfile.lock | 44 ++- lib/sms_factor.rb | 27 +- lib/sms_factor/errors.rb | 24 ++ lib/sms_factor/headers.rb | 19 ++ lib/sms_factor/response_factory.rb | 29 ++ lib/sms_factor/responses/balance_response.rb | 14 + lib/sms_factor/responses/base_response.rb | 20 ++ .../responses/create_sub_account_response.rb | 12 + .../responses/create_token_response.rb | 13 + .../responses/delete_token_response.rb | 11 + lib/sms_factor/responses/sub_account.rb | 31 ++ .../responses/sub_account_response.rb | 14 + .../responses/sub_accounts_response.rb | 12 + .../responses/toggle_lock_response.rb | 11 + lib/sms_factor/responses/token_details.rb | 18 ++ lib/sms_factor/responses/token_response.rb | 14 + lib/sms_factor/responses/tokens_response.rb | 12 + .../responses/transfer_credits_response.rb | 12 + lib/sms_factor/sub_account.rb | 106 +++++++ lib/sms_factor/token.rb | 82 ++++++ lib/sms_factor/types.rb | 9 + sms_factor.gemspec | 7 +- spec/sms_factor/response_factory_spec.rb | 58 ++++ spec/sms_factor/sub_account_spec.rb | 266 ++++++++++++++++++ spec/sms_factor/token_spec.rb | 196 +++++++++++++ spec/spec_helper.rb | 2 + spec/support/sms_factor_api.rb | 40 +++ 28 files changed, 1077 insertions(+), 28 deletions(-) create mode 100644 lib/sms_factor/errors.rb create mode 100644 lib/sms_factor/headers.rb create mode 100644 lib/sms_factor/response_factory.rb create mode 100644 lib/sms_factor/responses/balance_response.rb create mode 100644 lib/sms_factor/responses/base_response.rb create mode 100644 lib/sms_factor/responses/create_sub_account_response.rb create mode 100644 lib/sms_factor/responses/create_token_response.rb create mode 100644 lib/sms_factor/responses/delete_token_response.rb create mode 100644 lib/sms_factor/responses/sub_account.rb create mode 100644 lib/sms_factor/responses/sub_account_response.rb create mode 100644 lib/sms_factor/responses/sub_accounts_response.rb create mode 100644 lib/sms_factor/responses/toggle_lock_response.rb create mode 100644 lib/sms_factor/responses/token_details.rb create mode 100644 lib/sms_factor/responses/token_response.rb create mode 100644 lib/sms_factor/responses/tokens_response.rb create mode 100644 lib/sms_factor/responses/transfer_credits_response.rb create mode 100644 lib/sms_factor/sub_account.rb create mode 100644 lib/sms_factor/token.rb create mode 100644 lib/sms_factor/types.rb create mode 100644 spec/sms_factor/response_factory_spec.rb create mode 100644 spec/sms_factor/sub_account_spec.rb create mode 100644 spec/sms_factor/token_spec.rb create mode 100644 spec/support/sms_factor_api.rb diff --git a/Gemfile b/Gemfile index f9f7a87..986d4a9 100644 --- a/Gemfile +++ b/Gemfile @@ -5,6 +5,8 @@ gemspec # When updating the following gems, run `earthly +dev` in the case you're using # Docker to develop this gem. +gem 'dry-struct' +gem 'dry-types' gem 'ffaker' gem 'rake' gem 'rspec' diff --git a/Gemfile.lock b/Gemfile.lock index ab9f227..e356342 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,6 +2,8 @@ PATH remote: . specs: sms_factor (0.2.2) + dry-struct (~> 1.6) + dry-types (~> 1.7) nokogiri (~> 1.13, < 1.14) rest-client (~> 2.0.2, >= 2.0.2) @@ -11,19 +13,41 @@ GEM addressable (2.8.4) public_suffix (>= 2.0.2, < 6.0) ast (2.4.2) + concurrent-ruby (1.3.5) crack (0.4.5) rexml diff-lcs (1.5.0) - domain_name (0.5.20190701) - unf (>= 0.0.5, < 1.0.0) + domain_name (0.6.20240107) + dry-core (1.0.0) + concurrent-ruby (~> 1.0) + zeitwerk (~> 2.6) + dry-inflector (1.1.0) + dry-logic (1.5.0) + concurrent-ruby (~> 1.0) + dry-core (~> 1.0, < 2) + zeitwerk (~> 2.6) + dry-struct (1.6.0) + dry-core (~> 1.0, < 2) + dry-types (>= 1.7, < 2) + ice_nine (~> 0.11) + zeitwerk (~> 2.6) + dry-types (1.7.1) + concurrent-ruby (~> 1.0) + dry-core (~> 1.0) + dry-inflector (~> 1.0) + dry-logic (~> 1.4) + zeitwerk (~> 2.6) ffaker (2.21.0) hashdiff (1.0.1) - http-cookie (1.0.5) + http-cookie (1.0.8) domain_name (~> 0.5) + ice_nine (0.11.2) json (2.6.3) - mime-types (3.4.1) - mime-types-data (~> 3.2015) - mime-types-data (3.2023.0218.1) + logger (1.7.0) + mime-types (3.7.0) + logger + mime-types-data (~> 3.2025, >= 3.2025.0507) + mime-types-data (3.2025.0701) netrc (0.11.0) nokogiri (1.13.10-x86_64-linux) racc (~> 1.4) @@ -31,7 +55,7 @@ GEM parser (3.2.2.0) ast (~> 2.4.1) public_suffix (5.0.1) - racc (1.6.2) + racc (1.8.1) rainbow (3.1.1) rake (13.0.6) regexp_parser (2.7.0) @@ -71,20 +95,20 @@ GEM rubocop (~> 1.33) rubocop-capybara (~> 2.17) ruby-progressbar (1.13.0) - unf (0.1.4) - unf_ext - unf_ext (0.0.8.2) unicode-display_width (2.4.2) vcr (6.1.0) webmock (3.18.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) + zeitwerk (2.6.18) PLATFORMS ruby DEPENDENCIES + dry-struct + dry-types ffaker rake rspec diff --git a/lib/sms_factor.rb b/lib/sms_factor.rb index 441a2f2..35e8fdc 100644 --- a/lib/sms_factor.rb +++ b/lib/sms_factor.rb @@ -42,7 +42,7 @@ def deliver(delay: :now, check: false, api_key: nil) RestClient.post( sms_factor_url(check), { data: build_deliver_data_from(delay).to_json }, - sms_factor_api_headers(api_key) + SmsFactor::Headers.api_headers(api_key) ) ) end @@ -65,19 +65,6 @@ def build_deliver_data_from(delay) data end - def sms_factor_api_headers(api_key = null) - headers = { - accept: :json, - verify_ssl: false - } - - if SmsFactor::Init.configuration.api_auth? - headers[:Authorization] = "Bearer #{api_key || SmsFactor::Init.configuration.api_key}" - end - - headers - end - def sms_factor_url(check) url = "#{SmsFactor::Init.configuration.api_url}/send" url += '/simulate' if check @@ -85,6 +72,18 @@ def sms_factor_url(check) end end +# core utils require 'sms_factor/configuration' +require 'sms_factor/errors' +require 'sms_factor/headers' require 'sms_factor/init' +require 'sms_factor/response_factory' +require 'sms_factor/types' + +# responses require 'sms_factor/sms_response' +Dir[File.expand_path('sms_factor/responses/**/*.rb', __dir__)].sort.each { |f| require f } + +# services +require 'sms_factor/sub_account' +require 'sms_factor/token' diff --git a/lib/sms_factor/errors.rb b/lib/sms_factor/errors.rb new file mode 100644 index 0000000..e99ec0a --- /dev/null +++ b/lib/sms_factor/errors.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class SmsFactor + class ApiError < StandardError + attr_reader :status, :message + + def initialize(status, message) + @status = status + @message = message + super("[API error #{status}] #{message}") + end + end + + class AuthError < ApiError; end + class XmlError < ApiError; end + class NotEnoughCreditsError < ApiError; end + class DateError < ApiError; end + class ResourceNotFoundError < ApiError; end + class JsonError < ApiError; end + class DataError < ApiError; end + class ModerationError < ApiError; end + class InvalidTokenIdError < ApiError; end + class UnknownApiError < ApiError; end +end diff --git a/lib/sms_factor/headers.rb b/lib/sms_factor/headers.rb new file mode 100644 index 0000000..d5fd323 --- /dev/null +++ b/lib/sms_factor/headers.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class SmsFactor + module Headers + def self.api_headers(api_key = nil) + headers = { + accept: :json, + content_type: :json, + verify_ssl: false + } + + if SmsFactor::Init.configuration.api_auth? + headers[:Authorization] = "Bearer #{api_key || SmsFactor::Init.configuration.api_key}" + end + + headers + end + end +end diff --git a/lib/sms_factor/response_factory.rb b/lib/sms_factor/response_factory.rb new file mode 100644 index 0000000..7a68f6a --- /dev/null +++ b/lib/sms_factor/response_factory.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class SmsFactor + class ResponseFactory + ERROR_CLASSES = { + -1 => SmsFactor::AuthError, + -2 => SmsFactor::XmlError, + -3 => SmsFactor::NotEnoughCreditsError, + -4 => SmsFactor::DateError, + -5 => SmsFactor::ResourceNotFoundError, + -6 => SmsFactor::JsonError, + -7 => SmsFactor::DataError, + -8 => SmsFactor::ModerationError, + -10 => SmsFactor::InvalidTokenIdError + }.freeze + + def self.build(response, klass) + parsed = JSON.parse(response.body, symbolize_names: true) + + raise error_class(parsed[:status]).new(parsed[:status], parsed[:message]) unless parsed[:status] == 1 + + klass.new(parsed) + end + + def self.error_class(status) + ERROR_CLASSES[status] || SmsFactor::UnknownApiError + end + end +end diff --git a/lib/sms_factor/responses/balance_response.rb b/lib/sms_factor/responses/balance_response.rb new file mode 100644 index 0000000..9402fe9 --- /dev/null +++ b/lib/sms_factor/responses/balance_response.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require 'sms_factor/responses/base_response' + +class SmsFactor + module Responses + class BalanceResponse < BaseResponse + attribute :credits, SmsFactor::Types::Coercible::Integer + attribute :postpaid, SmsFactor::Types::Coercible::Integer + attribute :postpaid_limit, SmsFactor::Types::Strict::Bool | SmsFactor::Types::Coercible::Integer + attribute :unlimited, SmsFactor::Types::Strict::Bool + end + end +end diff --git a/lib/sms_factor/responses/base_response.rb b/lib/sms_factor/responses/base_response.rb new file mode 100644 index 0000000..ac8c61d --- /dev/null +++ b/lib/sms_factor/responses/base_response.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'dry-struct' +require 'sms_factor/types' +require 'sms_factor/errors' + +class SmsFactor + module Responses + class BaseResponse < Dry::Struct + transform_keys { |k| k.to_s.gsub('-', '_').to_sym } + + attribute :status, SmsFactor::Types::Strict::Integer + attribute :message, SmsFactor::Types::Strict::String + + def success? + status == 1 + end + end + end +end diff --git a/lib/sms_factor/responses/create_sub_account_response.rb b/lib/sms_factor/responses/create_sub_account_response.rb new file mode 100644 index 0000000..3f3e67a --- /dev/null +++ b/lib/sms_factor/responses/create_sub_account_response.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require 'sms_factor/responses/base_response' + +class SmsFactor + module Responses + class CreateSubAccountResponse < BaseResponse + attribute :id, SmsFactor::Types::Coercible::Integer + attribute :active, SmsFactor::Types::Coercible::Integer + end + end +end diff --git a/lib/sms_factor/responses/create_token_response.rb b/lib/sms_factor/responses/create_token_response.rb new file mode 100644 index 0000000..f59a6ec --- /dev/null +++ b/lib/sms_factor/responses/create_token_response.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'sms_factor/responses/base_response' + +class SmsFactor + module Responses + class CreateTokenResponse < BaseResponse + attribute :token, SmsFactor::Types::Strict::String + attribute :token_id, SmsFactor::Types::Strict::String + attribute :allowed_ips, SmsFactor::Types::Array.of(SmsFactor::Types::Strict::String).optional + end + end +end diff --git a/lib/sms_factor/responses/delete_token_response.rb b/lib/sms_factor/responses/delete_token_response.rb new file mode 100644 index 0000000..589133f --- /dev/null +++ b/lib/sms_factor/responses/delete_token_response.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'sms_factor/responses/base_response' + +class SmsFactor + module Responses + class DeleteTokenResponse < BaseResponse + attribute :deleted_token, SmsFactor::Types::Coercible::Integer + end + end +end diff --git a/lib/sms_factor/responses/sub_account.rb b/lib/sms_factor/responses/sub_account.rb new file mode 100644 index 0000000..975ad7d --- /dev/null +++ b/lib/sms_factor/responses/sub_account.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'dry-struct' +require 'sms_factor/types' + +class SmsFactor + module Responses + class SubAccount < Dry::Struct + attribute :client_id, SmsFactor::Types::Coercible::Integer + attribute :email, SmsFactor::Types::Strict::String + attribute :firstname, SmsFactor::Types::Strict::String.optional + attribute :lastname, SmsFactor::Types::Strict::String.optional + attribute :city, SmsFactor::Types::Strict::String.optional + attribute :phone, SmsFactor::Types::Strict::String.optional + attribute :address1, SmsFactor::Types::Strict::String.optional + attribute :address2, SmsFactor::Types::Strict::String.optional + attribute :zip, SmsFactor::Types::Strict::String.optional + attribute :country, SmsFactor::Types::Strict::String.optional + attribute :country_code, SmsFactor::Types::Strict::String.optional + attribute :lang, SmsFactor::Types::Strict::String.optional + attribute :credits, SmsFactor::Types::Coercible::Integer + attribute :unlimited, SmsFactor::Types::Coercible::Integer + attribute? :description, SmsFactor::Types::Strict::String.optional + attribute :senderid, SmsFactor::Types::Strict::String.optional + attribute :status, SmsFactor::Types::Coercible::Integer + attribute :time_zone, SmsFactor::Types::Strict::String.optional + attribute :current_month_consumption, SmsFactor::Types::Coercible::Integer.optional + attribute :previous_month_consumption, SmsFactor::Types::Coercible::Integer.optional + end + end +end diff --git a/lib/sms_factor/responses/sub_account_response.rb b/lib/sms_factor/responses/sub_account_response.rb new file mode 100644 index 0000000..4c0fec4 --- /dev/null +++ b/lib/sms_factor/responses/sub_account_response.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require 'sms_factor/responses/base_response' +require 'sms_factor/responses/sub_account' + +class SmsFactor + module Responses + class SubAccountResponse < BaseResponse + attribute :sub_account do + attributes_from SmsFactor::Responses::SubAccount + end + end + end +end diff --git a/lib/sms_factor/responses/sub_accounts_response.rb b/lib/sms_factor/responses/sub_accounts_response.rb new file mode 100644 index 0000000..74337bd --- /dev/null +++ b/lib/sms_factor/responses/sub_accounts_response.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require 'sms_factor/responses/base_response' +require 'sms_factor/responses/sub_account' + +class SmsFactor + module Responses + class SubAccountsResponse < BaseResponse + attribute :sub_accounts, SmsFactor::Types::Strict::Array.of(SmsFactor::Responses::SubAccount) + end + end +end diff --git a/lib/sms_factor/responses/toggle_lock_response.rb b/lib/sms_factor/responses/toggle_lock_response.rb new file mode 100644 index 0000000..f67f32a --- /dev/null +++ b/lib/sms_factor/responses/toggle_lock_response.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'sms_factor/responses/base_response' + +class SmsFactor + module Responses + class ToggleLockResponse < BaseResponse + attribute :state, SmsFactor::Types::Strict::String + end + end +end diff --git a/lib/sms_factor/responses/token_details.rb b/lib/sms_factor/responses/token_details.rb new file mode 100644 index 0000000..6841b3a --- /dev/null +++ b/lib/sms_factor/responses/token_details.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'dry-struct' +require 'sms_factor/types' + +class SmsFactor + module Responses + class TokenDetails < Dry::Struct + attribute :name, SmsFactor::Types::Strict::String + attribute :api_token_id, SmsFactor::Types::Coercible::Integer + attribute :created_at, SmsFactor::Types::Strict::String + attribute :expired_at, SmsFactor::Types::Strict::String.optional + attribute :ttl, SmsFactor::Types::Coercible::Integer.optional + attribute :is_active, SmsFactor::Types::Coercible::Integer + attribute :allowed_ips, SmsFactor::Types::Array.of(SmsFactor::Types::Strict::String).optional + end + end +end diff --git a/lib/sms_factor/responses/token_response.rb b/lib/sms_factor/responses/token_response.rb new file mode 100644 index 0000000..668f156 --- /dev/null +++ b/lib/sms_factor/responses/token_response.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require 'sms_factor/responses/base_response' +require 'sms_factor/responses/token_details' + +class SmsFactor + module Responses + class TokenResponse < BaseResponse + attribute :token do + attributes_from SmsFactor::Responses::TokenDetails + end + end + end +end diff --git a/lib/sms_factor/responses/tokens_response.rb b/lib/sms_factor/responses/tokens_response.rb new file mode 100644 index 0000000..e513a2b --- /dev/null +++ b/lib/sms_factor/responses/tokens_response.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require 'sms_factor/responses/base_response' +require 'sms_factor/responses/token_details' + +class SmsFactor + module Responses + class TokensResponse < BaseResponse + attribute :tokens, SmsFactor::Types::Strict::Array.of(SmsFactor::Responses::TokenDetails) + end + end +end diff --git a/lib/sms_factor/responses/transfer_credits_response.rb b/lib/sms_factor/responses/transfer_credits_response.rb new file mode 100644 index 0000000..eefab59 --- /dev/null +++ b/lib/sms_factor/responses/transfer_credits_response.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require 'sms_factor/responses/base_response' + +class SmsFactor + module Responses + class TransferCreditsResponse < BaseResponse + attribute :credits, SmsFactor::Types::Coercible::Integer + attribute :child_credits, SmsFactor::Types::Coercible::Integer + end + end +end diff --git a/lib/sms_factor/sub_account.rb b/lib/sms_factor/sub_account.rb new file mode 100644 index 0000000..e3a0d7f --- /dev/null +++ b/lib/sms_factor/sub_account.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'rest-client' +require 'json' +require 'sms_factor/headers' +require 'sms_factor/response_factory' +require 'sms_factor/responses/balance_response' +require 'sms_factor/responses/create_sub_account_response' +require 'sms_factor/responses/sub_account_response' +require 'sms_factor/responses/sub_accounts_response' +require 'sms_factor/responses/toggle_lock_response' +require 'sms_factor/responses/transfer_credits_response' + +class SmsFactor + class SubAccount + API_URL = SmsFactor::Init.configuration.api_url + + COMPANY = 'company' + ASSOCIATION = 'association' + ADMINISTRATION = 'administration' + PRIVATE = 'private' + + TYPES = [ + COMPANY, + ASSOCIATION, + ADMINISTRATION, + PRIVATE + ].freeze + + def create(email:, password:, type:, **account_params) + validate_create_params(email, password, type) + payload = build_create_payload(email, password, type, account_params) + + url = "#{API_URL}/account" + response = RestClient.post(url, payload.to_json, SmsFactor::Headers.api_headers) + SmsFactor::ResponseFactory.build(response, SmsFactor::Responses::CreateSubAccountResponse) + end + + def list + url = "#{API_URL}/sub-accounts" + response = RestClient.get(url, SmsFactor::Headers.api_headers) + SmsFactor::ResponseFactory.build(response, SmsFactor::Responses::SubAccountsResponse) + end + + def get(sub_account_id) + url = "#{API_URL}/sub-accounts/#{sub_account_id}" + response = RestClient.get(url, SmsFactor::Headers.api_headers) + SmsFactor::ResponseFactory.build(response, SmsFactor::Responses::SubAccountResponse) + end + + def lock(sub_account_id) + url = "#{API_URL}/account/#{sub_account_id}/lock" + response = RestClient.get(url, SmsFactor::Headers.api_headers) + SmsFactor::ResponseFactory.build(response, SmsFactor::Responses::ToggleLockResponse) + end + + def unlock(sub_account_id) + url = "#{API_URL}/account/#{sub_account_id}/unlock" + response = RestClient.get(url, SmsFactor::Headers.api_headers) + SmsFactor::ResponseFactory.build(response, SmsFactor::Responses::ToggleLockResponse) + end + + def balance(api_key = nil) + url = "#{API_URL}/credits" + response = RestClient.get(url, SmsFactor::Headers.api_headers(api_key)) + SmsFactor::ResponseFactory.build(response, SmsFactor::Responses::BalanceResponse) + end + + def transfer_credits(sub_account_id, credits) + payload = { + transfer: { + to_account: sub_account_id, + credits: credits + } + } + + url = "#{API_URL}/account/transfer" + response = RestClient.post(url, payload.to_json, SmsFactor::Headers.api_headers) + SmsFactor::ResponseFactory.build(response, SmsFactor::Responses::TransferCreditsResponse) + end + + private + + def validate_create_params(email, password, type) + { email: email, password: password, type: type }.each do |key, value| + raise ArgumentError, "#{key} is required" if value.nil? || value.strip.empty? + end + + return if TYPES.include?(type) + + raise ArgumentError, "Invalid type: #{type}. Must be one of: #{TYPES.join(', ')}" + end + + def build_create_payload(email, password, type, account_params) + account_data = { + email: email, + password: password, + type: type, + isChild: 1, + unlimited: 0 + }.merge(account_params).compact + + { account: account_data } + end + end +end diff --git a/lib/sms_factor/token.rb b/lib/sms_factor/token.rb new file mode 100644 index 0000000..09249a4 --- /dev/null +++ b/lib/sms_factor/token.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require 'rest-client' +require 'json' +require 'sms_factor/headers' +require 'sms_factor/response_factory' +require 'sms_factor/responses/token_response' +require 'sms_factor/responses/tokens_response' +require 'sms_factor/responses/delete_token_response' + +class SmsFactor + class Token + API_URL = SmsFactor::Init.configuration.api_url + + def create_for_main_account(name:, ttl: nil, allowed_ips: nil) + url = "#{API_URL}/token" + create_token(url, name: name, ttl: ttl, allowed_ips: allowed_ips) + end + + def create_for_sub_account(sub_account_id:, name:, ttl: nil, allowed_ips: nil) + url = "#{API_URL}/token/account/#{sub_account_id}" + create_token(url, name: name, ttl: ttl, allowed_ips: allowed_ips) + end + + def list(api_key: nil) + url = "#{API_URL}/token" + + response = RestClient.get( + url, + SmsFactor::Headers.api_headers(api_key) + ) + + SmsFactor::ResponseFactory.build(response, SmsFactor::Responses::TokensResponse) + end + + def get(token_id:, api_key: nil) + url = "#{API_URL}/token/#{token_id}" + + response = RestClient.get( + url, + SmsFactor::Headers.api_headers(api_key) + ) + + SmsFactor::ResponseFactory.build(response, SmsFactor::Responses::TokenResponse) + end + + def delete(token_id:, api_key: nil) + url = "#{API_URL}/token/#{token_id}" + + response = RestClient.delete( + url, + SmsFactor::Headers.api_headers(api_key) + ) + + SmsFactor::ResponseFactory.build(response, SmsFactor::Responses::DeleteTokenResponse) + end + + private + + def create_token(url, name:, ttl:, allowed_ips:) + payload = build_create_payload(name, ttl, allowed_ips) + + response = RestClient.post( + url, + payload.to_json, + SmsFactor::Headers.api_headers + ) + + SmsFactor::ResponseFactory.build(response, SmsFactor::Responses::CreateTokenResponse) + end + + def build_create_payload(name, ttl, allowed_ips) + { + token: { + name: name, + ttl: ttl, + allowed_ips: allowed_ips + }.compact + } + end + end +end diff --git a/lib/sms_factor/types.rb b/lib/sms_factor/types.rb new file mode 100644 index 0000000..8169cca --- /dev/null +++ b/lib/sms_factor/types.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'dry-types' + +class SmsFactor + module Types + include Dry.Types() + end +end diff --git a/sms_factor.gemspec b/sms_factor.gemspec index 09c169f..4b811d5 100644 --- a/sms_factor.gemspec +++ b/sms_factor.gemspec @@ -7,10 +7,7 @@ Gem::Specification.new do |s| # rubocop:disable Gemspec/RequireMFA s.description = 'An easy way to use API SMS from http://www.smsfactor.com/ (http://www.smsfactor.com/api-sms)' s.authors = ['Julien Séveno'] s.email = 'jseveno@gmail.com' - s.files = ['lib/sms_factor.rb', - 'lib/sms_factor/configuration.rb', - 'lib/sms_factor/init.rb', - 'lib/sms_factor/sms_response.rb'] + s.files = Dir['lib/**/*', 'LICENSE', 'README.md'] s.homepage = 'https://github.com/juseveno/sms_factor' s.licenses = ['LGPL'] s.require_paths = ['lib'] @@ -22,5 +19,7 @@ Gem::Specification.new do |s| # rubocop:disable Gemspec/RequireMFA else s.add_runtime_dependency 'nokogiri', '~> 1.14' end + s.add_runtime_dependency 'dry-struct', '~> 1.6' + s.add_runtime_dependency 'dry-types', '~> 1.7' s.add_runtime_dependency 'rest-client', '~> 2.0.2', '>= 2.0.2' end diff --git a/spec/sms_factor/response_factory_spec.rb b/spec/sms_factor/response_factory_spec.rb new file mode 100644 index 0000000..e15b2b9 --- /dev/null +++ b/spec/sms_factor/response_factory_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe SmsFactor::ResponseFactory do + let(:success_response) do + instance_double( + RestClient::Response, + body: { + status: 1, + message: 'OK', + credits: 200, + postpaid: 0, + postpaid_limit: false, + unlimited: false + }.to_json + ) + end + + let(:error_response) do + instance_double(RestClient::Response, body: { status: -3, message: 'Not enough credits' }.to_json) + end + + describe '.build' do + subject(:result) do + described_class.build(success_response, SmsFactor::Responses::BalanceResponse) + end + + it 'returns a BalanceResponse' do + expect(result).to be_a(SmsFactor::Responses::BalanceResponse) + end + + it 'has the correct credits value' do + expect(result.credits).to eq(200) + end + end + + describe '.build with unknown error status' do + let(:unknown_error_response) do + instance_double(RestClient::Response, + body: { status: -99, message: 'Something weird happened' }.to_json) + end + + it 'raises UnknownApiError for unknown status codes' do + expect do + described_class.build(unknown_error_response, SmsFactor::Responses::BalanceResponse) + end.to raise_error(SmsFactor::UnknownApiError, /Something weird happened/) + end + end + + describe '.build with error response' do + it 'raises ApiError on error status' do + expect do + described_class.build(error_response, SmsFactor::Responses::BalanceResponse) + end.to raise_error(SmsFactor::NotEnoughCreditsError, /Not enough credits/) + end + end +end diff --git a/spec/sms_factor/sub_account_spec.rb b/spec/sms_factor/sub_account_spec.rb new file mode 100644 index 0000000..a0735f2 --- /dev/null +++ b/spec/sms_factor/sub_account_spec.rb @@ -0,0 +1,266 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rest-client' + +RSpec.describe SmsFactor::SubAccount do + subject(:api) { described_class.new } + + include_context 'with sms_factor responses' + + let(:sub_account_data) do + { + client_id: 101, + email: 'single@test.com', + credits: 15, + unlimited: 1, + status: 1, + firstname: 'Yoh', + lastname: 'Asakura', + city: 'Tokyo', + phone: '33612345678', + address1: 'Street 1', + address2: 'Street 2', + zip: '75000', + country: 'FR', + country_code: 'FR', + lang: 'FR', + senderid: '', + time_zone: 'Europe/Paris', + current_month_consumption: 100, + previous_month_consumption: 200 + } + end + + describe '#create' do + context 'with valid parameters' do + let(:api_response) do + success_response( + status: 1, + message: 'OK', + id: '1234', + active: '1' + ) + end + + let(:expect_create_call) do + expect(RestClient).to have_received(:post).with( + a_string_including('/account'), + a_string_matching(/"email":"test@test.com"/) + .and(a_string_matching(/"type":"company"/)) + .and(a_string_matching(/"firstname":"bob"/)), + hash_including(:accept, :content_type) + ) + end + + before do + allow(RestClient).to receive(:post).and_return(api_response) + end + + it 'builds correct payload' do + api.create(email: 'test@test.com', password: 'secret', type: SmsFactor::SubAccount::COMPANY, firstname: 'bob') + + expect_create_call + end + end + + context 'with missing parameters' do + it 'raises ArgumentError for missing email' do + expect do + api.create(email: '', password: 'secret', type: SmsFactor::SubAccount::COMPANY) + end.to raise_error(ArgumentError, /email is required/) + end + + it 'raises ArgumentError for missing password' do + expect do + api.create(email: 't@test.com', password: '', type: SmsFactor::SubAccount::COMPANY) + end.to raise_error(ArgumentError, /password is required/) + end + + it 'raises ArgumentError for missing type' do + expect do + api.create(email: 't@test.com', password: 'secret', type: nil) + end.to raise_error(ArgumentError, /type is required/) + end + + it 'raises ArgumentError for invalid type' do + expect do + api.create(email: 't@test.com', password: 'secret', type: 'badtype') + end.to raise_error(ArgumentError, /Invalid type/) + end + end + + it_behaves_like 'an endpoint with API errors', { + http_verb: :post, + method_name: :create, + args: [email: 't@test.com', password: 'secret', type: 'company'], + error_key: :auth_error + } + end + + describe '#list' do + let(:api_response) do + success_response( + status: 1, + message: 'OK', + sub_accounts: [sub_account_data] + ) + end + + before do + allow(RestClient).to receive(:get).and_return(api_response) + end + + it 'returns list of sub accounts' do + result = api.list + expect(result.sub_accounts.map(&:client_id)).to eq [101] + end + + it_behaves_like 'an endpoint with API errors', { + http_verb: :get, + method_name: :list, + args: [], + error_key: :auth_error + } + end + + describe '#get' do + let(:api_response) do + success_response( + status: 1, + message: 'OK', + sub_account: sub_account_data + ) + end + + before do + allow(RestClient).to receive(:get).and_return(api_response) + end + + it 'returns single sub account' do + result = api.get(101) + expect(result.sub_account.client_id).to eq 101 + end + + it_behaves_like 'an endpoint with API errors', { + http_verb: :get, + method_name: :get, + args: [999], + error_key: :resource_not_found + } + end + + describe '#lock' do + let(:api_response) do + success_response( + status: 1, + message: 'OK', + state: 'locked' + ) + end + + before do + allow(RestClient).to receive(:get).and_return(api_response) + end + + it 'locks a sub account' do + result = api.lock(101) + expect(result.state).to eq 'locked' + end + + it_behaves_like 'an endpoint with API errors', { + http_verb: :get, + method_name: :lock, + args: [101], + error_key: :resource_not_found + } + end + + describe '#unlock' do + let(:api_response) do + success_response( + status: 1, + message: 'OK', + state: 'unlocked' + ) + end + + before do + allow(RestClient).to receive(:get).and_return(api_response) + end + + it 'unlocks a sub account' do + result = api.unlock(101) + expect(result.state).to eq 'unlocked' + end + + it_behaves_like 'an endpoint with API errors', { + http_verb: :get, + method_name: :unlock, + args: [101], + error_key: :resource_not_found + } + end + + describe '#balance' do + let(:api_response) do + success_response( + status: 1, + message: 'OK', + credits: 200, + postpaid: 0, + postpaid_limit: false, + unlimited: false + ) + end + + before do + allow(RestClient).to receive(:get).and_return(api_response) + allow(SmsFactor::Headers).to receive(:api_headers).and_call_original + end + + it 'returns balance with credits' do + result = api.balance + expect(result.credits).to eq 200 + end + + it 'passes custom api_key to headers' do + api.balance('subaccount-api-key') + expect(SmsFactor::Headers).to have_received(:api_headers).with('subaccount-api-key') + end + + it_behaves_like 'an endpoint with API errors', { + http_verb: :get, + method_name: :balance, + args: [], + error_key: :resource_not_found + } + end + + describe '#transfer_credits' do + let(:api_response) do + success_response( + status: 1, + message: 'OK', + credits: '990', + child_credits: '1010' + ) + end + + before do + allow(RestClient).to receive(:post).and_return(api_response) + end + + it 'transfers credits' do + result = api.transfer_credits(101, 10) + expect(result.credits).to eq 990 + end + + it_behaves_like 'an endpoint with API errors', { + http_verb: :post, + method_name: :transfer_credits, + args: [101, 10], + error_key: :not_enough_credits + } + end +end diff --git a/spec/sms_factor/token_spec.rb b/spec/sms_factor/token_spec.rb new file mode 100644 index 0000000..b8cda12 --- /dev/null +++ b/spec/sms_factor/token_spec.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rest-client' + +RSpec.describe SmsFactor::Token do + subject(:api) { described_class.new } + + include_context 'with sms_factor responses' + + describe '#create_for_main_account' do + let(:response) do + success_response( + status: 1, + message: 'OK', + token: 'abc.def', + token_id: '2', + allowed_ips: [] + ) + end + + let(:expect_create_call) do + expect(RestClient).to have_received(:post).with( + a_string_including('/token'), + a_string_matching(/"name":"test"/) + .and(a_string_matching(/"ttl":3600/)) + .and(a_string_matching(/"allowed_ips":\["1.2.3.4"\]/)), + hash_including(:accept, :content_type) + ) + end + + before do + allow(RestClient).to receive(:post).and_return(response) + end + + it 'creates a token for main account' do + result = api.create_for_main_account(name: 'test') + expect(result.token).to eq('abc.def') + end + + it 'sends correct payload' do + allow(RestClient).to receive(:post).and_return(response) + + api.create_for_main_account(name: 'test', ttl: 3600, allowed_ips: ['1.2.3.4']) + + expect_create_call + end + + it_behaves_like 'an endpoint with API errors', { + http_verb: :post, + method_name: :create_for_main_account, + args: [{ name: 'test' }], + error_key: :auth_error + } + end + + describe '#create_for_sub_account' do + let(:response) do + success_response( + status: 1, + message: 'OK', + token: 'sub.token', + token_id: '3', + allowed_ips: [] + ) + end + + let(:expect_create_call) do + expect(RestClient).to have_received(:post).with( + a_string_including('/token/account/5'), + a_string_matching(/"name":"SubToken"/) + .and(a_string_matching(/"ttl":1800/)) + .and(a_string_matching(/"allowed_ips":\["8.8.8.8"\]/)), + hash_including(:accept, :content_type) + ) + end + + before do + allow(RestClient).to receive(:post).and_return(response) + end + + it 'creates token for sub account' do + result = api.create_for_sub_account(sub_account_id: 5, name: 'SubToken') + expect(result.token).to eq('sub.token') + end + + it 'sends correct payload for sub account' do + allow(RestClient).to receive(:post).and_return(response) + + api.create_for_sub_account(sub_account_id: 5, name: 'SubToken', ttl: 1800, allowed_ips: ['8.8.8.8']) + + expect_create_call + end + + it_behaves_like 'an endpoint with API errors', { + http_verb: :post, + method_name: :create_for_sub_account, + args: [{ sub_account_id: 5, name: 'SubToken' }], + error_key: :auth_error + } + end + + describe '#list' do + let(:response) do + success_response( + status: 1, + message: 'OK', + tokens: [{ + name: 'My Token', + api_token_id: 15, + is_active: '1', + created_at: '2025-01-01T01:00:00Z', + expired_at: nil, + ttl: nil, + allowed_ips: [] + }] + ) + end + + before do + allow(RestClient).to receive(:get).and_return(response) + end + + it 'returns tokens list with correct names' do + result = api.list + expect(result.tokens.map(&:name)).to eq(['My Token']) + end + + it_behaves_like 'an endpoint with API errors', { + http_verb: :get, + method_name: :list, + args: [], + error_key: :resource_not_found + } + end + + describe '#get' do + let(:response) do + success_response( + status: 1, + message: 'OK', + token: { + name: 'One Token', + api_token_id: 12, + created_at: '2025-01-01T01:00:00Z', + expired_at: nil, + ttl: nil, + is_active: '1', + allowed_ips: [] + } + ) + end + + before do + allow(RestClient).to receive(:get).and_return(response) + end + + it 'returns a single token with correct name' do + result = api.get(token_id: 12) + expect(result.token.name).to eq('One Token') + end + + it_behaves_like 'an endpoint with API errors', { + http_verb: :get, + method_name: :get, + args: [{ token_id: 999 }], + error_key: :invalid_token + } + end + + describe '#delete' do + let(:response) do + success_response( + status: 1, + message: 'OK', + deleted_token: '12' + ) + end + + before do + allow(RestClient).to receive(:delete).and_return(response) + end + + it 'deletes a token and returns correct id' do + result = api.delete(token_id: 12) + expect(result.deleted_token).to eq(12) + end + + it_behaves_like 'an endpoint with API errors', { + http_verb: :delete, + method_name: :delete, + args: [{ token_id: 999 }], + error_key: :invalid_token + } + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 6f90b72..def6ac4 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -47,3 +47,5 @@ end end end + +Dir[File.join(__dir__, 'support/**/*.rb')].sort.each { |f| require f } diff --git a/spec/support/sms_factor_api.rb b/spec/support/sms_factor_api.rb new file mode 100644 index 0000000..f485548 --- /dev/null +++ b/spec/support/sms_factor_api.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +RSpec.shared_context 'with sms_factor responses' do + def success_response(body_hash) + instance_spy( + RestClient::Response, + body: body_hash.to_json + ) + end + + def error_response(status, message) + instance_spy( + RestClient::Response, + body: { status: status, message: message }.to_json + ) + end +end + +RSpec.shared_examples 'an endpoint with API errors' do |params| + include_context 'with sms_factor responses' + + let(:error_cases) do + { + auth_error: [-1, SmsFactor::AuthError, 'Auth error'], + not_enough_credits: [-3, SmsFactor::NotEnoughCreditsError, 'Not enough credits'], + resource_not_found: [-5, SmsFactor::ResourceNotFoundError, 'Not found'], + invalid_token: [-10, SmsFactor::InvalidTokenIdError, 'Token not found'] + } + end + + it 'raises an API error' do + status_code, error_class, error_message = error_cases.fetch(params[:error_key]) + + allow(RestClient).to receive(params[:http_verb]).and_return(error_response(status_code, error_message)) + + expect do + api.send(params[:method_name], *params[:args]) + end.to raise_error(error_class, /#{error_message}/) + end +end From 617ce933f5c7ef6e6437d7de893f3bdaef1b49d5 Mon Sep 17 00:00:00 2001 From: Baptiste LEVESQUE Date: Thu, 3 Jul 2025 15:46:58 +0200 Subject: [PATCH 2/2] Add CI tests for ruby 3.1 to 3.4 and update bundle and gems --- .github/workflows/ci.yml | 5 +- Gemfile.lock | 92 ++++++++++++++++------------- docker-compose-earthly.yml | 2 + lib/sms_factor/errors.rb | 8 +-- lib/sms_factor/response_factory.rb | 4 +- sms_factor.gemspec | 10 ++-- spec/sms_factor/sub_account_spec.rb | 2 +- spec/sms_factor/token_spec.rb | 12 ++-- spec/sms_factor_spec.rb | 2 +- spec/spec_helper.rb | 2 +- spec/support/sms_factor_api.rb | 18 +++++- 11 files changed, 95 insertions(+), 62 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12fa810..b613e4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,9 +10,12 @@ jobs: fail-fast: false matrix: include: - - ruby: 2.6 - ruby: 2.7 - ruby: 3 + - ruby: 3.1 + - ruby: 3.2 + - ruby: 3.3 + - ruby: 3.4 runs-on: ubuntu-latest name: RSpec suite with Ruby ${{ matrix.ruby }} env: diff --git a/Gemfile.lock b/Gemfile.lock index e356342..c08aae9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -10,13 +10,16 @@ PATH GEM remote: https://rubygems.org/ specs: - addressable (2.8.4) - public_suffix (>= 2.0.2, < 6.0) - ast (2.4.2) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + ast (2.4.3) + base64 (0.3.0) + bigdecimal (3.2.2) concurrent-ruby (1.3.5) - crack (0.4.5) + crack (1.0.0) + bigdecimal rexml - diff-lcs (1.5.0) + diff-lcs (1.6.2) domain_name (0.6.20240107) dry-core (1.0.0) concurrent-ruby (~> 1.0) @@ -38,11 +41,13 @@ GEM dry-logic (~> 1.4) zeitwerk (~> 2.6) ffaker (2.21.0) - hashdiff (1.0.1) + hashdiff (1.2.0) http-cookie (1.0.8) domain_name (~> 0.5) ice_nine (0.11.2) - json (2.6.3) + json (2.12.2) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) logger (1.7.0) mime-types (3.7.0) logger @@ -51,53 +56,58 @@ GEM netrc (0.11.0) nokogiri (1.13.10-x86_64-linux) racc (~> 1.4) - parallel (1.22.1) - parser (3.2.2.0) + parallel (1.27.0) + parser (3.3.8.0) ast (~> 2.4.1) - public_suffix (5.0.1) + racc + prism (1.4.0) + public_suffix (5.1.1) racc (1.8.1) rainbow (3.1.1) - rake (13.0.6) - regexp_parser (2.7.0) + rake (13.3.0) + regexp_parser (2.10.0) rest-client (2.0.2) http-cookie (>= 1.0.2, < 2.0) mime-types (>= 1.16, < 4.0) netrc (~> 0.8) - rexml (3.2.5) - rspec (3.12.0) - rspec-core (~> 3.12.0) - rspec-expectations (~> 3.12.0) - rspec-mocks (~> 3.12.0) - rspec-core (3.12.1) - rspec-support (~> 3.12.0) - rspec-expectations (3.12.2) + rexml (3.4.1) + rspec (3.13.1) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.5) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.12.0) - rspec-mocks (3.12.5) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.5) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.12.0) - rspec-support (3.12.0) - rubocop (1.49.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.4) + rubocop (1.77.0) json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) parallel (~> 1.10) - parser (>= 3.2.0.0) + parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8, < 3.0) - rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.28.0, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.45.1, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.28.0) - parser (>= 3.2.1.0) - rubocop-capybara (2.17.1) - rubocop (~> 1.41) - rubocop-rspec (2.19.0) - rubocop (~> 1.33) - rubocop-capybara (~> 2.17) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.45.1) + parser (>= 3.3.7.2) + prism (~> 1.4) + rubocop-rspec (3.6.0) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) ruby-progressbar (1.13.0) - unicode-display_width (2.4.2) - vcr (6.1.0) - webmock (3.18.1) + unicode-display_width (3.1.4) + unicode-emoji (~> 4.0, >= 4.0.4) + unicode-emoji (4.0.4) + vcr (6.3.1) + base64 + webmock (3.25.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) @@ -119,4 +129,4 @@ DEPENDENCIES webmock BUNDLED WITH - 2.1.4 + 2.3.26 diff --git a/docker-compose-earthly.yml b/docker-compose-earthly.yml index 911c858..3a303f3 100644 --- a/docker-compose-earthly.yml +++ b/docker-compose-earthly.yml @@ -1,5 +1,7 @@ version: '3' +name: sms_factor + services: # docker-compose run --rm gem [rspec [path to spec file]] gem: diff --git a/lib/sms_factor/errors.rb b/lib/sms_factor/errors.rb index e99ec0a..ce21aba 100644 --- a/lib/sms_factor/errors.rb +++ b/lib/sms_factor/errors.rb @@ -2,12 +2,12 @@ class SmsFactor class ApiError < StandardError - attr_reader :status, :message + attr_reader :status, :message, :details - def initialize(status, message) + def initialize(status, message, details) @status = status - @message = message - super("[API error #{status}] #{message}") + @message = "#{message}: #{details}" + super("[API error #{status}] #{message}: #{details}") end end diff --git a/lib/sms_factor/response_factory.rb b/lib/sms_factor/response_factory.rb index 7a68f6a..fa28a20 100644 --- a/lib/sms_factor/response_factory.rb +++ b/lib/sms_factor/response_factory.rb @@ -17,7 +17,9 @@ class ResponseFactory def self.build(response, klass) parsed = JSON.parse(response.body, symbolize_names: true) - raise error_class(parsed[:status]).new(parsed[:status], parsed[:message]) unless parsed[:status] == 1 + unless parsed[:status] == 1 + raise error_class(parsed[:status]).new(parsed[:status], parsed[:message], parsed[:details]) + end klass.new(parsed) end diff --git a/sms_factor.gemspec b/sms_factor.gemspec index 4b811d5..97de220 100644 --- a/sms_factor.gemspec +++ b/sms_factor.gemspec @@ -15,11 +15,11 @@ Gem::Specification.new do |s| # rubocop:disable Gemspec/RequireMFA s.required_ruby_version = ">= #{ENV.fetch('RUBY_VERSION', '2.7')}" if Gem::Version.new(ENV.fetch('RUBY_VERSION', '2.7')) < Gem::Version.new('3.0') - s.add_runtime_dependency 'nokogiri', '~> 1.13', '< 1.14' + s.add_dependency 'nokogiri', '~> 1.13', '< 1.14' else - s.add_runtime_dependency 'nokogiri', '~> 1.14' + s.add_dependency 'nokogiri', '~> 1.14' end - s.add_runtime_dependency 'dry-struct', '~> 1.6' - s.add_runtime_dependency 'dry-types', '~> 1.7' - s.add_runtime_dependency 'rest-client', '~> 2.0.2', '>= 2.0.2' + s.add_dependency 'dry-struct', '~> 1.6' + s.add_dependency 'dry-types', '~> 1.7' + s.add_dependency 'rest-client', '~> 2.0.2', '>= 2.0.2' end diff --git a/spec/sms_factor/sub_account_spec.rb b/spec/sms_factor/sub_account_spec.rb index a0735f2..467944c 100644 --- a/spec/sms_factor/sub_account_spec.rb +++ b/spec/sms_factor/sub_account_spec.rb @@ -43,7 +43,7 @@ ) end - let(:expect_create_call) do + def expect_create_call expect(RestClient).to have_received(:post).with( a_string_including('/account'), a_string_matching(/"email":"test@test.com"/) diff --git a/spec/sms_factor/token_spec.rb b/spec/sms_factor/token_spec.rb index b8cda12..2129bd3 100644 --- a/spec/sms_factor/token_spec.rb +++ b/spec/sms_factor/token_spec.rb @@ -19,7 +19,7 @@ ) end - let(:expect_create_call) do + def expect_create_call expect(RestClient).to have_received(:post).with( a_string_including('/token'), a_string_matching(/"name":"test"/) @@ -49,7 +49,7 @@ it_behaves_like 'an endpoint with API errors', { http_verb: :post, method_name: :create_for_main_account, - args: [{ name: 'test' }], + args: [name: 'test'], error_key: :auth_error } end @@ -65,7 +65,7 @@ ) end - let(:expect_create_call) do + def expect_create_call expect(RestClient).to have_received(:post).with( a_string_including('/token/account/5'), a_string_matching(/"name":"SubToken"/) @@ -95,7 +95,7 @@ it_behaves_like 'an endpoint with API errors', { http_verb: :post, method_name: :create_for_sub_account, - args: [{ sub_account_id: 5, name: 'SubToken' }], + args: [sub_account_id: 5, name: 'SubToken'], error_key: :auth_error } end @@ -163,7 +163,7 @@ it_behaves_like 'an endpoint with API errors', { http_verb: :get, method_name: :get, - args: [{ token_id: 999 }], + args: [token_id: 999], error_key: :invalid_token } end @@ -189,7 +189,7 @@ it_behaves_like 'an endpoint with API errors', { http_verb: :delete, method_name: :delete, - args: [{ token_id: 999 }], + args: [token_id: 999], error_key: :invalid_token } end diff --git a/spec/sms_factor_spec.rb b/spec/sms_factor_spec.rb index 15aef95..4e57161 100644 --- a/spec/sms_factor_spec.rb +++ b/spec/sms_factor_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -describe SmsFactor, vcr: true do +describe SmsFactor, :vcr do let(:message) { FFaker::LoremFR.phrase[0..160] } describe 'sms' do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index def6ac4..dfc4048 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -28,7 +28,7 @@ config.hook_into :webmock end -shared_context 'with vcr', vcr: true do +shared_context 'with vcr', :vcr do # Disable new records on CI. Most of the CI providers # configure environment variable called CI. let(:cassette_record) { ENV['CI'] ? :none : :new_episodes } diff --git a/spec/support/sms_factor_api.rb b/spec/support/sms_factor_api.rb index f485548..dac7ea9 100644 --- a/spec/support/sms_factor_api.rb +++ b/spec/support/sms_factor_api.rb @@ -19,6 +19,22 @@ def error_response(status, message) RSpec.shared_examples 'an endpoint with API errors' do |params| include_context 'with sms_factor responses' + def call_api_method(api, method_name, args) + method_args = args.dup + kwargs = {} + + if method_args.size == 1 && method_args.first.is_a?(Hash) + # Single hash passed: interpret as kwargs + kwargs = method_args.first + method_args = [] + elsif method_args.last.is_a?(Hash) + # Multiple args, ending with a hash + kwargs = method_args.pop + end + + api.send(method_name, *method_args, **kwargs) + end + let(:error_cases) do { auth_error: [-1, SmsFactor::AuthError, 'Auth error'], @@ -34,7 +50,7 @@ def error_response(status, message) allow(RestClient).to receive(params[:http_verb]).and_return(error_response(status_code, error_message)) expect do - api.send(params[:method_name], *params[:args]) + call_api_method(api, params[:method_name], params[:args]) end.to raise_error(error_class, /#{error_message}/) end end