virtuous 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/test.yml +26 -0
  3. data/.gitignore +5 -0
  4. data/.reek.yml +36 -0
  5. data/.rubocop.yml +87 -0
  6. data/.ruby-version +1 -0
  7. data/.yardopts +1 -0
  8. data/CHANGELOG.md +18 -0
  9. data/Gemfile +17 -0
  10. data/LICENSE +21 -0
  11. data/README.md +54 -0
  12. data/Rakefile +24 -0
  13. data/lib/virtuous/client/contact.rb +220 -0
  14. data/lib/virtuous/client/contact_address.rb +78 -0
  15. data/lib/virtuous/client/gift.rb +394 -0
  16. data/lib/virtuous/client/gift_designation.rb +59 -0
  17. data/lib/virtuous/client/individual.rb +125 -0
  18. data/lib/virtuous/client/recurring_gift.rb +86 -0
  19. data/lib/virtuous/client.rb +272 -0
  20. data/lib/virtuous/error.rb +54 -0
  21. data/lib/virtuous/helpers/hash_helper.rb +28 -0
  22. data/lib/virtuous/helpers/string_helper.rb +31 -0
  23. data/lib/virtuous/parse_oj.rb +24 -0
  24. data/lib/virtuous/version.rb +5 -0
  25. data/lib/virtuous.rb +12 -0
  26. data/logo/virtuous.svg +1 -0
  27. data/spec/spec_helper.rb +25 -0
  28. data/spec/support/client_factory.rb +10 -0
  29. data/spec/support/fixtures/contact.json +112 -0
  30. data/spec/support/fixtures/contact_address.json +20 -0
  31. data/spec/support/fixtures/contact_addresses.json +42 -0
  32. data/spec/support/fixtures/contact_gifts.json +80 -0
  33. data/spec/support/fixtures/gift.json +55 -0
  34. data/spec/support/fixtures/gift_designation_query_options.json +2701 -0
  35. data/spec/support/fixtures/gift_designations.json +175 -0
  36. data/spec/support/fixtures/gifts.json +112 -0
  37. data/spec/support/fixtures/import.json +0 -0
  38. data/spec/support/fixtures/individual.json +46 -0
  39. data/spec/support/fixtures/recurring_gift.json +26 -0
  40. data/spec/support/fixtures_helper.rb +5 -0
  41. data/spec/support/virtuous_mock.rb +101 -0
  42. data/spec/virtuous/client_spec.rb +270 -0
  43. data/spec/virtuous/error_spec.rb +74 -0
  44. data/spec/virtuous/resources/contact_address_spec.rb +75 -0
  45. data/spec/virtuous/resources/contact_spec.rb +137 -0
  46. data/spec/virtuous/resources/gift_designation_spec.rb +70 -0
  47. data/spec/virtuous/resources/gift_spec.rb +249 -0
  48. data/spec/virtuous/resources/individual_spec.rb +95 -0
  49. data/spec/virtuous/resources/recurring_gift_spec.rb +67 -0
  50. data/spec/virtuous_spec.rb +7 -0
  51. data/virtuous.gemspec +25 -0
  52. metadata +121 -0
