responsys-api 0.0.8 → 0.0.9

Sign up to get free protection for your applications and to get access to all the features.
Files changed (49) hide show
  1. checksums.yaml +8 -8
  2. data/.gitignore +3 -0
  3. data/.travis.yml +7 -0
  4. data/README.md +2 -2
  5. data/Rakefile +11 -0
  6. data/lib/responsys/api/campaign.rb +1 -1
  7. data/lib/responsys/api/client.rb +7 -10
  8. data/lib/responsys/api/object/email_format.rb +1 -1
  9. data/lib/responsys/api/object/field_type.rb +1 -1
  10. data/lib/responsys/api/object/optional_data.rb +1 -1
  11. data/lib/responsys/api/object/query_column.rb +1 -1
  12. data/lib/responsys/api/object/record.rb +1 -1
  13. data/lib/responsys/api/object/record_data.rb +1 -1
  14. data/lib/responsys/api/session.rb +16 -1
  15. data/lib/responsys/helper.rb +9 -1
  16. data/lib/responsys/i18n/en.yml +24 -19
  17. data/lib/responsys/i18n/fr.yml +21 -0
  18. data/lib/responsys_api.rb +1 -3
  19. data/responsys-api.gemspec +1 -1
  20. data/spec/api/campaign_spec.rb +3 -7
  21. data/spec/api/client_spec.rb +18 -48
  22. data/spec/api/list_spec.rb +7 -11
  23. data/spec/api/table_spec.rb +26 -31
  24. data/spec/api_credentials.sample.yml +3 -0
  25. data/spec/fixtures/vcr_cassettes/api/client/expired_session.yml +14 -953
  26. data/spec/fixtures/vcr_cassettes/api/list/merge.yml +9 -97
  27. data/spec/fixtures/vcr_cassettes/api/list/retrieve.yml +11 -11
  28. data/spec/fixtures/vcr_cassettes/api/list/retrieve_single.yml +9 -97
  29. data/spec/fixtures/vcr_cassettes/api/list/retrieve_single_single.yml +9 -9
  30. data/spec/fixtures/vcr_cassettes/api/profile_extension/merge_profile_extension_records.yml +10 -10
  31. data/spec/fixtures/vcr_cassettes/api/profile_extension/retrieve_profile_extension_records.yml +10 -10
  32. data/spec/fixtures/vcr_cassettes/api/table/create_with_pk.yml +8 -8
  33. data/spec/fixtures/vcr_cassettes/api/table/delete_with_pk.yml +8 -8
  34. data/spec/fixtures/vcr_cassettes/member/present1.yml +8 -945
  35. data/spec/fixtures/vcr_cassettes/member/present2.yml +8 -8
  36. data/spec/fixtures/vcr_cassettes/member/present3.yml +8 -8
  37. data/spec/fixtures/vcr_cassettes/member/present4.yml +8 -8
  38. data/spec/fixtures/vcr_cassettes/member/present5.yml +8 -8
  39. data/spec/fixtures/vcr_cassettes/member/retrieve_profile_extension.yml +19 -19
  40. data/spec/helper_spec.rb +31 -0
  41. data/spec/member_spec.rb +12 -21
  42. data/spec/spec_helper.rb +32 -15
  43. data/spec/test_data.yml +38 -0
  44. metadata +10 -12
  45. data/spec/fixtures/vcr_cassettes/api/campaign/login.yml +0 -940
  46. data/spec/fixtures/vcr_cassettes/api/list/login.yml +0 -982
  47. data/spec/fixtures/vcr_cassettes/api/profile_extension/login.yml +0 -982
  48. data/spec/fixtures/vcr_cassettes/api/table/create.yml +0 -45
  49. data/spec/fixtures/vcr_cassettes/api/table/delete.yml +0 -45
checksums.yaml CHANGED
@@ -1,15 +1,15 @@
1
1
  ---
2
2
  !binary "U0hBMQ==":
3
3
  metadata.gz: !binary |-
4
- ZmRlYWRjMDA4YTliNzE3NGY2NzIzZGUyYjM3OTkyNjUyNGFkZmI1Yw==
4
+ NThlNWRiM2JmMDc1ZDJhNGM2ZjI1YWMwZjhiMGViYzM4MGI3ZGEyMw==
5
5
  data.tar.gz: !binary |-
