clever_tap_dubit 0.3.2

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 (59) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +13 -0
  3. data/.rspec +1 -0
  4. data/.rubocop.yml +48 -0
  5. data/.travis.yml +6 -0
  6. data/Gemfile +16 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +164 -0
  9. data/Rakefile +6 -0
  10. data/bin/console +8 -0
  11. data/bin/setup +8 -0
  12. data/clever_tap.gemspec +33 -0
  13. data/lib/clever_tap/client.rb +113 -0
  14. data/lib/clever_tap/config.rb +25 -0
  15. data/lib/clever_tap/entity.rb +87 -0
  16. data/lib/clever_tap/event.rb +30 -0
  17. data/lib/clever_tap/failed_response.rb +28 -0
  18. data/lib/clever_tap/profile.rb +7 -0
  19. data/lib/clever_tap/response.rb +34 -0
  20. data/lib/clever_tap/successful_response.rb +30 -0
  21. data/lib/clever_tap/uploader.rb +72 -0
  22. data/lib/clever_tap/version.rb +3 -0
  23. data/lib/clever_tap.rb +79 -0
  24. data/lib/clevertap-ruby.rb +1 -0
  25. data/spec/factories/profile.rb +36 -0
  26. data/spec/integrations/clever_tap_spec.rb +81 -0
  27. data/spec/rubocop_spec.rb +12 -0
  28. data/spec/shared/clever_tap_client.rb +13 -0
  29. data/spec/shared/entity.rb +105 -0
  30. data/spec/spec_helper.rb +18 -0
  31. data/spec/units/clever_tap_client_spec.rb +277 -0
  32. data/spec/units/clever_tap_spec.rb +88 -0
  33. data/spec/units/event_spec.rb +43 -0
  34. data/spec/units/failed_response_spec.rb +31 -0
  35. data/spec/units/profile_spec.rb +29 -0
  36. data/spec/units/response_spec.rb +63 -0
  37. data/spec/units/successful_response_spec.rb +112 -0
  38. data/spec/units/uploader_spec.rb +129 -0
  39. data/spec/vcr_cassettes/CleverTap/uploading_a_many_profiles/when_only_some_are_valid/partially_succeds.yml +42 -0
  40. data/spec/vcr_cassettes/CleverTap/uploading_a_profile/when_is_invalid/fails.yml +41 -0
  41. data/spec/vcr_cassettes/CleverTap/uploading_a_profile/when_is_valid/succeed.yml +35 -0
  42. data/spec/vcr_cassettes/CleverTap/uploading_an_event/when_is_valid/succeed.yml +34 -0
  43. data/spec/vcr_cassettes/CleverTap_Client/_upload/when_upload_records_are_homogenous/and_invalid_records/calls_on_failed_upload_once.yml +38 -0
  44. data/spec/vcr_cassettes/CleverTap_Client/_upload/when_upload_records_are_homogenous/and_invalid_records/returns_an_array_with_one_failed_Response_object.yml +38 -0
  45. data/spec/vcr_cassettes/CleverTap_Client/_upload/when_upload_records_are_homogenous/and_valid_records/and_objects_do_not_fit_upload_limit_/calls_on_successful_upload_proc_twice.yml +67 -0
  46. data/spec/vcr_cassettes/CleverTap_Client/_upload/when_upload_records_are_homogenous/and_valid_records/and_objects_do_not_fit_upload_limit_/returns_an_array_with_two_successful_Response_objects.yml +67 -0
  47. data/spec/vcr_cassettes/CleverTap_Client/_upload/when_upload_records_are_homogenous/and_valid_records/and_objects_fit_upload_limit_/calls_on_successful_upload_proc_once.yml +36 -0
  48. data/spec/vcr_cassettes/CleverTap_Client/_upload/when_upload_records_are_homogenous/and_valid_records/and_objects_fit_upload_limit_/returns_an_array_with_one_successful_Response_object.yml +36 -0
  49. data/spec/vcr_cassettes/CleverTap_Uploader/_call/when_age_is_invalid/behaves_like_validation_failure/failed_to_upload_the_profiles.yml +48 -0
  50. data/spec/vcr_cassettes/CleverTap_Uploader/_call/when_education_status_is_invalid/behaves_like_validation_failure/failed_to_upload_the_profiles.yml +49 -0
  51. data/spec/vcr_cassettes/CleverTap_Uploader/_call/when_email_is_invalid/behaves_like_validation_failure/failed_to_upload_the_profiles.yml +48 -0
  52. data/spec/vcr_cassettes/CleverTap_Uploader/_call/when_employment_status_is_invalid/behaves_like_validation_failure/failed_to_upload_the_profiles.yml +48 -0
  53. data/spec/vcr_cassettes/CleverTap_Uploader/_call/when_marital_status_is_invalid/behaves_like_validation_failure/failed_to_upload_the_profiles.yml +48 -0
  54. data/spec/vcr_cassettes/CleverTap_Uploader/_call/when_phone_is_invalid/behaves_like_validation_failure/failed_to_upload_the_profiles.yml +48 -0
  55. data/spec/vcr_cassettes/CleverTap_Uploader/_call/when_the_creation_date_field_is_missing/behaves_like_validation_failure/failed_to_upload_the_profiles.yml +48 -0
  56. data/spec/vcr_cassettes/CleverTap_Uploader/_call/with_invalid_credentials/failed_to_upload_the_profiles.yml +36 -0
  57. data/spec/vcr_cassettes/CleverTap_Uploader/_call/with_valid_data/makes_successful_upload.yml +36 -0
  58. data/spec/vcr_config.rb +13 -0
  59. metadata +192 -0