@@ -0,0 +1,272 @@
1
+ require 'faraday'
2
+
3
+ Dir[File.expand_path('client/*.rb', __dir__)].sort.each { |f| require f }
4
+
5
+ module Virtuous
6
+ ##
7
+ # An API client for Virtuous.
8
+ # See {initialize} for a full list of supported configuration options.
9
+ #
10
+ # ### Authentication
11
+ #
12
+ # #### Api key auth
13
+ #
14
+ # To generate an api key you need to visit the
15
+ # [virtuous connect dashboard](https://connect.virtuoussoftware.com/).
16
+ # Then you can use the key by setting the `api_key` param while creating the client or
17
+ # by setting the `VIRTUOUS_KEY` environment variable beforehand.
18
+ #
19
+ # client = Virtuous::Client.new(
20
+ # api_key: api_key,
21
+ # # ...
22
+ # )
23
+ #
24
+ # #### Oauth
25
+ #
26
+ # First, an access token needs to be fetched by providing a user's email and password.
27
+ # This will return an access token that lasts for 15 days, and a refresh token that should be
28
+ # stored and used to create clients in the future.
29
+ # The client will use the expiry date of the access token to automatically determine when a
30
+ # new one needs to be fetched.
31
+ #
32
+ # client = Virtuous::Client.new
33
+ # client.authenticate(email, password)
34
+ # user.update(
35
+ # access_token: client.access_token, refresh_token: client.refresh_token,
36
+ # token_expiration: client.expires_at
37
+ # )
38
+ #
39
+ # # Afterwards
40
+ #
41
+ # client = Virtuous::Client.new(
42
+ # access_token: user.access_token, refresh_token: user.refresh_token,
43
+ # expires_at: user.token_expiration
44
+ # )
45
+ #
46
+ # # Use client
47
+ #
48
+ # if client.refreshed
49
+ # # Update values if they changed
50
+ # user.update(
51
+ # access_token: client.access_token, refresh_token: client.refresh_token,
52
+ # token_expiration: client.expires_at
53
+ # )
54
+ # end
55
+ #
56
+ # #### Two-Factor Authentication
57
+ #
58
+ # client = Virtuous::Client.new
59
+ # response = client.authenticate(email, password)
60
+ # if response[:requires_otp]
61
+ # # Prompt user for OTP
62
+ # client.authenticate(email, password, otp)
63
+ # end
64
+ #
65
+ # Check resource modules to see available client methods:
66
+ #
67
+ # - {Contact}
68
+ # - {Individual}
69
+ # - {Gift}
70
+ # - {GiftDesignation}
71
+ class Client
72
+ include Contact
73
+ include ContactAddress
74
+ include Individual
75
+ include Gift
76
+ include RecurringGift
77
+ include GiftDesignation
78
+
79
+ ##
80
+ # Access token used for OAuth authentication.
81
+ attr_reader :access_token
82
+ ##
83
+ # Token used to refresh the access token when it has expired.
84
+ attr_reader :refresh_token
85
+ ##
86
+ # Expiration date of the access token.
87
+ attr_reader :expires_at
88
+ ##
89
+ # True if the access token has been refreshed.
90
+ attr_reader :refreshed
91
+
92
+ ##
93
+ # @option config [String] :base_url The base url to use for API calls.
94
+ # Default: `https://api.virtuoussoftware.com`.
95
+ # @option config [String] :api_key The key for the API.
96
+ # @option config [String] :access_token The OAuth access token.
97
+ # @option config [String] :refresh_token The OAuth refresh token.
98
+ # @option config [Time] :expires_at The expiration date of the access token.
99
+ # @option config [Hash] :ssl_options SSL options to use with Faraday.
100
+ # @option config [Logger] :logger Logger object for Faraday.
101
+ # @option config [Symbol] :adapter Faraday adapter to use. Default: `Faraday.default_adapter`.
102
+ def initialize(**config)
103
+ read_config(config)
104
+
105
+ @refreshed = false
106
+ end
107
+
108
+ ##
109
+ # @!method get(path, body = {})
110
+ # Makes a `GET` request to the path.
111
+ #
112
+ # @param path [String] The path to send the request.
113
+ # @param body [Hash] Hash of URI query unencoded key/value pairs.
114
+ #
115
+ # @return [Object] The body of the response.
116
+
117
+ ##
118
+ # @!method post(path, body = {})
119
+ # Makes a `POST` request to the path.
120
+ #
121
+ # @param path [String] The path to send the request.
122
+ # @param body [Hash] Hash of URI query unencoded key/value pairs.
123
+ #
124
+ # @return [Object] The body of the response.
125
+
126
+ ##
127
+ # @!method delete(path, body = {})
128
+ # Makes a `DELETE` request to the path.
129
+ #
130
+ # @param path [String] The path to send the request.
131
+ # @param body [Hash] Hash of URI query unencoded key/value pairs.
132
+ #
133
+ # @return [Object] The body of the response.
134
+
135
+ ##
136
+ # @!method patch(path, body = {})
137
+ # Makes a `PATCH` request to the path.
138
+ #
139
+ # @param path [String] The path to send the request.
140
+ # @param body [Hash] Hash of URI query unencoded key/value pairs.
141
+ #
142
+ # @return [Object] The body of the response.
143
+
144
+ ##
145
+ # @!method put(path, body = {})
146
+ # Makes a `PUT` request to the path.
147
+ #
148
+ # @param path [String] The path to send the request.
149
+ # @param body [Hash] Hash of URI query unencoded key/value pairs.
150
+ #
151
+ # @return [Object] The body of the response.
152
+ [:get, :post, :delete, :patch, :put].each do |http_method|
153
+ define_method(http_method) do |path, body = {}|
154
+ connection.public_send(http_method, path, body).body
155
+ end
156
+ end
157
+
158
+ ##
159
+ # Send a request to get an access token using the email and password of a user.
160
+ #
161
+ # @param email [String] The email of the user.
162
+ # @param password [String] The password of the user.
163
+ # @param otp [Integer] The One Time Password of the two-factor authentication flow.
164
+ #
165
+ # @return [Hash] If the authentication was a success, it contains the OAuth tokens.
166
+ # If two factor is required, it will include the `:requires_otp` key.
167
+ # @example Output if authentication is a success
168
+ # {
169
+ # access_token: '<access_token>',
170
+ # refresh_token: '<refresh_token>',
171
+ # expires_at: Time
172
+ # }
173
+ #
174
+ # @example Output if Two-Factor Authentication is required
175
+ # {
176
+ # requires_otp: true
177
+ # }
178
+ def authenticate(email, password, otp = nil)
179
+ data = { grant_type: 'password', username: email, password: password }
180
+ data[:otp] = otp unless otp.nil?
181
+ get_access_token(data)
182
+ end
183
+
184
+ private
185
+
186
+ def encode(string)
187
+ # CGI.escape changes spaces to '+', and we need '%20' instead.
188
+ CGI.escape(string).gsub('+', '%20')
189
+ end
190
+
191
+ def read_config(config)
192
+ [
193
+ :base_url, :api_key, :access_token, :refresh_token, :expires_at, :ssl_options, :logger,
194
+ :adapter
195
+ ].each do |attribute|
196
+ instance_variable_set("@#{attribute}", config[attribute])
197
+ end
198
+
199
+ @api_key ||= ENV.fetch('VIRTUOUS_KEY', nil)
200
+ @base_url ||= 'https://api.virtuoussoftware.com'
201
+ @adapter ||= Faraday.default_adapter
202
+ end
203
+
204
+ def use_access_token?
205
+ !@access_token.nil? || !@refresh_token.nil?
206
+ end
207
+
208
+ def get_access_token(body)
209
+ response = unauthorized_connection.post('/Token', URI.encode_www_form(body))
210
+
211
+ return { requires_otp: true } if response.env.status == 202
212
+
213
+ self.oauth_tokens = response.body
214
+
215
+ { access_token: @access_token, refresh_token: @refresh_token, expires_at: @expires_at }
216
+ end
217
+
218
+ def oauth_tokens=(values)
219
+ @access_token = values['access_token']
220
+ @expires_at = Time.parse(values['.expires'])
221
+ @refresh_token = values['refresh_token']
222
+ @refreshed = true
223
+ end
224
+
225
+ def check_token_expiration
226
+ if !@refresh_token.nil? &&
227
+ ((!@expires_at.nil? && @expires_at < Time.now) || @access_token.nil?)
228
+ get_access_token({ grant_type: 'refresh_token', refresh_token: @refresh_token })
229
+ end
230
+ end
231
+
232
+ def parse(data)
233
+ data = JSON.parse(data) if data.is_a?(String)
234
+
235
+ HashHelper.deep_transform_keys(data) { |key| StringHelper.underscore(key).to_sym }
236
+ end
237
+
238
+ def format(data)
239
+ HashHelper.deep_transform_keys(data) { |key| StringHelper.camelize(key.to_s) }
240
+ end
241
+
242
+ def bearer_token
243
+ if @api_key.nil?
244
+ check_token_expiration
245
+ return @access_token
246
+ end
247
+
248
+ @api_key
249
+ end
250
+
251
+ def connection
252
+ unauthorized_connection do |conn|
253
+ conn.request :authorization, 'Bearer', -> { bearer_token }
254
+ end
255
+ end
256
+
257
+ def unauthorized_connection
258
+ options = { url: @base_url }
259
+
260
+ options[:ssl] = @ssl_options unless @ssl_options.nil?
261
+
262
+ Faraday.new(options) do |conn|
263
+ conn.request :json
264
+ conn.response :oj
265
+ conn.response :logger, @logger if @logger
266
+ conn.use FaradayMiddleware::VirtuousErrorHandler
267
+ conn.adapter @adapter
268
+ yield(conn) if block_given?
269
+ end
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,54 @@
1
+ module Virtuous # :nodoc: all
2
+ class Error < StandardError; end
3
+ class BadGateway < Error; end
4
+ class BadRequest < Error; end
5
+ class CloudflareError < Error; end
6
+ class Forbidden < Error; end
7
+ class GatewayTimeout < Error; end
8
+ class InternalServerError < Error; end
9
+ class NotFound < Error; end
10
+ class ServiceUnavailable < Error; end
11
+ class Unauthorized < Error; end
12
+ end
13
+
14
+ require 'faraday'
15
+ module FaradayMiddleware
16
+ class VirtuousErrorHandler < Faraday::Middleware
17
+ ERROR_STATUSES = (400..600).freeze
18
+
19
+ ##
20
+ # Throws an exception for responses with an HTTP error code.
21
+ def on_complete(env)
22
+ message = error_message(env)
23
+
24
+ case env[:status]
25
+ when 400
26
+ raise Virtuous::BadRequest, message
27
+ when 401
28
+ raise Virtuous::Unauthorized, message
29
+ when 403
30
+ raise Virtuous::Forbidden, message
31
+ when 404
32
+ raise Virtuous::NotFound, message
33
+ when 500
34
+ raise Virtuous::InternalServerError, message
35
+ when 502
36
+ raise Virtuous::BadGateway, message
37
+ when 503
38
+ raise Virtuous::ServiceUnavailable, message
39
+ when 504
40
+ raise Virtuous::GatewayTimeout, message
41
+ when 520
42
+ raise Virtuous::CloudflareError, message
43
+ when ERROR_STATUSES
44
+ raise Virtuous::Error, message
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def error_message(env)
51
+ "#{env[:status]}: #{env[:url]} #{env[:body]}"
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,28 @@
1
+ module Virtuous
2
+ ##
3
+ # Helper methods for hashes
4
+ class HashHelper
5
+ ##
6
+ # Recieves a block which is called on all of the keys of the hash,
7
+ # including the keys nested hashes inside.
8
+ # @param hash [Hash] the hash to call the block upon.
9
+ # @return [Hash] A new hash with the transformed keys.
10
+ def self.deep_transform_keys(hash, &transform)
11
+ new_hash = {}
12
+ hash.each_key do |key|
13
+ value = hash[key]
14
+ new_key = transform.call(key)
15
+ new_hash[new_key] = if value.is_a?(Array)
16
+ value.map do |item|
17
+ item.is_a?(Hash) ? deep_transform_keys(item, &transform) : item
18
+ end
19
+ elsif value.is_a?(Hash)
20
+ deep_transform_keys(value, &transform)
21
+ else
22
+ value
23
+ end
24
+ end
25
+ new_hash
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,31 @@
1
+ module Virtuous
2
+ ##
3
+ # Helper methods for strings
4
+ class StringHelper
5
+ class << self
6
+ ##
7
+ # Transform a String from `CamelCase` to `snake_case`.
8
+ # @param string [String] the string to transform.
9
+ # @return [String] the `snake_case` string.
10
+ def underscore(string)
11
+ string.gsub('::', '/')
12
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
13
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
14
+ .tr('-', '_')
15
+ .downcase
16
+ end
17
+
18
+ ##
19
+ # Transform a String from `snake_case` to `CamelCase`.
20
+ # @param string [String] the string to transform.
21
+ # @return [String] the `CamelCase` string.
22
+ def camelize(string)
23
+ string = string.sub(/^(?:(?=\b|[A-Z_])|\w)/, &:downcase)
24
+
25
+ string.gsub(%r{(?:_|(/))([a-z\d]*)}) do
26
+ "#{::Regexp.last_match(1)}#{::Regexp.last_match(2).capitalize}"
27
+ end.gsub('/', '::')
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,24 @@
1
+ require 'oj'
2
+
3
+ module FaradayMiddleware
4
+ class ParseOj < Faraday::Middleware
5
+ ##
6
+ # Parses JSON responses.
7
+ def on_complete(env)
8
+ body = env[:body]
9
+ env[:body] = if empty_body?(body.strip)
10
+ nil
11
+ else
12
+ Oj.load(body, mode: :compat)
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ def empty_body?(body)
19
+ body.empty? && body == ''
20
+ end
21
+ end
22
+ end
23
+
24
+ Faraday::Response.register_middleware(oj: FaradayMiddleware::ParseOj)
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Virtuous
4
+ VERSION = '0.0.1'
5
+ end
data/lib/virtuous.rb ADDED
@@ -0,0 +1,12 @@
1
+ require_relative 'virtuous/version'
2
+ require_relative 'virtuous/error'
3
+ require_relative 'virtuous/parse_oj'
4
+ Dir[File.expand_path('virtuous/helpers/*.rb', __dir__)].sort.each { |f| require f }
5
+ require_relative 'virtuous/client'
6
+
7
+ ##
8
+ # Virtuous module documentation.
9
+ #
10
+ # Go to Client to see the documentation of the client.
11
+ module Virtuous
12
+ end
data/logo/virtuous.svg ADDED
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 372.4 66.52"><defs><style>.cls-1{fill:#00a3e0;}.cls-2{fill:#f0f8ff;}</style></defs><g id="Layer_2" data-name="Layer 2"><g id="Logo_Variations" data-name="Logo Variations"><path class="cls-1" d="M27.76,45.66V66.52C3,45.5,0,53.55,0,27.6V6.71C24.79,27.75,27.76,19.69,27.76,45.66Z"></path><path class="cls-1" d="M59.82,6.71V27.6c0,25.95-3,17.9-27.77,38.92V45.66C32.05,19.69,35,27.75,59.82,6.71Z"></path><path class="cls-2" d="M132.58,16,116.29,56.86h-9.91L90.06,16h9.28a4,4,0,0,1,2.32.68A3.52,3.52,0,0,1,103,18.48l7.52,20.68c.37.92.66,1.84.93,2.76a24.29,24.29,0,0,1,.92-2.81l7.7-20.7a3.63,3.63,0,0,1,1.31-1.73,3.86,3.86,0,0,1,2.26-.64Z"></path><path class="cls-2" d="M148.83,16V53.35a3.51,3.51,0,0,1-3.5,3.47h-8V19.48a3.5,3.5,0,0,1,3.5-3.49Z"></path><path class="cls-2" d="M148.83,3.48V10h-8a3.49,3.49,0,0,1-3.5-3.48V0h8A3.51,3.51,0,0,1,148.83,3.48Z"></path><path class="cls-2" d="M371.48,39.05a10.08,10.08,0,0,0-2.4-3.17,13.27,13.27,0,0,0-3.32-2.14c-1.17-.52-2.38-1-3.59-1.41s-2.38-.78-3.53-1.13a20,20,0,0,1-2.91-1.09,6.21,6.21,0,0,1-1.84-1.28,1.8,1.8,0,0,1-.53-1.38,2.59,2.59,0,0,1,1.17-2.23,6.23,6.23,0,0,1,3.75-1,10.31,10.31,0,0,1,2.77.34,16.81,16.81,0,0,1,2.19.79l1.79.85a3.76,3.76,0,0,0,3.51.09,3.42,3.42,0,0,0,1.14-1.19l2.6-4-.6-.59a17.69,17.69,0,0,0-5.85-3.67,22.26,22.26,0,0,0-14.43-.38,14.9,14.9,0,0,0-4.93,2.76,11.66,11.66,0,0,0-3.05,4.11,12.24,12.24,0,0,0-1,4.95,11.64,11.64,0,0,0,.93,4.8,10.26,10.26,0,0,0,2.4,3.39,13,13,0,0,0,3.34,2.24,32.52,32.52,0,0,0,3.69,1.46c1.23.41,2.42.78,3.57,1.13a20.08,20.08,0,0,1,2.92,1.09,6.29,6.29,0,0,1,1.85,1.29,2.12,2.12,0,0,1,.53,1.52,3.34,3.34,0,0,1-.27,1.36,2.87,2.87,0,0,1-.85,1.1,5.13,5.13,0,0,1-1.64.85,8.17,8.17,0,0,1-2.55.34,9.4,9.4,0,0,1-3.16-.43,15.06,15.06,0,0,1-2.25-1c-.67-.37-1.25-.71-1.75-1a4.19,4.19,0,0,0-4.19-.16,4.06,4.06,0,0,0-1.34,1.33L341,51.85l.64.57a17,17,0,0,0,2.76,2A21.58,21.58,0,0,0,347.84,56,23.21,23.21,0,0,0,351.68,57a23.84,23.84,0,0,0,11.07-.64A15.11,15.11,0,0,0,368,53.5a12.32,12.32,0,0,0,3.3-4.43,13.94,13.94,0,0,0,1.11-5.56A10,10,0,0,0,371.48,39.05Z"></path><path class="cls-2" d="M236.88,19.47V38.74c0,3.35-.65,6.43-1.66,7.8a5.57,5.57,0,0,1-4.82,2.13,5.64,5.64,0,0,1-4.9-2.14c-1-1.44-1.66-4.46-1.66-7.79l0-19.27A3.49,3.49,0,0,0,220.3,16h-8V39.81a21.12,21.12,0,0,0,1.24,6.58,16.85,16.85,0,0,0,3.69,5.91A16,16,0,0,0,223,56.08a21.52,21.52,0,0,0,14.76,0,16,16,0,0,0,5.66-3.78,16.81,16.81,0,0,0,3.64-5.91,21.65,21.65,0,0,0,1.24-6.57V16h-8A3.48,3.48,0,0,0,236.88,19.47Z"></path><path class="cls-2" d="M155.31,30.28V56.82h8a3.49,3.49,0,0,0,3.49-3.48V34.62c0-3.35.65-6.42,1.64-7.8a5.62,5.62,0,0,1,4.83-2.13h1.4a3.5,3.5,0,0,0,3.5-3.47V16h-8.54a16.05,16.05,0,0,0-5.82,1,12.78,12.78,0,0,0-4.61,3,13.51,13.51,0,0,0-2.87,4.73,17.07,17.07,0,0,0-1,5,2.94,2.94,0,0,0,0,.42"></path><path class="cls-2" d="M268,27.44c1.37-1.92,3.44-2.85,6.35-2.85s5.06.92,6.45,2.87,2.16,5,2.16,8.95-.73,6.91-2.16,8.9-3.48,2.84-6.45,2.84-5-.92-6.36-2.82-2.14-5-2.14-8.92S266.62,29.45,268,27.44Zm-12.42.26a24.86,24.86,0,0,0-1.43,8.63A24.86,24.86,0,0,0,255.57,45a19,19,0,0,0,4.12,6.7,17.91,17.91,0,0,0,6.46,4.25,24.17,24.17,0,0,0,16.6,0,18,18,0,0,0,6.46-4.25A19.12,19.12,0,0,0,293.3,45a24.64,24.64,0,0,0,1.44-8.63,24.48,24.48,0,0,0-1.45-8.64,18.79,18.79,0,0,0-4.13-6.58,18.39,18.39,0,0,0-6.46-4.21,24.28,24.28,0,0,0-16.6,0,18.06,18.06,0,0,0-10.53,10.8Z"></path><path class="cls-2" d="M312,19.47V38.74c0,3.35.65,6.43,1.65,7.8a6.61,6.61,0,0,0,9.73,0c1-1.44,1.66-4.46,1.66-7.79l0-19.27a3.49,3.49,0,0,1,3.5-3.47h8V39.81a21.11,21.11,0,0,1-1.23,6.58,16.85,16.85,0,0,1-3.69,5.91,15.91,15.91,0,0,1-5.74,3.78,21.49,21.49,0,0,1-14.75,0,16,16,0,0,1-5.67-3.78,16.81,16.81,0,0,1-3.64-5.91,21.06,21.06,0,0,1-1.24-6.57V16h8A3.48,3.48,0,0,1,312,19.47Z"></path><path class="cls-2" d="M195.22,24.7h7.46A2.75,2.75,0,0,0,205.44,22V16H195.22V8.87a2.77,2.77,0,0,0-2.77-2.76h-8.66V45.66c0,3.61,1,6.5,3,8.59s4.89,3.17,8.53,3.17a18.6,18.6,0,0,0,5.83-.91,15.17,15.17,0,0,0,5.05-2.76l.69-.56-3.28-5.3,0-.07a4.23,4.23,0,0,0-.76-.82,2.26,2.26,0,0,0-2.56-.07c-.21.13-.45.25-.72.39a8.1,8.1,0,0,1-.92.38,3.2,3.2,0,0,1-1.11.14,2,2,0,0,1-1.66-.67,3.11,3.11,0,0,1-.64-2.14Z"></path></g></g></svg>
@@ -0,0 +1,25 @@
1
+ $LOAD_PATH.unshift(File.join(__FILE__, '..', '..', 'lib'))
2
+
3
+ SPEC_DIR = File.dirname(__FILE__)
4
+ FIXTURES_DIR = File.join(SPEC_DIR, 'support', 'fixtures')
5
+
6
+ require 'virtuous'
7
+ require 'webmock/rspec'
8
+ require_relative 'support/fixtures_helper'
9
+ require_relative 'support/virtuous_mock'
10
+ require_relative 'support/client_factory'
11
+
12
+ RSpec.configure do |config|
13
+ config.before :suite do
14
+ WebMock.disable_net_connect!
15
+ end
16
+
17
+ config.before :each do
18
+ stub_request(:any, /virtuoussoftware/).to_rack(VirtuousMock)
19
+ end
20
+
21
+ config.run_all_when_everything_filtered = true
22
+ config.filter_run :focus
23
+
24
+ config.order = 'random'
25
+ end
@@ -0,0 +1,10 @@
1
+ RSpec.shared_context 'resource specs' do
2
+ let(:attrs) do
3
+ {
4
+ api_key: 'test_api_key',
5
+ base_url: 'http://api.virtuoussoftware.com'
6
+ }
7
+ end
8
+
9
+ let(:client) { Virtuous::Client.new(**attrs) }
10
+ end
@@ -0,0 +1,112 @@
1
+ {
2
+ "id": 1,
3
+ "contactType": "Organization",
4
+ "isPrivate": true,
5
+ "name": "Test Contact",
6
+ "informalName": "Test Contact",
7
+ "formalContactName": "Test Contact",
8
+ "alternateContactName": "Test Contact",
9
+ "preferredSalutationName": "Test Contact",
10
+ "preferredAddresseeName": "Test Contact",
11
+ "description": null,
12
+ "website": null,
13
+ "maritalStatus": null,
14
+ "anniversaryMonth": null,
15
+ "anniversaryDay": null,
16
+ "anniversaryYear": null,
17
+ "mergedIntoContactId": null,
18
+ "address": {
19
+ "id": 800,
20
+ "label": "Test address",
21
+ "address1": "123 test street",
22
+ "address2": null,
23
+ "city": null,
24
+ "state": "",
25
+ "postal": null,
26
+ "country": "",
27
+ "isPrimary": true,
28
+ "canBePrimary": false,
29
+ "startMonth": 1,
30
+ "startDay": 1,
31
+ "endMonth": 12,
32
+ "endDay": 31,
33
+ "createDateTimeUtc": "2023-11-30T14:20:56.34",
34
+ "createdByUser": "Test User",
35
+ "modifiedDateTimeUtc": "2023-11-30T14:20:56.3533333",
36
+ "modifiedByUser": "Test User"
37
+ },
38
+ "giftAskAmount": "$1,200",
39
+ "giftAskType": null,
40
+ "lifeToDateGiving": "$0",
41
+ "yearToDateGiving": "$0",
42
+ "lastGiftAmount": "$0",
43
+ "lastGiftDate": "Unavailable",
44
+ "contactIndividuals": [
45
+ {
46
+ "id": 2,
47
+ "contactId": 1,
48
+ "prefix": "Mr",
49
+ "prefix2": null,
50
+ "firstName": "Test",
51
+ "middleName": null,
52
+ "lastName": "Individual",
53
+ "preMarriageName": null,
54
+ "suffix": null,
55
+ "nickname": null,
56
+ "gender": "Male",
57
+ "isPrimary": true,
58
+ "canBePrimary": false,
59
+ "isSecondary": false,
60
+ "canBeSecondary": false,
61
+ "birthMonth": 9,
62
+ "birthDay": 4,
63
+ "birthYear": 1998,
64
+ "birthDate": "9/4/1998",
65
+ "approximateAge": 25,
66
+ "isDeceased": false,
67
+ "deceasedDate": "",
68
+ "passion": null,
69
+ "avatarUrl": null,
70
+ "contactMethods": [
71
+ {
72
+ "id": 2081,
73
+ "type": "Work Email",
74
+ "value": "email@test.com",
75
+ "isOptedIn": false,
76
+ "isPrimary": true,
77
+ "canBePrimary": false,
78
+ "createDateTimeUtc": "2023-11-30T14:20:56.3233333",
79
+ "createdByUser": "Test User",
80
+ "modifiedDateTimeUtc": "2023-11-30T14:20:56.37",
81
+ "modifiedByUser": "Test User"
82
+ }
83
+ ],
84
+ "createDateTimeUtc": "2023-11-30T14:20:56.2933333",
85
+ "createdByUser": "Test User",
86
+ "modifiedDateTimeUtc": "2023-11-30T14:20:56.3533333",
87
+ "modifiedByUser": "Test User",
88
+ "customFields": [],
89
+ "customCollections": []
90
+ }
91
+ ],
92
+ "contactGiftsUrl": "/api/Gift/ByContact/1",
93
+ "contactPassthroughGiftsUrl": "/api/Gift/Passthrough/ByContact/1",
94
+ "contactPlannedGiftsUrl": "/api/PlannedGift/ByContact/1",
95
+ "contactRecurringGiftsUrl": "/api/RecurringGift/ByContact/1",
96
+ "contactImportantNotesUrl": "/api/ContactNote/Important/ByContact/1",
97
+ "contactNotesUrl": "/api/ContactNote/ByContact/1",
98
+ "contactTagsUrl": "/api/ContactTag/ByContact/1",
99
+ "contactRelationshipsUrl": "/api/Relationship/ByContact/1",
100
+ "primaryAvatarUrl": null,
101
+ "contactReferences": [],
102
+ "originSegmentId": null,
103
+ "originSegment": null,
104
+ "createDateTimeUtc": "2023-11-30T14:20:56.277",
105
+ "createdByUser": "Test User",
106
+ "modifiedDateTimeUtc": "2023-11-30T14:20:56.353",
107
+ "modifiedByUser": "Test User",
108
+ "tags": [],
109
+ "organizationGroups": [],
110
+ "customFields": [],
111
+ "customCollections": []
112
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "id": 1,
3
+ "label": "Home address",
4
+ "address1": "324 Frank Island",
5
+ "address2": "Apt. 366",
6
+ "city": "Antonioborough",
7
+ "state": "MA",
8
+ "postal": "27516",
9
+ "country": "United States",
10
+ "is_primary": true,
11
+ "can_be_primary": false,
12
+ "start_month": 1,
13
+ "start_day": 1,
14
+ "end_month": 12,
15
+ "end_day": 31,
16
+ "create_date_time_utc": "2023-11-30T14:20:56.34",
17
+ "created_by_user": "Test User",
18
+ "modified_date_time_utc": "2023-12-27T15:33:09.9",
19
+ "modified_by_user": "Test User"
20
+ }
@@ -0,0 +1,42 @@
1
+ [
2
+ {
3
+ "id": 1,
4
+ "label": "Home address",
5
+ "address1": "324 Frank Island",
6
+ "address2": "Apt. 366",
7
+ "city": "Antonioborough",
8
+ "state": "MA",
9
+ "postal": "27516",
10
+ "country": "United States",
11
+ "is_primary": true,
12
+ "can_be_primary": false,
13
+ "start_month": 1,
14
+ "start_day": 1,
15
+ "end_month": 12,
16
+ "end_day": 31,
17
+ "create_date_time_utc": "2023-11-30T14:20:56.34",
18
+ "created_by_user": "Test User",
19
+ "modified_date_time_utc": "2023-12-27T15:33:09.9",
20
+ "modified_by_user": "Test User"
21
+ },
22
+ {
23
+ "id": 2,
24
+ "label": "Home address",
25
+ "address1": "324 Frank Island",
26
+ "address2": "Apt. 366",
27
+ "city": "Antonioborough",
28
+ "state": "MA",
29
+ "postal": "27516",
30
+ "country": "United States",
31
+ "is_primary": false,
32
+ "can_be_primary": true,
33
+ "start_month": 1,
34
+ "start_day": 1,
35
+ "end_month": 12,
36
+ "end_day": 31,
37
+ "create_date_time_utc": "2023-12-27T15:35:02.447",
38
+ "created_by_user": "Test User",
39
+ "modified_date_time_utc": "2023-12-27T15:35:02.967",
40
+ "modified_by_user": "Test User"
41
+ }
42
+ ]