6
- MjA4Nzc5MzFmMzVkZmNkYTlmOWZkZTZjNWMxMDM0NzhkOGQ0NDVjNw==
6
+ ZDkyMzQ2YTA3NzNlNjJhYzVkYjg1NWZmYjYyMDgzZTQ1ZWE2NzY2Nw==
7
7
  SHA512:
8
8
  metadata.gz: !binary |-
9
- NTNiYzIzYjNjYWRmZDYyYWEyZWE3N2I1ODQxNmEwOTYzY2Q1M2EyNWE1NWZk
10
- ZjI2MGRmODMzZDc2OGMzZTdhM2NmMTVhOGQwZGRhN2Y5OWJkNThiNDBhYzg3
11
- Njk2NTZhOGMwMDIxZGJjNTBlY2Q2YWM1OGZhNGFlMGM3MDZiNzA=
9
+ ZjA1YzZmYjU5N2FiYzMzY2VlZjcyYTAwZWI3ZDljOTAxMDkyNjE2NzY3MmI5
10
+ ZTQ0NzAzNzYxZDIzNmVjM2RjYTA1MzhhNDMwYmNhMWMzYWY1YjQ1MzE5Yjdm
11
+ N2FhMmM0ZWEzYzExYmRkMTJmMGMxMDBmOTM0OWQwOGU2NGU0Yzc=
12
12
  data.tar.gz: !binary |-
13
- YzZmYzk1NTkwOGMyOTRmOTUyYjYxOWY1NTdkNzM1NDgyNjFlZjhkNjVkZjgy
14
- ZDkyYjU0M2E3MzYxNjc2YjE2YzAxZjUyOWVkMTk4MmUyYzA5MTBlMGU5NjVj
15
- Njg5NGYxOWEyZjc5ZjhmZGRjYTFhNGZkMDU4MDZlODE0ODJmODE=
13
+ MWFlYzk1Y2E3ZGRjYTUyNTY3NzU0ODE3NzMwZTk4ZjgyODkzYTYzZDAwODM3
14
+ ZDcxYWZmZmM4NmUyOWJlNDFhZjRkYzRmZWU4YmIzNDc3YmZmNDA5OTZjOTBj
15
+ YTc1NGQ5ZDRlOWJlY2Y0MjI2YTc5N2UxM2EwMWY0ZmRiYmVhYzA=
data/.gitignore CHANGED
@@ -20,3 +20,6 @@ lib/.DS_Store
20
20
  lib/responsys/.DS_Store
21
21
  .rvmrc
22
22
  .idea/
23
+ .ruby-version
24
+
25
+ spec/api_credentials.yml
@@ -0,0 +1,7 @@
1
+ language: ruby
2
+ rvm:
3
+ - 1.9.3
4
+ - 2.1.2
5
+
6
+ gemfile:
7
+ - Gemfile
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # ResponsysApi
1
+ # ResponsysApi ![Master branch build status](https://travis-ci.org/dandemeyere/responsys-api.svg?branch=master)
2
2
 
3
3
  A gem to help you communicate to the Responsys Interact SOAP API. Currently working of Responsys Interact version 6.20.
4
4
 
