diff --git a/.env.example b/.env.example index 74ceadec5..609523c43 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ CONCIERGE_DATABASE_URL="postgres://localhost/concierge_development" WAYTOSTAY_URL="https://apis.sandbox.waytostay.com:25443" +ROOMORAMA_SECRET_AUDIT=xxx ROOMORAMA_SECRET_JTB=xxx ROOMORAMA_SECRET_KIGO_LEGACY=xxx ROOMORAMA_SECRET_KIGO=xxx diff --git a/apps/api/config/environment_variables.yml b/apps/api/config/environment_variables.yml index 60227e882..9671ceaa6 100644 --- a/apps/api/config/environment_variables.yml +++ b/apps/api/config/environment_variables.yml @@ -1,3 +1,4 @@ +- ROOMORAMA_SECRET_AUDIT - ROOMORAMA_SECRET_JTB - ROOMORAMA_SECRET_KIGO_LEGACY - ROOMORAMA_SECRET_KIGO diff --git a/apps/api/config/initializers/validate_supplier_credentials.rb b/apps/api/config/initializers/validate_supplier_credentials.rb index 6dc55a6b7..7eacc60a9 100644 --- a/apps/api/config/initializers/validate_supplier_credentials.rb +++ b/apps/api/config/initializers/validate_supplier_credentials.rb @@ -2,6 +2,7 @@ if enforce_on_envs.include?(Hanami.env) Concierge::Credentials.validate_credentials!({ + audit: %w(secret_key host), atleisure: %w(username password test_mode), jtb: %w(id user password company url), kigo: %w(subscription_key), diff --git a/apps/api/config/routes.rb b/apps/api/config/routes.rb index d1a5b1fd1..75fb56477 100644 --- a/apps/api/config/routes.rb +++ b/apps/api/config/routes.rb @@ -4,6 +4,7 @@ post '/kigo/legacy/quote', to: 'kigo/legacy#quote' post '/poplidays/quote', to: 'poplidays#quote' post '/waytostay/quote', to: 'waytostay#quote' +post '/audit/quote', to: 'audit#quote' post '/ciirus/quote', to: 'ciirus#quote' post '/jtb/booking', to: 'j_t_b#booking' @@ -12,8 +13,10 @@ post '/ciirus/booking', to: 'ciirus#booking' post '/kigo/booking', to: 'kigo#booking' post '/kigo/legacy/booking', to: 'kigo/legacy#booking' +post '/audit/booking', to: 'audit#booking' post 'waytostay/cancel', to: 'waytostay#cancel' +post '/audit/cancel', to: 'audit#cancel' post 'ciirus/cancel', to: 'ciirus#cancel' post 'checkout', to: 'static#checkout' diff --git a/apps/api/controllers/audit/booking.rb b/apps/api/controllers/audit/booking.rb new file mode 100644 index 000000000..7ca75e8a8 --- /dev/null +++ b/apps/api/controllers/audit/booking.rb @@ -0,0 +1,20 @@ +require_relative "../booking" + +module API::Controllers::Audit + + # API::Controllers::Audit::Booking + # + # Performs create booking for properties from Audit. + class Booking + include API::Controllers::Booking + + def create_booking(params) + Audit::Client.new.book(params) + end + + def supplier_name + Audit::Client::SUPPLIER_NAME + end + end +end + diff --git a/apps/api/controllers/audit/cancel.rb b/apps/api/controllers/audit/cancel.rb new file mode 100644 index 000000000..bdbca075a --- /dev/null +++ b/apps/api/controllers/audit/cancel.rb @@ -0,0 +1,19 @@ +require_relative "../cancel" + +module API::Controllers::Audit + + # API::Controllers::Audit::Cancel + # + # Cancels reservation from Audit. + class Cancel + include API::Controllers::Cancel + + def cancel_reservation(params) + Audit::Client.new.cancel(params) + end + + def supplier_name + Audit::Client::SUPPLIER_NAME + end + end +end diff --git a/apps/api/controllers/audit/quote.rb b/apps/api/controllers/audit/quote.rb new file mode 100644 index 000000000..2cf8de8db --- /dev/null +++ b/apps/api/controllers/audit/quote.rb @@ -0,0 +1,20 @@ +require_relative "../quote" + +module API::Controllers::Audit + + # API::Controllers::Audit::Quote + # + # Performs booking quotations for properties from Audit. + class Quote + include API::Controllers::Quote + + def quote_price(params) + Audit::Client.new.quote(params) + end + + def supplier_name + Audit::Client::SUPPLIER_NAME + end + end +end + diff --git a/apps/api/middlewares/authentication.rb b/apps/api/middlewares/authentication.rb index a7912c102..f5017db62 100644 --- a/apps/api/middlewares/authentication.rb +++ b/apps/api/middlewares/authentication.rb @@ -46,6 +46,7 @@ class Authentication # secrets.for(request_path) # => X32842I class Secrets APP_SECRETS = { + "/audit" => ENV["ROOMORAMA_SECRET_AUDIT"], "/jtb" => ENV["ROOMORAMA_SECRET_JTB"], "/kigo/legacy" => ENV["ROOMORAMA_SECRET_KIGO_LEGACY"], "/kigo" => ENV["ROOMORAMA_SECRET_KIGO"], diff --git a/apps/workers/suppliers/audit.rb b/apps/workers/suppliers/audit.rb new file mode 100644 index 000000000..6fa6052b5 --- /dev/null +++ b/apps/workers/suppliers/audit.rb @@ -0,0 +1,68 @@ +module Workers::Suppliers + + class Audit + SUPPLIER_NAME = "Audit" + attr_reader :property_sync, :calendar_sync, :host + + def initialize(host) + @host = host + @property_sync = Workers::PropertySynchronisation.new(host) + @calendar_sync = Workers::CalendarSynchronisation.new(host) + end + + def perform + result = importer.fetch_properties + if result.success? + result.value.each do |json| + property_sync.start(json['identifier']) do + importer.json_to_property(json) do |calendar_entries| + calendar_sync.start(json['identifier']) do + calendar = Roomorama::Calendar.new(json['identifier']) + calendar_entries.each {|entry| calendar.add entry } + Result.new(calendar) + end + end + end + end + + property_sync.finish! + calendar_sync.finish! + else + message = "Failed to perform the `#fetch_properties` operation" + announce_error(message, result) + end + end + + private + + def importer + @properties ||= ::Audit::Importer.new(credentials) + end + + def credentials + @credentials ||= Concierge::Credentials.for(SUPPLIER_NAME) + end + + def announce_error(message, result) + message = { + label: 'Synchronisation Failure', + message: message, + backtrace: caller + } + context = Concierge::Context::Message.new(message) + Concierge.context.augment(context) + + Concierge::Announcer.trigger(Concierge::Errors::EXTERNAL_ERROR, { + operation: 'sync', + supplier: SUPPLIER_NAME, + code: result.error.code, + context: Concierge.context.to_h, + happened_at: Time.now + }) + end + end +end + +Concierge::Announcer.on("sync.#{Workers::Suppliers::Audit::SUPPLIER_NAME}") do |host| + Workers::Suppliers::Audit.new(host).perform +end diff --git a/config/credentials/production.yml b/config/credentials/production.yml index d55e5eede..efd47dd48 100644 --- a/config/credentials/production.yml +++ b/config/credentials/production.yml @@ -29,6 +29,11 @@ waytostay: client_id: <%= ENV["WAYTOSTAY_CLIENT_ID"] %> client_secret: <%= ENV["WAYTOSTAY_CLIENT_SECRET"] %> +audit: + secret_key: test_secret + host: http://localhost:9292 + fetch_properties_endpoint: /spec/fixtures/audit/properties.json + ciirus: url: <%= ENV["CIIRUS_URL"] %> username: <%= ENV["CIIRUS_USERNAME"] %> diff --git a/config/credentials/staging.yml b/config/credentials/staging.yml index d55e5eede..efd47dd48 100644 --- a/config/credentials/staging.yml +++ b/config/credentials/staging.yml @@ -29,6 +29,11 @@ waytostay: client_id: <%= ENV["WAYTOSTAY_CLIENT_ID"] %> client_secret: <%= ENV["WAYTOSTAY_CLIENT_SECRET"] %> +audit: + secret_key: test_secret + host: http://localhost:9292 + fetch_properties_endpoint: /spec/fixtures/audit/properties.json + ciirus: url: <%= ENV["CIIRUS_URL"] %> username: <%= ENV["CIIRUS_USERNAME"] %> diff --git a/config/credentials/test.yml b/config/credentials/test.yml index 204b64d67..e4fd4cc67 100644 --- a/config/credentials/test.yml +++ b/config/credentials/test.yml @@ -35,6 +35,11 @@ waytostay: client_id: test_id client_secret: test_secret +audit: + secret_key: <%= ENV.fetch('AUDIT_SECRET_KEY', 'test_secret') %> + host: http://localhost:9292 + fetch_properties_endpoint: /spec/fixtures/audit/properties.json + ciirus: url: "http://www.example.org" username: "roomorama-user" diff --git a/config/suppliers.yml b/config/suppliers.yml index 86415f554..2609b84d2 100644 --- a/config/suppliers.yml +++ b/config/suppliers.yml @@ -21,9 +21,16 @@ WayToStay: availabilities: absence: "WayToStay calendar is synchronised with property metadata, due to the diff-like API provided." +Audit: + workers: + metadata: + every: "1h" + availabilities: + every: "1h" + Ciirus: workers: metadata: every: "1d" availabilities: - every: "5h" \ No newline at end of file + every: "5h" diff --git a/lib/concierge/suppliers/audit/client.rb b/lib/concierge/suppliers/audit/client.rb new file mode 100644 index 000000000..b6cea1d67 --- /dev/null +++ b/lib/concierge/suppliers/audit/client.rb @@ -0,0 +1,70 @@ +module Audit + # +Audit::Client+ + # + # This class is a convenience class for interacting with Audit. + # + # For more information on how to interact with Audit, check the project Wiki. + class Client + + SUPPLIER_NAME = "Audit" + + attr_reader :credentials + + def initialize + @credentials = Concierge::Credentials.for("audit") + end + + # On success, return Result wrapping Quotation object + # - When property_id is `success`, a successful response is returned + # - When property_id is `connection_timeout`, Faraday::TimeoutError should be raised by HTTP::Client + def quote(params) + client = Concierge::HTTPClient.new(credentials.host) + result = client.get("/spec/fixtures/audit/quotation.#{params[:property_id]}.json") + if result.success? + json = JSON.parse(result.value.body) + Result.new(Quotation.new(json['result'])) + else + result + end + end + + # On success, return Result wrapping Reservation object + # - When property_id is `success`, a successful response is returned + # - When property_id is `connection_timeout`, Faraday::TimeoutError should be raised by HTTP::Client + def book(params) + client = Concierge::HTTPClient.new(credentials.host) + result = client.get("/spec/fixtures/audit/booking.#{params[:property_id]}.json") + if result.success? + json = JSON.parse(result.value.body) + Result.new(Reservation.new(json['result'])) + else + result + end + end + + # On success, return Result wrapping reference_number String + # - When reference_number is `success`, a successful response is returned + # - When reference_number is `connection_timeout`, Faraday::TimeoutError should be raised by HTTP::Client + def cancel(params) + client = Concierge::HTTPClient.new(credentials.host) + result = client.get("/spec/fixtures/audit/cancel.#{params[:reference_number]}.json") + if result.success? + json = JSON.parse(result.value.body) + Result.new(json['result']) + else + result + end + end + + def announce_error(operation, result) + Concierge::Announcer.trigger(Concierge::Errors::EXTERNAL_ERROR, { + operation: operation, + supplier: SUPPLIER_NAME, + code: result.error.code, + context: Concierge.context.to_h, + happened_at: Time.now + }) + end + + end +end diff --git a/lib/concierge/suppliers/audit/importer.rb b/lib/concierge/suppliers/audit/importer.rb new file mode 100644 index 000000000..3f37af813 --- /dev/null +++ b/lib/concierge/suppliers/audit/importer.rb @@ -0,0 +1,68 @@ +module Audit + # +Audit::Importer+ + # + # This class wraps supplier API and provides data for building properties. + # + # Usage + # + # importer = Audit::Importer.new(credentials) + # importer.fetch_properties + # + # => # 'XX-12345-67', ...}, ...] + class Importer + attr_reader :credentials + + def initialize(credentials) + @credentials = credentials + end + + # retrieves the list of properties + def fetch_properties + client = Concierge::HTTPClient.new(credentials.host) + result = client.get(credentials.fetch_properties_endpoint) + if result.success? + json = JSON.parse(result.value.body) + Result.new(json['result']) + else + result + end + end + + def json_to_property(json) + # `Roomorama::Property.load` prefer absolute urls, but our fixture `url` values are relative + # make it happy + fix_relative_urls!(URI.join(credentials.host, credentials.fetch_properties_endpoint), json) + + Roomorama::Property.load(Concierge::SafeAccessHash.new json).tap do |property_result| + if property_result.success? + property = property_result.value + calendar_entries = json['availability_dates'].collect do |yyyymmdd, boolean| + Roomorama::Calendar::Entry.new( + date: yyyymmdd, + available: boolean, + nightly_rate: property.nightly_rate, + ) + end + yield calendar_entries + end + end + end + + private + + def fix_relative_urls!(base_uri, object) + case object + when Hash + object.each do |key, value| + if key == 'url' + object[key] = URI.join(base_uri, URI.escape(value)).to_s + elsif value.kind_of?(Hash) || value.kind_of?(Array) + fix_relative_urls!(base_uri, value) + end + end + when Array + object.each {|item| fix_relative_urls!(base_uri, item) } + end + end + end +end diff --git a/lib/concierge/suppliers/audit/server.rb b/lib/concierge/suppliers/audit/server.rb new file mode 100644 index 000000000..b4b47f833 --- /dev/null +++ b/lib/concierge/suppliers/audit/server.rb @@ -0,0 +1,82 @@ +require 'json' +require_relative '../../http_client' + +module Audit + # +Audit::Server+ + # + # This class is the Audit web app. + # + # For more information on how to interact with Audit, check the project Wiki. + class Server + + SCENARIOS = [ + 'success', + 'connection_timeout', + 'wrong_json', + 'invalid_json', + ] + + def initialize(app) + @app = app + end + + def call(env) + status, headers, body = @app.call(env) + status, headers, body = handle_404(env) || [status, headers, body] if status == 404 + + if File.basename(env['PATH_INFO']) =~ /booking/ + new_body = replace_response_body(body) + [status, headers.merge('Content-Length' => new_body.length.to_s), [new_body]] + else + [status, headers, body] + end + end + + private + + def retry_with(env, old_string, new_string) + @app.call(env.merge({ + 'PATH_INFO' => env['PATH_INFO'].gsub(old_string, new_string), + 'REQUEST_PATH' => env['REQUEST_PATH'] && env['REQUEST_PATH'].gsub(old_string, new_string), + })) + end + + def handle_404(env) + case File.basename(env['PATH_INFO']) + when /properties/ + property_json = JSON.parse(IO.read 'spec/fixtures/audit/property.json') + result = SCENARIOS.collect {|k| property_json.merge('identifier' => k, 'title' => "#{property_json['title']} (#{k})") } + new_body = JSON.pretty_generate(result: result) + [200, {}, [new_body]] + + when /sample/ + # sample = success + retry_with(env, 'sample', 'success') + + when /connection_timeout/ + # First we wait + sleep Concierge::HTTPClient::CONNECTION_TIMEOUT + 1 + + # Then we return the requested info (Concierge::HTTPClient should have errored out by now) + retry_with(env, 'connection_timeout', 'success') + + when /wrong_json/ + [200, {}, ["[1, 2, 3]"]] + + when /invalid_json/ + [200, {}, ["{"]] + end + end + + def replace_response_body(body) + body_string = case body + when Rack::File + IO.read body.path + else + body.join("") + end + + body_string.gsub("REPLACEME", SCENARIOS.sample) + end + end +end diff --git a/spec/api/controllers/audit/booking_spec.rb b/spec/api/controllers/audit/booking_spec.rb new file mode 100644 index 000000000..6546b0df2 --- /dev/null +++ b/spec/api/controllers/audit/booking_spec.rb @@ -0,0 +1,53 @@ +require 'spec_helper' +require_relative "../shared/booking_validations" + +RSpec.describe API::Controllers::Audit::Booking do + include Support::HTTPStubbing + + let(:params) { + { + property_id: "A123", + check_in: "2016-03-22", + check_out: "2016-03-24", + guests: 2, + subtotal: 300, + customer: { + first_name: "Alex", + last_name: "Black", + country: "India", + city: "Mumbai", + address: "first street", + postal_code: "123123", + email: "test@example.com", + phone: "555-55-55", + } + } + } + + it_behaves_like "performing booking parameters validations", controller_generator: -> { described_class.new } + + + describe "#call" do + + let(:response) { parse_response(described_class.new.call(params)) } + + it "returns proper error if external request failed" do + erred_reservation = Result.error(:network_error) + expect_any_instance_of(Audit::Client).to receive(:book).and_return(erred_reservation) + + expect(response.status).to eq 503 + expect(response.body["status"]).to eq "error" + expect(response.body["errors"]["booking"]).to eq "Could not create booking with remote supplier" + end + + it "returns a booking code when successful" do + reservation = Result.new(Reservation.new(params)) + reservation.value.reference_number = "test_code" + expect_any_instance_of(Audit::Client).to receive(:book).and_return(reservation) + + expect(response.status).to eq 200 + expect(response.body["status"]).to eq "ok" + expect(response.body["reference_number"]).to eq "test_code" + end + end +end diff --git a/spec/api/controllers/audit/cancel_spec.rb b/spec/api/controllers/audit/cancel_spec.rb new file mode 100644 index 000000000..f29e3bbac --- /dev/null +++ b/spec/api/controllers/audit/cancel_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' +require_relative "../shared/cancel" + +RSpec.describe API::Controllers::Audit::Cancel do + let(:params) { { reference_number: "A123" } } + + it_behaves_like "cancel action" do + let(:success_cases) { + [ + { params: {reference_number: "A023"}, cancelled_reference_number: "XYZ" }, + { params: {reference_number: "A024"}, cancelled_reference_number: "ASD" }, + ] + } + let(:error_cases) { + [ + { params: {reference_number: "A123"}, error: {"cancellation" => "Could not cancel with remote supplier"} }, + { params: {reference_number: "A124"}, error: {"cancellation" => "Already cancelled"} }, + ] + } + + before do + allow_any_instance_of(Audit::Client).to receive(:cancel) do |instance, par| + result = nil + error_cases.each do |kase| + if par.reference_number == kase[:params][:reference_number] + result = Result.error(:already_cancelled, kase[:error]) + break + end + end + success_cases.each do |kase| + if par.reference_number == kase[:params][:reference_number] + result = Result.new(kase[:cancelled_reference_number]) + break + end + end + result + end + end + end + +end diff --git a/spec/api/controllers/audit/quote_spec.rb b/spec/api/controllers/audit/quote_spec.rb new file mode 100644 index 000000000..ea779f64d --- /dev/null +++ b/spec/api/controllers/audit/quote_spec.rb @@ -0,0 +1,91 @@ +require "spec_helper" +require "concierge/result" +require_relative "../shared/quote_validations" +require_relative "../shared/external_error_reporting" + +RSpec.describe API::Controllers::Audit::Quote do + include Support::HTTPStubbing + + let(:params) { + { property_id: "success", check_in: "2016-03-22", check_out: "2016-03-25", guests: 2 } + } + + it_behaves_like "performing parameter validations", controller_generator: -> { described_class.new } do + let(:valid_params) { params } + end + + it_behaves_like "external error reporting" do + let(:params) { + { property_id: "success", unit_id: "123", check_in: "2016-03-22", check_out: "2016-03-25", guests: 2 } + } + let(:supplier_name) { "Audit" } + let(:error_code) { "savon_erorr" } + + def provoke_failure! + credentials = Concierge::Credentials.for("audit") + stub_call(:get, URI.join(credentials.host, "/spec/fixtures/audit/quotation.success.json").to_s) { raise Faraday::Error.new("oops123") } + Struct.new(:code).new("network_failure") + end + end + + describe "#call" do + subject { described_class.new.call(params) } + + it "returns a proper error message if client returns quotation with error" do + expect_any_instance_of(Audit::Client).to receive(:quote).and_return(Result.error(:network_error)) + + response = parse_response(subject) + expect(response.status).to eq 503 + expect(response.body["status"]).to eq "error" + expect(response.body["errors"]["quote"]).to eq "Could not quote price with remote supplier" + end + + it "returns unavailable quotation when client returns so" do + unavailable_quotation = Quotation.new({ + property_id: params[:property_id], + check_in: params[:check_in], + check_out: params[:check_out], + guests: params[:guests], + available: false + }) + expect_any_instance_of(Audit::Client).to receive(:quote).and_return(Result.new(unavailable_quotation)) + + response = parse_response(subject) + expect(response.status).to eq 200 + expect(response.body["status"]).to eq "ok" + expect(response.body["available"]).to eq false + expect(response.body["property_id"]).to eq "success" + expect(response.body["check_in"]).to eq "2016-03-22" + expect(response.body["check_out"]).to eq "2016-03-25" + expect(response.body["guests"]).to eq 2 + expect(response.body).not_to have_key("currency") + expect(response.body).not_to have_key("total") + end + + it "returns available quotation when call is successful" do + available_quotation = Quotation.new({ + property_id: params[:property_id], + check_in: params[:check_in], + check_out: params[:check_out], + guests: params[:guests], + available: true, + currency: "EUR", + total: 56.78, + }) + expect_any_instance_of(Audit::Client).to receive(:quote).and_return(Result.new(available_quotation)) + + response = parse_response(subject) + expect(response.status).to eq 200 + expect(response.body["status"]).to eq "ok" + expect(response.body["available"]).to eq true + expect(response.body["property_id"]).to eq "success" + expect(response.body["check_in"]).to eq "2016-03-22" + expect(response.body["check_out"]).to eq "2016-03-25" + expect(response.body["guests"]).to eq 2 + expect(response.body["currency"]).to eq "EUR" + expect(response.body["total"]).to eq 56.78 + + end + end + +end diff --git a/spec/fixtures/audit/booking.success.json b/spec/fixtures/audit/booking.success.json new file mode 100644 index 000000000..a71f8fe84 --- /dev/null +++ b/spec/fixtures/audit/booking.success.json @@ -0,0 +1,14 @@ +{ + "result": { + "reference_number": "REPLACEME", + "property_id": "String", + "unit_id": "String", + "check_in": "String", + "check_out": "String", + "guests": 3, + "code": "FOOBAR-123", + "extra": "Hash", + "customer": { + } + } +} diff --git a/spec/fixtures/audit/cancel.success.json b/spec/fixtures/audit/cancel.success.json new file mode 100644 index 000000000..80bf76ad5 --- /dev/null +++ b/spec/fixtures/audit/cancel.success.json @@ -0,0 +1,3 @@ +{ + "result": "success" +} diff --git a/spec/fixtures/audit/config.ru b/spec/fixtures/audit/config.ru new file mode 100644 index 000000000..d78d540ae --- /dev/null +++ b/spec/fixtures/audit/config.ru @@ -0,0 +1,34 @@ +# +Audit supplier server+ +# +# This is the mock API server for `Audit` supplier +# +# Usage +# +# bash$ rackup spec/fixtures/audit/config.ru +# [2016-07-12 10:44:29] INFO WEBrick 1.3.1 +# [2016-07-12 10:44:29] INFO ruby 2.3.0 (2015-12-25) [x86_64-darwin14] +# [2016-07-12 10:44:29] INFO WEBrick::HTTPServer#start: pid=92594 port=9292 +# +# +# To get successful response, request for +# - http://localhost:9292/spec/fixtures/audit/quotation.success.json +# - http://localhost:9292/spec/fixtures/audit/booking.success.json +# - http://localhost:9292/spec/fixtures/audit/cancel.success.json +# +# To get a connection timeout (sleeps Concierge::HTTPClient::CONNECTION_TIMEOUT + 1 second), +# replace `success` with `connection_timeout`, e.g. +# - http://localhost:9292/spec/fixtures/audit/quotation.connection_timeout.json +# +# To get an invalid json response, replace `success` with `invalid_json` or `wrong_json`, e.g. +# - http://localhost:9292/spec/fixtures/audit/quotation.wrong_json.json + +require 'rack' +require_relative '../../../lib/concierge/suppliers/audit/server.rb' + +use Audit::Server +use Rack::Static, :urls => ['/spec'] + +run -> (env) { + path = Dir['spec/fixtures/audit/*'].sample + [200, {'Content-Type' => 'text/html'}, ["Try #{path} instead"]] +} diff --git a/spec/fixtures/audit/fetch_properties.json b/spec/fixtures/audit/fetch_properties.json new file mode 100644 index 000000000..57c330279 --- /dev/null +++ b/spec/fixtures/audit/fetch_properties.json @@ -0,0 +1,103 @@ +{ + "result": [ + { + "identifier": "audit-property-1", + "title": "Audit Property #1", + "type": "house", + "subtype": "cabin", + "postal_code": "069935", + "country_code": "SG", + "city": "Singapore", + "neighborhood": "CBD", + "description": "Audit property description", + "number_of_bedrooms": 2, + "minimum_stay": 1, + "weekly_rate": 100, + "monthly_rate": 400, + "security_deposit_amount": 99, + "security_deposit_currency_code": "MYR", + "security_deposit_type": "credit_card_auth", + "pets_allowed": true, + "smoking_allowed": true, + "services_cleaning": true, + "services_cleaning_rate": 10, + "services_cleaning_required": true, + "services_airport_pickup": true, + "services_car_rental": true, + "services_car_rental_rate": 11.5, + "services_airport_pickup_rate": 12, + "services_concierge": true, + "services_concierge_rate": 13, + "disabled": false, + "instant_booking": true, + "check_in_instructions": "Lorem ipsum instructions", + "cancellation_policy": "standard", + "default_to_available": true, + "lat": 1.282097, + "lng": 103.848025, + "currency": "SGD", + "address": "115 Amoy Street", + "apartment_number": "#02-01", + "max_guests": 2, + "nightly_rate": 100, + "availability_dates": { + "2016-07-11": true, + "2016-07-12": true, + "2016-07-13": true + }, + "images": [ + { + "identifier": "audit-image1", + "url": "/spec/fixtures/audit/villa.jpg?img1", + "caption": "Barbecue Pit" + } + ], + "units": [ + { + "identifier": "audit-unit1", + "title": "Audit Unit 1", + "description": "Audit unit 1 description", + "nightly_rate": 20, + "weekly_rate": 100, + "monthly_rate": 400, + "number_of_bedrooms": 2, + "max_guests": 4, + "host_daily_price": 20, + "host_weekly_price": 100, + "host_monthly_price": 400, + "number_of_units": 2, + "images": [ + { + "identifier": "audit-unit1img1", + "url": "/spec/fixtures/audit/villa.jpg?unit1img1" + }, + { + "identifier": "audit-unit1img2", + "url": "/spec/fixtures/audit/😻猫.png?unit1img2" + } + ] + }, + { + "identifier": "audit-unit2", + "title": "Audit Unit 2", + "description": "Audit unit 2 description", + "nightly_rate": 20, + "weekly_rate": 100, + "monthly_rate": 400, + "number_of_bedrooms": 2, + "max_guests": 4, + "host_daily_price": 20, + "host_weekly_price": 100, + "host_monthly_price": 400, + "number_of_units": 2, + "images": [ + { + "identifier": "audit-unit2img1", + "url": "/spec/fixtures/audit/😻猫.png?unit2img1" + } + ] + } + ] + } + ] +} diff --git a/spec/fixtures/audit/property.json b/spec/fixtures/audit/property.json new file mode 100644 index 000000000..1a50d029f --- /dev/null +++ b/spec/fixtures/audit/property.json @@ -0,0 +1,99 @@ +{ + "identifier": "audit-property-1", + "title": "Audit Property #1", + "type": "house", + "subtype": "cabin", + "postal_code": "069935", + "country_code": "SG", + "city": "Singapore", + "neighborhood": "CBD", + "description": "Audit property description", + "number_of_bedrooms": 2, + "minimum_stay": 1, + "weekly_rate": 100, + "monthly_rate": 400, + "security_deposit_amount": 99, + "security_deposit_currency_code": "MYR", + "security_deposit_type": "credit_card_auth", + "pets_allowed": true, + "smoking_allowed": true, + "services_cleaning": true, + "services_cleaning_rate": 10, + "services_cleaning_required": true, + "services_airport_pickup": true, + "services_car_rental": true, + "services_car_rental_rate": 11.5, + "services_airport_pickup_rate": 12, + "services_concierge": true, + "services_concierge_rate": 13, + "disabled": false, + "instant_booking": true, + "check_in_instructions": "Lorem ipsum instructions", + "cancellation_policy": "standard", + "default_to_available": true, + "lat": 1.282097, + "lng": 103.848025, + "currency": "SGD", + "address": "115 Amoy Street", + "apartment_number": "#02-01", + "max_guests": 2, + "nightly_rate": 100, + "availability_dates": { + "2016-07-11": true, + "2016-07-12": true, + "2016-07-13": true + }, + "images": [ + { + "identifier": "audit-image1", + "url": "/spec/fixtures/audit/villa.jpg?img1", + "caption": "Barbecue Pit" + } + ], + "units": [ + { + "identifier": "audit-unit1", + "title": "Audit Unit 1", + "description": "Audit unit 1 description", + "nightly_rate": 20, + "weekly_rate": 100, + "monthly_rate": 400, + "number_of_bedrooms": 2, + "max_guests": 4, + "host_daily_price": 20, + "host_weekly_price": 100, + "host_monthly_price": 400, + "number_of_units": 2, + "images": [ + { + "identifier": "audit-unit1img1", + "url": "/spec/fixtures/audit/villa.jpg?unit1img1" + }, + { + "identifier": "audit-unit1img2", + "url": "/spec/fixtures/audit/😻猫.png?unit1img2" + } + ] + }, + { + "identifier": "audit-unit2", + "title": "Audit Unit 2", + "description": "Audit unit 2 description", + "nightly_rate": 20, + "weekly_rate": 100, + "monthly_rate": 400, + "number_of_bedrooms": 2, + "max_guests": 4, + "host_daily_price": 20, + "host_weekly_price": 100, + "host_monthly_price": 400, + "number_of_units": 2, + "images": [ + { + "identifier": "audit-unit2img1", + "url": "/spec/fixtures/audit/😻猫.png?unit2img1" + } + ] + } + ] +} diff --git a/spec/fixtures/audit/quotation.success.json b/spec/fixtures/audit/quotation.success.json new file mode 100644 index 000000000..a28df28a9 --- /dev/null +++ b/spec/fixtures/audit/quotation.success.json @@ -0,0 +1,12 @@ +{ + "result": { + "property_id": "String", + "unit_id": "String", + "check_in": "String", + "check_out": "String", + "guests": 2, + "available": true, + "total": 400.59, + "currency": "USD" + } +} diff --git a/spec/fixtures/audit/quotation.unavailable.json b/spec/fixtures/audit/quotation.unavailable.json new file mode 100644 index 000000000..36a6a4415 --- /dev/null +++ b/spec/fixtures/audit/quotation.unavailable.json @@ -0,0 +1,12 @@ +{ + "result": { + "property_id": "String", + "unit_id": "String", + "check_in": "String", + "check_out": "String", + "guests": 2, + "available": false, + "total": 400.59, + "currency": "USD" + } +} diff --git a/spec/fixtures/audit/villa.jpg b/spec/fixtures/audit/villa.jpg new file mode 100644 index 000000000..92b97c94e Binary files /dev/null and b/spec/fixtures/audit/villa.jpg differ diff --git "a/spec/fixtures/audit/\360\237\230\273\347\214\253.png" "b/spec/fixtures/audit/\360\237\230\273\347\214\253.png" new file mode 100644 index 000000000..70c0d249a Binary files /dev/null and "b/spec/fixtures/audit/\360\237\230\273\347\214\253.png" differ diff --git a/spec/lib/concierge/suppliers/audit/client_spec.rb b/spec/lib/concierge/suppliers/audit/client_spec.rb new file mode 100644 index 000000000..13c46caed --- /dev/null +++ b/spec/lib/concierge/suppliers/audit/client_spec.rb @@ -0,0 +1,157 @@ +require "spec_helper" +require_relative "../shared/book" +require_relative "../shared/quote" +require_relative "../shared/cancel" + +RSpec.describe Audit::Client do + include Support::HTTPStubbing + include Support::Fixtures + + let(:base_url) { Concierge::Credentials.for('Audit')['host'] } + subject { described_class.new } + + describe "#quote" do + let(:success_json) { JSON.parse(read_fixture('audit/quotation.success.json')) } + let(:unavailable_json) { JSON.parse(read_fixture('audit/quotation.unavailable.json')) } + + before do + stub_call(:get, "#{base_url}/spec/fixtures/audit/quotation.success.json") { + [200, {}, success_json.to_json] + } + stub_call(:get, "#{base_url}/spec/fixtures/audit/quotation.unavailable.json") { + [200, {}, unavailable_json.to_json] + } + stub_call(:get, "#{base_url}/spec/fixtures/audit/quotation.connection_timeout.json") { + raise Faraday::TimeoutError.new + } + end + + def quote_params_for(property_id) + { property_id: property_id, check_in: "2016-03-22", check_out: "2016-03-25", guests: 2 } + end + + it "returns the wrapped quotation from Audit::Price when successful" do + quote_result = subject.quote(quote_params_for("success")) + expect(quote_result).to be_success + + quote = quote_result.value + expect(quote).to be_a Quotation + expect(quote.total).to eq success_json['result']['total'] + end + + it "returns a Result with a generic error message on failure" do + quote_result = subject.quote(quote_params_for("connection_timeout")) + expect(quote_result).to_not be_success + expect(quote_result.error.code).to eq :connection_timeout + + quote = quote_result.value + expect(quote).to be_nil + end + + it_behaves_like "supplier quote method" do + let(:supplier_client) { described_class.new } + let(:success_params) { quote_params_for('success') } + let(:unavailable_params_list) {[ + quote_params_for('unavailable'), + ]} + let(:error_params_list) {[ + quote_params_for('connection_timeout'), + ]} + end + end + + describe "#book" do + let(:success_json) { JSON.parse(read_fixture('audit/booking.success.json')) } + + before do + stub_call(:get, "#{base_url}/spec/fixtures/audit/booking.success.json") { + [200, {}, success_json.to_json] + } + stub_call(:get, "#{base_url}/spec/fixtures/audit/booking.connection_timeout.json") { + raise Faraday::TimeoutError.new + } + end + + def book_params_for(property_id) + { + property_id: property_id, + check_in: "2016-03-22", + check_out: "2016-03-24", + guests: 2, + subtotal: 300, + customer: { + first_name: "Alex", + last_name: "Black", + country: "India", + city: "Mumbai", + address: "first street", + postal_code: "123123", + email: "test@example.com", + phone: "555-55-55", + } + } + end + + it "returns the wrapped reservation from Audit::Booking when successful" do + reservation_result = subject.book(book_params_for('success')) + expect(reservation_result).to be_success + + reservation = reservation_result.value + expect(reservation).to be_a Reservation + expect(reservation.reference_number).to eq success_json['result']['reference_number'] + end + + it "returns a Result with a generic error message on failure" do + reservation_result = subject.book(book_params_for('connection_timeout')) + expect(reservation_result).to_not be_success + expect(reservation_result.error.code).to eq :connection_timeout + end + + it_behaves_like "supplier book method" do + let(:supplier_client) { described_class.new } + let(:success_params) { book_params_for('success') } + let(:successful_reference_number) { success_json['result']['reference_number'] } + let(:error_params_list) {[ + book_params_for('connection_timeout'), + ]} + end + end + + describe "#cancel" do + let(:success_json) { JSON.parse(read_fixture('audit/cancel.success.json')) } + + before do + stub_call(:get, "#{base_url}/spec/fixtures/audit/cancel.success.json") { + [200, {}, success_json.to_json] + } + stub_call(:get, "#{base_url}/spec/fixtures/audit/cancel.connection_timeout.json") { + raise Faraday::TimeoutError.new + } + end + + def cancel_params_for(reference_number) + { + reference_number: reference_number + } + end + + it "returns the wrapped reference_number when successful" do + reservation_result = subject.cancel(cancel_params_for('success')) + expect(reservation_result).to be_success + + expect(reservation_result.value).to eq success_json['result'] + end + + it "returns a Result with a generic error message on failure" do + reservation_result = subject.cancel(cancel_params_for('connection_timeout')) + expect(reservation_result).to_not be_success + expect(reservation_result.error.code).to eq :connection_timeout + end + + it_behaves_like "supplier cancel method" do + let(:supplier_client) { described_class.new } + let(:success_params) { cancel_params_for('success') } + let(:error_params) { cancel_params_for('connection_timeout') } + end + end +end diff --git a/spec/lib/concierge/suppliers/audit/importer_spec.rb b/spec/lib/concierge/suppliers/audit/importer_spec.rb new file mode 100644 index 000000000..04acfaf88 --- /dev/null +++ b/spec/lib/concierge/suppliers/audit/importer_spec.rb @@ -0,0 +1,58 @@ +require 'spec_helper' + +RSpec.describe Audit::Importer do + include Support::Fixtures + include Support::HTTPStubbing + + let(:credentials) { Concierge::Credentials.for('audit') } + let(:importer) { described_class.new(credentials) } + let(:endpoint) { "#{credentials.host}#{credentials.fetch_properties_endpoint}" } + + describe '#fetch_properties' do + + subject { importer.fetch_properties } + + context 'success' do + before do + stub_call(:get, endpoint) { [200, {}, read_fixture('audit/fetch_properties.json')] } + end + + it 'should return Result of array of Hash' do + is_expected.to be_success + expect(subject.value).to be_kind_of(Array) + expect(subject.value.collect(&:class).uniq).to eq([Hash]) + end + end + + context 'error' do + before do + stub_call(:get, endpoint) { raise Faraday::Error.new("oops123") } + end + + it 'should return Result with errors' do + is_expected.not_to be_success + expect(subject.error.code).to eq :network_failure + end + end + end + + describe '#json_to_property' do + let(:json) { JSON.parse(read_fixture('audit/property.json')) } + + it 'should return Result of Roomorama::Property with calendar parsed' do + parsed_calendar_entries = [] + result = importer.json_to_property(json) do |calendar_entries| + calendar_entries.each_with_index do |entry, index| + yyyymmdd, bool = json['availability_dates'].to_a[index] + expect(entry.date).to eq(Date.parse(yyyymmdd)) + expect(entry.available).to eq(bool) + expect(entry.nightly_rate).to eq(json['nightly_rate']) + parsed_calendar_entries << entry + end + end + expect(result).to be_kind_of(Result) + expect(result.value).to be_kind_of(Roomorama::Property) + expect(parsed_calendar_entries.length).to eq(3) + end + end +end diff --git a/spec/lib/concierge/suppliers/audit/server_spec.rb b/spec/lib/concierge/suppliers/audit/server_spec.rb new file mode 100644 index 000000000..fe1eff74f --- /dev/null +++ b/spec/lib/concierge/suppliers/audit/server_spec.rb @@ -0,0 +1,86 @@ +require "spec_helper" + +RSpec.describe Audit::Server do + include Support::Fixtures + + let(:app) { ->(env) { [200, env, "app"] } } + let(:middleware) { described_class.new(Rack::Static.new(app, urls: ['/spec'])) } + + it "serves success files as-is" do + file = %w[ + spec/fixtures/audit/cancel.success.json + spec/fixtures/audit/quotation.success.json + ].sample + code, env, response = middleware.call Rack::MockRequest.env_for("http://admin.example.com/#{file}", {}) + expect(code).to eq(200) + expect(response_body_as_string(response)).to eq(IO.read file) + end + + it "serves sample requests as success" do + file = %w[ + spec/fixtures/audit/cancel.success.json + spec/fixtures/audit/quotation.success.json + ].sample + code, env, response = middleware.call Rack::MockRequest.env_for("http://admin.example.com/#{file.gsub("success", "sample")}", {}) + expect(code).to eq(200) + expect(response_body_as_string(response)).to eq(IO.read file) + end + + it "serves connection_timeout with correct content but after CONNECTION_TIMEOUT seconds delay" do + # we could do a start/end check.. but that'll make test slower + expect(middleware).to receive(:sleep).with(Concierge::HTTPClient::CONNECTION_TIMEOUT + 1).and_return(nil) + + file = %w[ + spec/fixtures/audit/cancel.success.json + spec/fixtures/audit/quotation.success.json + ].sample + code, env, response = middleware.call Rack::MockRequest.env_for("http://admin.example.com/#{file.gsub("success", "connection_timeout")}", {}) + expect(code).to eq(200) + expect(response_body_as_string(response)).to eq(IO.read file) + end + + it "serves successful booking with random `reference_number`" do + file = "spec/fixtures/audit/booking.success.json" + code, env, response = middleware.call Rack::MockRequest.env_for("http://admin.example.com/#{file}", {}) + expect(code).to eq(200) + file_json = JSON.parse(IO.read file) + resp_json = JSON.parse(response_body_as_string(response)) + expect(resp_json).not_to eq(file_json) + expect(Audit::Server::SCENARIOS).to include(resp_json['result']['reference_number']) + expect(result_without_key resp_json, 'reference_number').to eq(result_without_key file_json, 'reference_number') + end + + it "serves wrong_json with a wrong but valid json string" do + file = Dir["spec/fixtures/audit/*.success.json"].sample.gsub("success", "wrong_json") + code, env, response = middleware.call Rack::MockRequest.env_for("http://admin.example.com/#{file}", {}) + expect(code).to eq(200) + expect(response_body_as_string(response)).to eq("[1, 2, 3]") + end + + it "serves invalid_json with an invalid json string" do + file = Dir["spec/fixtures/audit/*.success.json"].sample.gsub("success", "invalid_json") + code, env, response = middleware.call Rack::MockRequest.env_for("http://admin.example.com/#{file}", {}) + expect(code).to eq(200) + expect(response_body_as_string(response)).to eq("{") + end + + it "serves unknown as 404" do + file = Dir["spec/fixtures/audit/*.success.json"].sample.gsub("success", "unknown") + code, env, response = middleware.call Rack::MockRequest.env_for("http://admin.example.com/#{file}", {}) + expect(code).to eq(404) + end + + def result_without_key(hash, key) + hash['result'].delete(key) + hash + end + + def response_body_as_string(response) + case response + when Rack::File + IO.read response.path + else + response.join("") + end + end +end diff --git a/spec/workers/suppliers/audit_spec.rb b/spec/workers/suppliers/audit_spec.rb new file mode 100644 index 000000000..02ea3ff5f --- /dev/null +++ b/spec/workers/suppliers/audit_spec.rb @@ -0,0 +1,101 @@ +require "spec_helper" + +RSpec.describe Workers::Suppliers::Audit do + include Support::Factories + include Support::Fixtures + + let(:host) { create_host } + let(:worker) { described_class.new(host) } + let(:fetch_properties_json) { JSON.parse(read_fixture('audit/fetch_properties.json')) } + let(:credentials) { worker.send(:credentials) } + + def keyvalue(counters) + [:created, :updated, :deleted, :available, :unavailable].inject({}) do |sum,k| + if counters.respond_to?(k) + sum.merge(k => counters.send(k)) + else + sum + end + end + end + + before do + # do NOT make API calls during tests + allow_any_instance_of(Workers::PropertySynchronisation).to receive(:run_operation).and_return(double(:result, :"success?" => true)) + allow_any_instance_of(Workers::CalendarSynchronisation).to receive(:run_operation).and_return(nil) + + # keep track of counters + @property_counters = Workers::PropertySynchronisation::PropertyCounters.new(0, 0, 0) + allow_any_instance_of(Workers::PropertySynchronisation).to receive(:save_sync_process) do |instance, *args| + @property_counters = instance.counters + end + + @calendar_counters = Workers::CalendarSynchronisation::AvailabilityCounters.new(0, 0) + allow_any_instance_of(Workers::CalendarSynchronisation).to receive(:finish!) do |instance, *args| + @calendar_counters = instance.counters + end + end + + describe "perform" do + + before do + allow_any_instance_of(Audit::Importer).to receive(:fetch_properties) do + Result.new(fetch_properties_json['result']) + end + end + + subject { proc { worker.perform } } + + context "fetched new property" do + it { is_expected.to change { keyvalue(@property_counters) }.to eq(created: 1, updated: 0, deleted: 0) } + it { is_expected.not_to change { keyvalue(@calendar_counters) } } + + context "property was synchronised by Concierge" do + before do + allow_any_instance_of(Workers::CalendarSynchronisation).to receive(:synchronised?).and_return(true) + end + + it { is_expected.to change { keyvalue(@calendar_counters) }.to eq(available: 3, unavailable: 0) } + end + end + + context "fetched existing property" do + before do + fetch_properties_json['result'].each do |json| + result = Audit::Importer.new(credentials).json_to_property(json) do |calendar_entries| + end + roomorama_property = result.value + # See Workers::Router#dispatch + # enqueues a diff operation if there is a property with the same identifier for the same host + data = roomorama_property.to_h.merge!(title: "Different title") + create_property(host_id: host.id, identifier: roomorama_property.identifier, data: data) + end + end + + it { is_expected.to change { keyvalue(@property_counters) }.to eq(created: 0, updated: 1, deleted: 0) } + it { is_expected.to change { keyvalue(@calendar_counters) }.to eq(available: 3, unavailable: 0) } + end + + context "error when importing json_to_property" do + before do + allow_any_instance_of(Audit::Importer).to receive(:json_to_property) do + Result.error(:missing_required_data, { 'foo' => 'bar'} ) + end + end + + it { is_expected.not_to change { keyvalue(@property_counters) } } + it { is_expected.not_to change { keyvalue(@calendar_counters) } } + end + + context "error when fetching" do + before do + allow_any_instance_of(Audit::Importer).to receive(:fetch_properties) do + Result.error(:network_failure) + end + end + + it { is_expected.not_to change { keyvalue(@property_counters) } } + it { is_expected.not_to change { keyvalue(@calendar_counters) } } + end + end +end