@@ -0,0 +1,30 @@
1
+ class CleverTap
2
+ # Normalize the success response data to one interface with the failure one
3
+ class SuccessfulResponse
4
+ attr_reader :raw_response, :unprocessed, :message, :code
5
+
6
+ # NOTE: raw_response can include processed, unprocessed, status
7
+ def initialize(raw_response = {})
8
+ @raw_response = raw_response
9
+ @unprocessed = raw_response['unprocessed']
10
+ @message = ''
11
+ @code = 200
12
+ end
13
+
14
+ def errors
15
+ unprocessed
16
+ end
17
+
18
+ def status
19
+ case
20
+ when success then 'success'
21
+ when raw_response['processed'].positive? then 'partial'
22
+ else 'fail'
23
+ end
24
+ end
25
+
26
+ def success
27
+ unprocessed.empty?
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,72 @@
1
+ class CleverTap
2
+ # unit uploading profile data to CleverTap
3
+ class Uploader
4
+ HTTP_PATH = 'upload'.freeze
5
+
6
+ TYPE_EVENT = :event
7
+ TYPE_PROFILE = :profile
8
+
9
+ ENTITY_DATA_NAMES = {
10
+ TYPE_EVENT => 'evtData',
11
+ TYPE_PROFILE => 'profileData'
12
+ }.freeze
13
+
14
+ attr_reader :records, :type, :identity_field, :date_field, :event_name, :dry_run
15
+
16
+ # TODO: make defaults configurable
17
+ # date_field should be a date object responding to `to_i` which
18
+ # should returns epoch time
19
+ # profile respond to .to_h
20
+ def initialize(records, identity_field:, date_field: nil, event_name: nil, dry_run: false)
21
+ @type = event_name ? TYPE_EVENT : TYPE_PROFILE
22
+ @records = records
23
+
24
+ @identity_field = identity_field
25
+ @date_field = date_field
26
+ @event_name = event_name
27
+ @dry_run = dry_run
28
+ end
29
+
30
+ def call(client)
31
+ response = client.post(HTTP_PATH, build_request_body) do |request|
32
+ request.params.merge!(dryRun: 1) if dry_run
33
+ end
34
+
35
+ parse_response(response)
36
+ end
37
+
38
+ private
39
+
40
+ def build_request_body
41
+ records.each_with_object('d' => []) do |record, request_body|
42
+ request_body['d'] << normalize_record(record)
43
+ end.to_json
44
+ end
45
+
46
+ def normalize_record(record)
47
+ ts = date_field ? record[date_field] : Time.now
48
+
49
+ {
50
+ 'identity' => pluck_identity(record).to_s,
51
+ 'ts' => ts.to_i,
52
+ 'type' => type,
53
+ ENTITY_DATA_NAMES[type] => record.to_h
54
+ }.tap do |hash|
55
+ hash.merge!('evtName' => event_name) if type == TYPE_EVENT
56
+ end
57
+ end
58
+
59
+ def parse_response(http_response)
60
+ http_response
61
+ end
62
+
63
+ def pluck_identity(record)
64
+ # TODO: symbolize record keys
65
+ identity = record[identity_field.to_s] || record[identity_field.to_sym]
66
+
67
+ raise "Missing identity field with name: '#{identity_field}' for #{record}" unless identity
68
+
69
+ identity
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,3 @@
1
+ class CleverTap
2
+ VERSION = '0.3.2'.freeze
3
+ end
data/lib/clever_tap.rb ADDED
@@ -0,0 +1,79 @@
1
+ require 'json'
2
+
3
+ require 'clever_tap/config'
4
+ require 'clever_tap/client'
5
+ require 'clever_tap/entity'
6
+ require 'clever_tap/event'
7
+ require 'clever_tap/profile'
8
+ require 'clever_tap/uploader'
9
+ require 'clever_tap/response'
10
+ require 'clever_tap/successful_response'
11
+ require 'clever_tap/failed_response'
12
+
13
+ # the main module of the system
14
+ class CleverTap
15
+ attr_reader :config
16
+
17
+ class << self
18
+ # Never instantiated. Variables are stored in the singleton_class.
19
+ private_class_method :new
20
+
21
+ attr_accessor :identity_field
22
+ attr_accessor :account_id
23
+ attr_accessor :account_passcode
24
+
25
+ def setup
26
+ yield(self)
27
+ end
28
+ end
29
+
30
+ def initialize(**params)
31
+ @config = Config.new(params)
32
+ yield(@config) if block_given?
33
+
34
+ @config.validate
35
+ @config.freeze
36
+ end
37
+
38
+ def client
39
+ @client ||= Client.new(config.account_id, config.passcode, &config.configure_faraday)
40
+ end
41
+
42
+ def upload_events(events, name:, **rest)
43
+ options = rest.merge(event_name: name, identity_field: config.identity_field)
44
+
45
+ response = Uploader.new(events, options).call(client)
46
+
47
+ normalize_response(response, records: events)
48
+ rescue Faraday::Error::TimeoutError, Faraday::Error::ClientError => e
49
+ FailedResponse.new(records: events, message: e.message)
50
+ end
51
+
52
+ def upload_event(event, **options)
53
+ upload_events([event], options)
54
+ end
55
+
56
+ def upload_profiles(profiles, **options)
57
+ options = options.merge(identity_field: config.identity_field)
58
+ response = Uploader.new(profiles, **options).call(client)
59
+
60
+ normalize_response(response, records: profiles)
61
+ rescue Faraday::Error::TimeoutError, Faraday::Error::ClientError => e
62
+ FailedResponse.new(records: profiles, message: e.message)
63
+ end
64
+
65
+ def upload_profile(profile, **options)
66
+ upload_profiles([profile], options)
67
+ end
68
+
69
+ private
70
+
71
+ def normalize_response(response, records:)
72
+ # TODO: handle JSON::ParserError
73
+ if response.success?
74
+ SuccessfulResponse.new(JSON.parse(response.body))
75
+ else
76
+ FailedResponse.new(records: records, code: response.status, message: response.body)
77
+ end
78
+ end
79
+ end
@@ -0,0 +1 @@
1
+ require 'clever_tap'
@@ -0,0 +1,36 @@
1
+ # Unit representing a profile and it's different states
2
+ class Profile
3
+ class << self
4
+ attr_accessor :store
5
+ end
6
+
7
+ self.store = {
8
+ id: 0
9
+ }
10
+
11
+ def self.build_valid(extra = {})
12
+ new({
13
+ 'identity' => store[:id] += 1,
14
+ 'created_at' => Time.new,
15
+ 'Name' => 'John Rush',
16
+ 'Email' => 'example@gmail.com',
17
+ 'Gender' => 'M',
18
+ 'Phone' => '+35922333232',
19
+ 'Employed' => 'Y',
20
+ 'Education' => 'Graduate',
21
+ 'Married' => 'Y',
22
+ 'Age' => '18'
23
+ }.merge(extra))
24
+ end
25
+
26
+ attr_reader :data
27
+ alias to_h data
28
+
29
+ def initialize(data = {})
30
+ @data = data
31
+ end
32
+
33
+ def [](key)
34
+ data[key]
35
+ end
36
+ end
@@ -0,0 +1,81 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe CleverTap, vcr: true do
4
+ # NOTE: clear mutations in CleverTap config
5
+ subject(:clever_tap) { CleverTap.new(account_id: AUTH_ACCOUNT_ID, passcode: AUTH_PASSCODE) }
6
+
7
+ describe 'uploading a profile' do
8
+ context 'when is valid' do
9
+ let(:profile) { Profile.build_valid }
10
+
11
+ it 'succeed' do
12
+ response = clever_tap.upload_profile(profile)
13
+
14
+ aggregate_failures do
15
+ expect(response.status).to eq('success')
16
+ expect(response.errors).to be_empty
17
+ end
18
+ end
19
+ end
20
+
21
+ context 'when is invalid' do
22
+ let(:profile) { Profile.build_valid('Email' => '$$$$$') }
23
+
24
+ it 'fails' do
25
+ response = clever_tap.upload_profile(profile)
26
+
27
+ aggregate_failures do
28
+ expect(response.status).to eq('fail')
29
+ expect(response.errors).to all(be_a(Hash))
30
+ expect(response.errors).to all(
31
+ include('status', 'code', 'error', 'record')
32
+ )
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ describe 'uploading a many profiles' do
39
+ context 'when only some are valid' do
40
+ let(:valid_profile) { Profile.build_valid }
41
+ let(:invalid_profile) { Profile.build_valid('Email' => '$$$$$') }
42
+ let(:profiles) { [valid_profile, invalid_profile] }
43
+
44
+ it 'partially succeds' do
45
+ response = clever_tap.upload_profiles(profiles)
46
+
47
+ aggregate_failures do
48
+ expect(response.status).to eq('partial')
49
+ expect(response.errors).to all(be_a(Hash))
50
+ expect(response.errors).to all(
51
+ include('status', 'code', 'error', 'record')
52
+ )
53
+ end
54
+ end
55
+ end
56
+ end
57
+
58
+ describe 'uploading an event' do
59
+ subject(:clever_tap) do
60
+ CleverTap.new(account_id: AUTH_ACCOUNT_ID, passcode: AUTH_PASSCODE, identity_field: 'ID')
61
+ end
62
+
63
+ context 'when is valid' do
64
+ let(:event) do
65
+ {
66
+ 'ID' => 555,
67
+ 'mobile' => true
68
+ }
69
+ end
70
+
71
+ it 'succeed' do
72
+ response = clever_tap.upload_event(event, name: 'register', identity_field: 'user_id')
73
+
74
+ aggregate_failures do
75
+ expect(response.status).to eq('success')
76
+ expect(response.errors).to be_empty
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,12 @@
1
+ require 'rubocop'
2
+
3
+ gem_root = File.expand_path('../', __FILE__)
4
+ CONFIG_FILE_PATH = File.join(gem_root, 'config', 'rubocop_spec.yml')
5
+
6
+ describe 'rubocop' do
7
+ let(:args) { ['--format', 'simple', '-D', gem_root] }
8
+
9
+ it 'passes all cops' do
10
+ expect { RuboCop::CLI.new.run(args) }.to output(/no offenses detected/).to_stdout
11
+ end
12
+ end
@@ -0,0 +1,13 @@
1
+ shared_examples 'configured `Client`' do
2
+ it 'preserves credentials in `Client`' do
3
+ expect(subject.account_id).to eq account_id
4
+ expect(subject.passcode).to eq account_passcode
5
+ end
6
+ end
7
+
8
+ shared_examples 'configured `Client`' do
9
+ it 'preserves credentials in `Client`' do
10
+ expect(subject.account_id).to eq account_id
11
+ expect(subject.passcode).to eq account_passcode
12
+ end
13
+ end
@@ -0,0 +1,105 @@
1
+ shared_examples_for 'setting allowed identities for' do |type|
2
+ type_data = type == 'event' ? 'evtData' : 'profileData'
3
+ described_class::ALLOWED_IDENTITIES.each do |id|
4
+ context "when `identity` set as `#{id}` in the event" do
5
+ let!(:params_ext) { params.merge!(identity: id) }
6
+ let!(:data_ext) { data.merge!(id => '1414') }
7
+
8
+ it { is_expected.to include(id => '1414') }
9
+ it { expect(subject[type_data]).not_to include(id => '1414') }
10
+ end
11
+ end
12
+ end
13
+
14
+ shared_examples_for 'choosing identity for' do |type|
15
+ evt_name = type == 'event' ? { name: 'Evt' } : {}
16
+ before { CleverTap.setup { |c| c.identity_field = 'ID' } }
17
+ let(:params) { { data: data }.merge!(evt_name) }
18
+ let(:data) { { 'ID' => 1, 'Name' => 'John' } }
19
+
20
+ context 'when custom `identity` from config' do
21
+ it { is_expected.to include 'identity' => '1' }
22
+ end
23
+
24
+ context 'when `identity` different from ALLOWED_IDENTITIES and config' do
25
+ let!(:params_ext) { params.merge!(identity: 'email') }
26
+ let!(:data_ext) { data.merge!('email' => 'example@email.com') }
27
+
28
+ it { is_expected.to include 'identity' => '1' }
29
+ end
30
+
31
+ context 'when `identity` missing from `data`' do
32
+ let(:data) { { 'Name' => 'John' } }
33
+
34
+ it { expect { subject }.to raise_error CleverTap::MissingIdentityError }
35
+ end
36
+
37
+ it_behaves_like 'setting allowed identities for', type
38
+ end
39
+
40
+ shared_examples_for 'choosing timestamp' do
41
+ let(:data) { { 'FBID' => 1, 'Name' => 'John' } }
42
+ let(:params) { { data: data, identity: 'FBID', name: 'evt' } }
43
+
44
+ context 'when no `timestamp_field`' do
45
+ it { is_expected.not_to include 'ts' }
46
+ end
47
+
48
+ context 'when specific `timestamp` field' do
49
+ let!(:data_ext) { data.merge!('Open Time' => open_time) }
50
+ let!(:params_ext) { params.merge!(timestamp_field: 'Open Time') }
51
+
52
+ context 'and `timestamp_field` is Unix timestamp' do
53
+ let(:open_time) { '1508241881' }
54
+ it { is_expected.to include('ts' => open_time.to_i) }
55
+ end
56
+
57
+ context 'and `timestamp_field` is `DateTime` timestamp' do
58
+ let(:open_time) { Time.now }
59
+ it { is_expected.to include('ts' => open_time.to_i) }
60
+ end
61
+ end
62
+
63
+ context 'when `custom_timestamp` specified' do
64
+ let!(:params_ext) { params.merge!(custom_timestamp: open_time) }
65
+
66
+ context 'and `custom_timestamp` is Unix timestamp' do
67
+ let(:open_time) { '1508241881' }
68
+ it { is_expected.to include('ts' => open_time.to_i) }
69
+ end
70
+
71
+ context 'and `custom_timestamp` is `DateTime` timestamp' do
72
+ let(:open_time) { Time.now }
73
+ it { is_expected.to include('ts' => open_time.to_i) }
74
+ end
75
+ end
76
+ end
77
+
78
+ shared_examples_for 'proper type' do
79
+ let(:data) { { 'FBID' => '1414', 'Name' => 'John' } }
80
+ let(:params) { { data: data, name: 'e', identity: 'FBID' } }
81
+
82
+ it { is_expected.to include described_class::TYPE_KEY_STRING => described_class::TYPE_VALUE_STRING }
83
+ end
84
+
85
+ shared_examples_for 'constructing data for' do |type|
86
+ obj_type = type == 'event' ? 'evtData' : 'profileData'
87
+ evt_name = type == 'event' ? { name: 'Evt' } : {}
88
+
89
+ let(:data) { { 'FBID' => '1414', 'Name' => 'John' } }
90
+ let(:params) { { data: data, identity: 'FBID' }.merge!(evt_name) }
91
+
92
+ context 'when no `data` param in `params` hash' do
93
+ let(:params) { {}.merge!(evt_name) }
94
+ it { expect { subject }.to raise_error CleverTap::NoDataError }
95
+ end
96
+
97
+ context 'when `data` empty hash in `params` hash' do
98
+ let(:params) { { data: {} }.merge!(evt_name) }
99
+ it { expect { subject }.to raise_error CleverTap::NoDataError }
100
+ end
101
+
102
+ context 'when `data` available in `params` hash' do
103
+ it { is_expected.to include(obj_type => { 'Name' => 'John' }) }
104
+ end
105
+ end
@@ -0,0 +1,18 @@
1
+ $LOAD_PATH.unshift File.expand_path('../lib', __FILE__)
2
+
3
+ require 'clever_tap'
4
+
5
+ require 'vcr'
6
+ require 'vcr_config'
7
+
8
+ require 'factories/profile'
9
+ require 'pry-byebug'
10
+
11
+ # Use for recording VCR cassettes
12
+ AUTH_ACCOUNT_ID = ENV['CLEVER_TAP_ACCOUNT_ID'] || 'fake-id'
13
+ AUTH_PASSCODE = ENV['CLEVER_TAP_PASSCODE'] || 'fake-passcode'
14
+
15
+ RSpec.configure do |config|
16
+ config.expect_with(:rspec) { |c| c.syntax = :expect }
17
+ config.mock_with(:rspec) { |c| c.syntax = :expect }
18
+ end