@@ -10,7 +10,7 @@ Have a look at our [wiki](https://github.com/dandemeyere/responsys-api/wiki) to
10
10
 
11
11
  Add this line to your application"s Gemfile:
12
12
 
13
- gem "responsys-api", "~> 0.0.6"
13
+ gem "responsys-api", "~> 0.0.8"
14
14
 
15
15
  Or install it locally with:
16
16
 
data/Rakefile CHANGED
@@ -1 +1,12 @@
1
1
  require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+ require "rake/testtask"
4
+
5
+ task :default => [:test]
6
+
7
+ task :test do
8
+ RSpec::Core::RakeTask.new(:spec) do |t|
9
+ t.pattern = "spec/**/*_spec.rb"
10
+ end
11
+ Rake::Task["spec"].execute
12
+ end
@@ -6,7 +6,7 @@ module Responsys
6
6
  include Responsys::Exceptions
7
7
 
8
8
  def trigger_message(campaign, recipients)
9
- raise ParameterException, I18n.t("api.campaign.incorrect_recipients_type") unless recipients.is_a? Array
9
+ raise ParameterException, Responsys::Helper.get_message("api.campaign.incorrect_recipients_type") unless recipients.is_a? Array
10
10
  message = {
11
11
  campaign: campaign.to_api,
12
12
  recipientData: recipients.map(&:to_api)
@@ -27,12 +27,14 @@ module Responsys
27
27
  else
28
28
  @client = Savon.client(wsdl: settings[:wsdl], element_form_default: :qualified, ssl_version: ssl_version)
29
29
  end
30
-
31
- login
32
30
  end
33
31
 
34
32
  def api_method(action, message = nil, response_type = :hash)
33
+ raise Responsys::Helper.get_message("api.client.api_method.wrong_action_#{action.to_s}") if action.to_sym == :login || action.to_sym == :logout
34
+
35
35
  begin
36
+ login
37
+
36
38
  response = run_with_credentials(action, message, jsession_id, header)
37
39
 
38
40
  case response_type
@@ -43,14 +45,9 @@ module Responsys
43
45
  end
44
46
 
45
47
  rescue Exception => e
46
- error_response = Responsys::Helper.format_response_with_errors(e)
47
-
48
- if error_response[:error][:code] == "INVALID_SESSION_ID"
49
- login
50
- api_method(action, message, response_type)
51
- else
52
- error_response
53
- end
48
+ Responsys::Helper.format_response_with_errors(e)
49
+ ensure
50
+ logout
54
51
  end
55
52
  end
56
53
 
@@ -10,7 +10,7 @@ module Responsys
10
10
  if AVAILABLE_EMAIL_FORMAT.include? email_format
11
11
  @email_format_string = email_format
12
12
  else
13
- raise ParameterException, I18n.t("api.object.email_format.incorrect_email_format")
13
+ raise ParameterException, Responsys::Helper.get_message("api.object.email_format.incorrect_email_format")
14
14
  end
15
15
  end
16
16
 
@@ -10,7 +10,7 @@ module Responsys
10
10
  if FIELD_TYPES.include? field_type
11
11
  @field_type_string = field_type
12
12
  else
13
- raise ParameterException, I18n.t("api.object.field_type.incorrect_field_type")
13
+ raise ParameterException, Responsys::Helper.get_message("api.object.field_type.incorrect_field_type")
14
14
  end
15
15
  end
16
16
 
@@ -6,7 +6,7 @@ module Responsys
6
6
  attr_accessor :name, :value
7
7
 
8
8
  def initialize(name = "", value = "")
9
- raise ParameterException, I18n.t("api.object.optional_data.incorrect_optional_data_type") unless (name.is_a? String) && (value.is_a? String)
9
+ raise ParameterException, Responsys::Helper.get_message("api.object.optional_data.incorrect_optional_data_type") unless (name.is_a? String) && (value.is_a? String)
10
10
  @name = name
11
11
  @value = value
12
12
  end
@@ -10,7 +10,7 @@ module Responsys
10
10
  if AVAILABLE_QUERY_COLUMN.include? query_column
11
11
  @query_column_string = query_column
12
12
  else
13
- raise ParameterException, I18n.t("api.object.query_column.incorrect_query_column")
13
+ raise ParameterException, Responsys::Helper.get_message("api.object.query_column.incorrect_query_column")
14
14
  end
15
15
  end
16
16
 
@@ -6,7 +6,7 @@ module Responsys
6
6
  attr_accessor :field_values
7
7
 
8
8
  def initialize(field_values)
9
- raise ParameterException, I18n.t("api.object.record.incorrect_field_values_type") unless field_values.is_a? Array
9
+ raise ParameterException, Responsys::Helper.get_message("api.object.record.incorrect_field_values_type") unless field_values.is_a? Array
10
10
  @field_values = field_values
11
11
  end
12
12
 
@@ -7,7 +7,7 @@ module Responsys
7
7
  attr_accessor :field_names, :records
8
8
 
9
9
  def initialize(data)
10
- raise ParameterException, I18n.t("api.object.record_data.incorrect_record_data_type") unless data.is_a? Array
10
+ raise ParameterException, Responsys::Helper.get_message("api.object.record_data.incorrect_record_data_type") unless data.is_a? Array
11
11
 
12
12
  self.field_names = data.map { |record| record.keys }.flatten.uniq
13
13
 
@@ -2,6 +2,8 @@ module Responsys
2
2
  module Api
3
3
  module Session
4
4
  def login
5
+ logout if logged_in?
6
+
5
7
  response = run("login", credentials)
6
8
  establish_session_id(response)
7
9
  establish_jsession_id(response)
@@ -9,7 +11,14 @@ module Responsys
9
11
  end
10
12
 
11
13
  def logout
12
- api_method(:logout)
14
+ return unless logged_in?
15
+
16
+ run_with_credentials(:logout, nil, jsession_id, header)
17
+ destroy_session_objects
18
+ end
19
+
20
+ def logged_in?
21
+ !(session_id.nil? || jsession_id.nil? || header.nil?)
13
22
  end
14
23
 
15
24
  private
@@ -25,6 +34,12 @@ module Responsys
25
34
  def set_session_credentials
26
35
  @header = { SessionHeader: { sessionId: session_id } }
27
36
  end
37
+
38
+ def destroy_session_objects
39
+ @session_id = nil
40
+ @jsession_id = nil
41
+ @header = nil
42
+ end
28
43
  end
29
44
  end
30
45
  end
@@ -77,7 +77,15 @@ module Responsys
77
77
  end
78
78
 
79
79
  def self.format_response_with_message(i18n_key)
80
- { status: "failure", error: { http_status_code: "", code: i18n_key.split('.')[-1], message: I18n.t(i18n_key) } }
80
+ { status: "failure", error: { http_status_code: "", code: i18n_key.split('.')[-1], message: get_message(i18n_key) } }
81
+ end
82
+
83
+ def self.get_message(key)
84
+ begin
85
+ I18n.t(key, scope: :responsys_api, locale: I18n.locale, raise: true)
86
+ rescue I18n::MissingTranslationData
87
+ I18n.t(key, scope: :responsys_api, locale: :en)
88
+ end
81
89
  end
82
90
  end
83
91
  end
@@ -1,20 +1,25 @@
1
1
  en:
2
- api:
3
- object:
4
- field_type:
5
- incorrect_field_type: The field type string you provided is incorrect
6
- query_column:
7
- incorrect_query_column: The query column you provided is incorrect
8
- record_data:
9
- incorrect_record_data_type: The data you provided is not an array
10
- optional_data:
11
- incorrect_optional_data_type: The name and value need to be Strings
12
- email_format:
13
- incorrect_email_format: The email format is not supported
14
- record:
15
- incorrect_field_values_type: The field_values must be an array
16
- campaign:
17
- incorrect_recipients_type: Recipients parameter must be an array
18
- member:
19
- riid_missing: Variable riid is not provided to the member
20
- record_not_found: The member has not been found in the list
2
+ responsys_api:
3
+ api:
4
+ object:
5
+ field_type:
6
+ incorrect_field_type: The field type string you provided is incorrect
7
+ query_column:
8
+ incorrect_query_column: The query column you provided is incorrect
9
+ record_data:
10
+ incorrect_record_data_type: The data you provided is not an array
11
+ optional_data:
12
+ incorrect_optional_data_type: The name and value need to be Strings
13
+ email_format:
14
+ incorrect_email_format: The email format is not supported
15
+ record:
16
+ incorrect_field_values_type: The field_values must be an array
17
+ client:
18
+ api_method:
19
+ wrong_action_login: Please use the dedicated login method
20
+ wrong_action_logout: Please use the dedicated logout method
21
+ campaign:
22
+ incorrect_recipients_type: Recipients parameter must be an array
23
+ member:
24
+ riid_missing: Variable riid is not provided to the member
25
+ record_not_found: The member has not been found in the list
@@ -0,0 +1,21 @@
1
+ fr:
2
+ responsys_api:
3
+ api:
4
+ object:
5
+ field_type:
6
+ incorrect_field_type: Le type fourni à l'objet FieldType est incorrect
7
+ query_column:
8
+ incorrect_query_column: La colonne de recherche est incorrecte
9
+ record_data:
10
+ incorrect_record_data_type: La donnée fournie n'est pas de type Array
11
+ optional_data:
12
+ incorrect_optional_data_type: Le nom et la valeur doivent être de type String
13
+ email_format:
14
+ incorrect_email_format: Le format de l'email est incorrect
15
+ record:
16
+ incorrect_field_values_type: L'objet fournissant les valeurs doit être de type Array
17
+ campaign:
18
+ incorrect_recipients_type: La liste des destinataires doit être de type Array
19
+ member:
20
+ riid_missing: Le riid n'est pas fournie à l'objet Member
21
+ record_not_found: L'enregistrement n'a pas été trouvé dans la liste
@@ -1,8 +1,6 @@
1
1
  require "i18n"
2
2
 
3
- I18n.load_path << File.expand_path("../responsys/i18n/en.yml", __FILE__)
4
- I18n.locale = :en
5
- I18n.enforce_available_locales = false
3
+ I18n.load_path.concat Dir.glob( File.dirname(__FILE__) + "/responsys/i18n/*.yml" )
6
4
 
7
5
  require "responsys/exceptions/all"
8
6
  require "responsys/helper"
@@ -4,7 +4,7 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
4
 
5
5
  Gem::Specification.new do |spec|
6
6
  spec.name = "responsys-api"
7
- spec.version = "0.0.8"
7
+ spec.version = "0.0.9"
8
8
  spec.authors = ["Dan DeMeyere", "Florian Lorrain", "Morgan Griggs", "Mike Rocco"]
9
9
  spec.email = ["dan@thredup.com", "florian.lorrain@thredup.com", "morgan@thredup.com", "michael.rocco@thredup.com"]
10
10
  spec.description = "A gem to integrate with the Responsys SOAP API"
@@ -5,9 +5,7 @@ describe Responsys::Api::Campaign do
5
5
 
6
6
  context "Trigger Message" do
7
7
  before(:all) do
8
- VCR.use_cassette("api/campaign/login") do
9
- @client = Responsys::Api::Client.instance
10
- end
8
+ @client = Responsys::Api::Client.instance
11
9
  end
12
10
 
13
11
  before(:each) do
@@ -17,10 +15,8 @@ describe Responsys::Api::Campaign do
17
15
  end
18
16
 
19
17
  it "should pass api_method a message Hash" do
20
- VCR.use_cassette("api/campaign/login") do
21
- expect(@client).to receive(:api_method).with(anything, be_a_kind_of(Hash))
22
- @client.trigger_message(@campaign, [@recipientData])
23
- end
18
+ expect(@client).to receive(:api_method).with(anything, be_a_kind_of(Hash))
19
+ @client.trigger_message(@campaign, [@recipientData])
24
20
  end
25
21
  end
26
22
 
@@ -4,43 +4,12 @@ require "singleton"
4
4
 
5
5
  describe Responsys::Api::Client do
6
6
 
7
- context "expired session" do
8
- before(:example) do
9
- allow_any_instance_of(Responsys::Api::Client).to receive(:login).and_return(nil)
10
-
11
- Responsys::Api::Client.instance.instance_variable_set(:@session_id, "fake_session_id")
12
- Responsys::Api::Client.instance.instance_variable_set(:@jsession_id, HTTPI::Cookie.new("jsessionid=fakejsessionid; Path=/; HttpOnly"))
13
- Responsys::Api::Client.instance.instance_variable_set(:@header, { SessionHeader: { sessionId: "fake_session_id" } })
14
- end
15
-
16
- it "should ask for a new session" do
17
- VCR.use_cassette("api/client/expired_session") do
18
- expect_any_instance_of(Responsys::Api::Client).to receive(:login).exactly(1).times.and_call_original
19
- result = Responsys::Api::Client.instance.api_method(:list_folders)
20
-
21
- expect(result[:status]).to eq("ok")
22
- end
23
- end
24
-
25
- it "should rerun the request with a new session id" do
26
- VCR.use_cassette("api/client/expired_session") do
27
- expect_any_instance_of(Responsys::Api::Client).to receive(:login).exactly(1).times.and_call_original
28
-
29
- expect(Responsys::Api::Client.instance.header[:SessionHeader][:sessionId]).to eq("fake_session_id")
30
-
31
- Responsys::Api::Client.instance.api_method(:list_folders)
32
-
33
- expect(Responsys::Api::Client.instance.header[:SessionHeader][:sessionId]).to eq("5GXdGHHKOLqsf4ukCpwQYz3B0b")
34
- end
35
- end
36
-
37
- end
38
-
39
7
  context "Authentication" do
8
+ subject { Responsys::Api::Client.instance }
40
9
  let(:savon_client) { double("savon client") }
41
10
 
42
11
  before(:context) do
43
- @credentials = { username: "your_responsys_username", password: "your_responsys_password" }
12
+ @credentials = { username: CREDENTIALS["username"], password: CREDENTIALS["password"] }
44
13
  end
45
14
 
46
15
  after(:context) do
@@ -52,7 +21,7 @@ describe Responsys::Api::Client do
52
21
 
53
22
  responsys = Responsys::Api::Client.instance
54
23
 
55
- expect(responsys.credentials).to eq({ username: "your_responsys_username", password: "your_responsys_password" })
24
+ expect(responsys.credentials).to eq({ username: CREDENTIALS["username"], password: CREDENTIALS["password"] })
56
25
  end
57
26
 
58
27
  context "login" do
@@ -71,7 +40,7 @@ describe Responsys::Api::Client do
71
40
  allow(response).to receive(:body).and_return(body)
72
41
  allow(response).to receive(:http).and_return(double("cookies", cookies: cookies))
73
42
 
74
- allow(Savon).to receive(:client).with({ wsdl: "https://wsxxxx.responsys.net/webservices/wsdl/ResponsysWS_Level1.wsdl", element_form_default: :qualified, ssl_version: :TLSv1}).and_return(savon_client) #Avoid the verification of the wsdl
43
+ allow(Savon).to receive(:client).with({ wsdl: CREDENTIALS["wsdl"], element_form_default: :qualified, ssl_version: :TLSv1}).and_return(savon_client) #Avoid the verification of the wsdl
75
44
  allow_any_instance_of(Responsys::Api::Client).to receive(:run).with("login", @credentials).and_return(response) #Verification of credentials
76
45
  allow(savon_client).to receive(:call).with(:login, @credentials ).and_return(response) #Actual login call
77
46
 
@@ -79,28 +48,29 @@ describe Responsys::Api::Client do
79
48
  end
80
49
 
81
50
  it "should set the session ids" do
82
- instance = Responsys::Api::Client.instance #Get it
51
+ subject.login
52
+
53
+ expect(subject.header).to eq({ SessionHeader: { sessionId: "fake_session_id" } }) #Test the ids are right
54
+ expect(subject.jsession_id).to eq("fake_jsession_id")
55
+ end
83
56
 
84
- expect(instance.header).to eq({ SessionHeader: { sessionId: "fake_session_id" } }) #Test the ids are right
85
- expect(instance.jsession_id).to eq("fake_jsession_id")
57
+ it "should refuse the access to api_method for login" do
58
+ expect{ subject.api_method(:login) }.to raise_error("Please use the dedicated login method")
86
59
  end
87
60
  end
88
61
 
89
62
  context "logout" do
90
- before(:example) do
91
- allow(Savon).to receive(:client).with({ wsdl: "https://wsxxxx.responsys.net/webservices/wsdl/ResponsysWS_Level1.wsdl", element_form_default: :qualified, ssl_version: :TLSv1}).and_return(savon_client) #Avoid the verification of the wsdl
92
- allow_any_instance_of(Responsys::Api::Client).to receive(:login).and_return(nil) #Avoid credentials checking
93
-
94
- Singleton.__init__(Responsys::Api::Client)
95
- end
63
+ subject { Responsys::Api::Client.instance }
96
64
 
97
65
  it "should logout" do
98
- instance = Responsys::Api::Client.instance #Get it
66
+ allow(subject).to receive(:logged_in?).and_return(true)
67
+ expect(subject).to receive(:run_with_credentials).with(:logout, anything, anything, anything) #Check the call is actually being done
99
68
 
100
- allow(Responsys::Helper).to receive(:format_response_hash).with(any_args) #We dont want to parse the response
101
- expect(savon_client).to receive(:call).with(:logout, anything) #Check the call is actually being done
69
+ subject.logout
70
+ end
102
71
 
103
- instance.logout
72
+ it "should refuse the access to api_method for logout" do
73
+ expect{ subject.api_method(:logout) }.to raise_error("Please use the dedicated logout method")
104
74
  end
105
75
  end
106
76
  end