diff --git a/.env.example b/.env.example
index 6955cef56..d0740e08b 100644
--- a/.env.example
+++ b/.env.example
@@ -10,6 +10,7 @@ ROOMORAMA_SECRET_CIIRUS=xxx
ROOMORAMA_SECRET_SAW=xxx
ROOMORAMA_SECRET_RENTALS_UNITED=xxx
ROOMORAMA_SECRET_AVANTIO=xxx
+ROOMORAMA_SECRET_THH=xxx
ROLLBAR_ACCESS_TOKEN=not_used_in_development
CONCIERGE_WEB_APP_SECRET=12345
SERVE_STATIC_ASSETS=true
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9b93f843c..437ba6de6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,13 @@ This file summarises the most important changes that went live on each release
of Concierge. Please check the Wiki entry on the release process to understand
how this file is formatted and how the process works.
+## [1.1.0]
+### Fixed
+- Avantio: add House(Casa) property type
+
+### Added
+- THH integrations: quote, book, cancel and synchronisation
+
## [1.0.0]
### Fixed
- Avantio: add handling of `check_in_too_near` response code
diff --git a/apps/api/config/environment_variables.yml b/apps/api/config/environment_variables.yml
index c401017ac..34b5b5367 100644
--- a/apps/api/config/environment_variables.yml
+++ b/apps/api/config/environment_variables.yml
@@ -8,4 +8,5 @@
- ROOMORAMA_SECRET_SAW
- ROOMORAMA_SECRET_RENTALS_UNITED
- ROOMORAMA_SECRET_AVANTIO
+- ROOMORAMA_SECRET_THH
- ZENDESK_NOTIFY_URL
diff --git a/apps/api/config/initializers/validate_supplier_credentials.rb b/apps/api/config/initializers/validate_supplier_credentials.rb
index a75e97063..d382c26f5 100644
--- a/apps/api/config/initializers/validate_supplier_credentials.rb
+++ b/apps/api/config/initializers/validate_supplier_credentials.rb
@@ -11,6 +11,7 @@
saw: %w(username password url),
poplidays: %w(url client_key passphrase),
rentalsunited: %w(username password url),
- avantio: %w(username password)
+ avantio: %w(username password),
+ thh: %w(key url)
})
end
diff --git a/apps/api/config/routes.rb b/apps/api/config/routes.rb
index 13fbe1d54..eb2a8df42 100644
--- a/apps/api/config/routes.rb
+++ b/apps/api/config/routes.rb
@@ -8,6 +8,7 @@
post '/saw/quote', to: 's_a_w#quote'
post '/rentalsunited/quote', to: 'rentals_united#quote'
post '/avantio/quote', to: 'avantio#quote'
+post '/thh/quote', to: 't_h_h#quote'
post '/jtb/booking', to: 'j_t_b#booking'
post '/atleisure/booking', to: 'at_leisure#booking'
@@ -19,6 +20,7 @@
post '/poplidays/booking', to: 'poplidays#booking'
post '/rentalsunited/booking', to: 'rentals_united#booking'
post '/avantio/booking', to: 'avantio#booking'
+post '/thh/booking', to: 't_h_h#booking'
post '/waytostay/cancel', to: 'waytostay#cancel'
post '/ciirus/cancel', to: 'ciirus#cancel'
@@ -29,6 +31,7 @@
post '/atleisure/cancel', to: 'at_leisure#cancel'
post '/rentalsunited/cancel', to: 'rentals_united#cancel'
post '/avantio/cancel', to: 'avantio#cancel'
+post '/thh/cancel', to: 't_h_h#cancel'
post '/checkout', to: 'static#checkout'
get '/kigo/image/:property_id/:image_id', to: 'kigo#image'
diff --git a/apps/api/controllers/poplidays/quote.rb b/apps/api/controllers/poplidays/quote.rb
index bf0fed785..276a48e04 100644
--- a/apps/api/controllers/poplidays/quote.rb
+++ b/apps/api/controllers/poplidays/quote.rb
@@ -9,7 +9,7 @@ class Quote
include API::Controllers::Quote
def quote_price(params)
- credentials = Concierge::Credentials.for(Poplidays::Client::SUPPLIER_NAME)
+ credentials = Concierge::Credentials.for(supplier_name)
Poplidays::Client.new(credentials).quote(params)
end
diff --git a/apps/api/controllers/thh/booking.rb b/apps/api/controllers/thh/booking.rb
new file mode 100644
index 000000000..42991654a
--- /dev/null
+++ b/apps/api/controllers/thh/booking.rb
@@ -0,0 +1,20 @@
+require_relative "../booking"
+
+module API::Controllers::THH
+
+ # +API::Controllers::THH::Booking+
+ #
+ # Performs create booking for properties from THH.
+ class Booking
+ include API::Controllers::Booking
+
+ def create_booking(params)
+ credentials = Concierge::Credentials.for(supplier_name)
+ THH::Client.new(credentials).book(params)
+ end
+
+ def supplier_name
+ THH::Client::SUPPLIER_NAME
+ end
+ end
+end
\ No newline at end of file
diff --git a/apps/api/controllers/thh/cancel.rb b/apps/api/controllers/thh/cancel.rb
new file mode 100644
index 000000000..d723e89c9
--- /dev/null
+++ b/apps/api/controllers/thh/cancel.rb
@@ -0,0 +1,20 @@
+require_relative "../cancel"
+
+module API::Controllers::THH
+
+ # API::Controllers::THH::Cancel
+ #
+ # Cancels reservation from THH.
+ class Cancel
+ include API::Controllers::Cancel
+
+ def cancel_reservation(params)
+ credentials = Concierge::Credentials.for(supplier_name)
+ THH::Client.new(credentials).cancel(params)
+ end
+
+ def supplier_name
+ THH::Client::SUPPLIER_NAME
+ end
+ end
+end
diff --git a/apps/api/controllers/thh/quote.rb b/apps/api/controllers/thh/quote.rb
new file mode 100644
index 000000000..dd96cb71d
--- /dev/null
+++ b/apps/api/controllers/thh/quote.rb
@@ -0,0 +1,20 @@
+require_relative "../quote"
+
+module API::Controllers::THH
+
+ # API::Controllers::THH::Quote
+ #
+ # Performs booking quotations for properties from THH.
+ class Quote
+ include API::Controllers::Quote
+
+ def quote_price(params)
+ credentials = Concierge::Credentials.for(supplier_name)
+ THH::Client.new(credentials).quote(params)
+ end
+
+ def supplier_name
+ THH::Client::SUPPLIER_NAME
+ end
+ end
+end
diff --git a/apps/api/middlewares/authentication.rb b/apps/api/middlewares/authentication.rb
index 6f1f39360..2f164fc7e 100644
--- a/apps/api/middlewares/authentication.rb
+++ b/apps/api/middlewares/authentication.rb
@@ -55,7 +55,8 @@ class Secrets
"/ciirus" => ENV["ROOMORAMA_SECRET_CIIRUS"],
"/saw" => ENV["ROOMORAMA_SECRET_SAW"],
"/rentalsunited" => ENV["ROOMORAMA_SECRET_RENTALS_UNITED"],
- "/avantio" => ENV["ROOMORAMA_SECRET_AVANTIO"]
+ "/avantio" => ENV["ROOMORAMA_SECRET_AVANTIO"],
+ "/thh" => ENV["ROOMORAMA_SECRET_THH"]
}
attr_reader :mapping
diff --git a/apps/workers/suppliers/thh/availabilities.rb b/apps/workers/suppliers/thh/availabilities.rb
new file mode 100644
index 000000000..9f972bf43
--- /dev/null
+++ b/apps/workers/suppliers/thh/availabilities.rb
@@ -0,0 +1,75 @@
+module Workers::Suppliers::THH
+ # +Workers::Suppliers::THH::Availabilities+
+ #
+ # Performs properties availabilities synchronisation with supplier
+ class Availabilities
+ attr_reader :synchronisation, :host
+
+ def initialize(host)
+ @host = host
+ @synchronisation = Workers::CalendarSynchronisation.new(host)
+ end
+
+ def perform
+ identifiers = all_identifiers
+
+ identifiers.each do |property_id|
+ synchronisation.start(property_id) do
+ result = fetch_property(property_id)
+ next result unless result.success?
+ property = result.value
+
+ roomorama_calendar = mapper.build(property)
+ Result.new(roomorama_calendar)
+ end
+ end
+ synchronisation.finish!
+ end
+
+ private
+
+ def report_error(message)
+ yield.tap do |result|
+ augment_context_error(message) unless result.success?
+ end
+ end
+
+ def fetch_property(property_id)
+ report_error("Failed to fetch details for property `#{property_id}`") do
+ importer.fetch_property(property_id)
+ end
+ end
+
+ def all_identifiers
+ PropertyRepository.from_host(host).only(:identifier).map(&:identifier)
+ end
+
+ def mapper
+ @mapper ||= ::THH::Mappers::RoomoramaCalendar.new
+ end
+
+ def importer
+ @importer ||= ::THH::Importer.new(credentials)
+ end
+
+ def credentials
+ Concierge::Credentials.for(THH::Client::SUPPLIER_NAME)
+ end
+
+ def augment_context_error(message)
+ message = {
+ label: 'Synchronisation Failure',
+ message: message,
+ backtrace: caller
+ }
+ context = Concierge::Context::Message.new(message)
+ Concierge.context.augment(context)
+ end
+ end
+end
+
+# listen supplier worker
+Concierge::Announcer.on('availabilities.THH') do |host, args|
+ Workers::Suppliers::THH::Availabilities.new(host).perform
+ Result.new({})
+end
diff --git a/apps/workers/suppliers/thh/metadata.rb b/apps/workers/suppliers/thh/metadata.rb
new file mode 100644
index 000000000..22184da75
--- /dev/null
+++ b/apps/workers/suppliers/thh/metadata.rb
@@ -0,0 +1,110 @@
+module Workers::Suppliers::THH
+ # +Workers::Suppliers::THH::Metadata+
+ #
+ # Performs properties synchronisation with supplier
+ class Metadata
+ SKIPPABLE_ERROR_CODES = [:no_available_dates]
+
+ attr_reader :synchronisation, :host
+
+ def initialize(host)
+ @host = host
+ @synchronisation = Workers::PropertySynchronisation.new(host)
+ end
+
+ def perform
+ result = synchronisation.new_context { importer.fetch_properties }
+
+ if result.success?
+ properties = result.value
+ properties.each do |property|
+ property_id = property['property_id']
+ if validator(property).valid?
+ synchronisation.start(property_id) do
+ # Puts property info to context for analyze in case of error
+ augment_property_info(property)
+
+ result = mapper.build(property)
+ if !result.success? && SKIPPABLE_ERROR_CODES.include?(result.error.code)
+ synchronisation.skip_property(property_id, result.error.data)
+ else
+ result
+ end
+ end
+ else
+ synchronisation.skip_property(property_id, 'Invalid property')
+ end
+ end
+ else
+ synchronisation.failed!
+ message = 'Failed to perform the `#fetch_properties` operation'
+ announce_error(message, result)
+ end
+ synchronisation.finish!
+ end
+
+ private
+
+ def report_error(message)
+ yield.tap do |result|
+ augment_context_error(message) unless result.success?
+ end
+ end
+
+ def mapper
+ @mapper ||= ::THH::Mappers::RoomoramaProperty.new
+ end
+
+ def importer
+ @importer ||= ::THH::Importer.new(credentials)
+ end
+
+ def validator(property)
+ THH::Validators::PropertyValidator.new(property)
+ end
+
+ def credentials
+ Concierge::Credentials.for(THH::Client::SUPPLIER_NAME)
+ end
+
+ def augment_property_info(property)
+ message = {
+ label: 'Property Info',
+ message: property.to_h.to_json,
+ backtrace: caller,
+ content_type: 'json'
+ }
+ context = Concierge::Context::Message.new(message)
+ Concierge.context.augment(context)
+ end
+
+ def augment_context_error(message)
+ message = {
+ label: 'Synchronisation Failure',
+ message: message,
+ backtrace: caller
+ }
+ context = Concierge::Context::Message.new(message)
+ Concierge.context.augment(context)
+ end
+
+ def announce_error(message, result)
+ augment_context_error(message)
+
+ Concierge::Announcer.trigger(Concierge::Errors::EXTERNAL_ERROR, {
+ operation: 'sync',
+ supplier: THH::Client::SUPPLIER_NAME,
+ code: result.error.code,
+ description: result.error.data,
+ context: Concierge.context.to_h,
+ happened_at: Time.now
+ })
+ end
+ end
+end
+
+# listen supplier worker
+Concierge::Announcer.on('metadata.THH') do |host, args|
+ Workers::Suppliers::THH::Metadata.new(host).perform
+ Result.new({})
+end
diff --git a/config/credentials/production.yml b/config/credentials/production.yml
index 76c53b66b..61da73c6a 100644
--- a/config/credentials/production.yml
+++ b/config/credentials/production.yml
@@ -55,3 +55,7 @@ avantio:
password: <%= ENV["AVANTIO_PASSWORD"] %>
code_partner: <%= ENV["AVANTIO_CODE_PARTNER"] %>
test: false
+
+thh:
+ url: <%= ENV["THH_URL"] %>
+ key: <%= ENV["THH_KEY"] %>
\ No newline at end of file
diff --git a/config/credentials/staging.yml b/config/credentials/staging.yml
index 996641180..b976a5409 100644
--- a/config/credentials/staging.yml
+++ b/config/credentials/staging.yml
@@ -55,3 +55,7 @@ avantio:
password: <%= ENV["AVANTIO_PASSWORD"] %>
code_partner: <%= ENV["AVANTIO_CODE_PARTNER"] %>
test: true
+
+thh:
+ url: <%= ENV["THH_URL"] %>
+ key: <%= ENV["THH_KEY"] %>
diff --git a/config/credentials/test.yml b/config/credentials/test.yml
index e0b3458d8..cc4d0bb01 100644
--- a/config/credentials/test.yml
+++ b/config/credentials/test.yml
@@ -63,3 +63,7 @@ avantio:
password: "password"
code_partner: "123"
test: true
+
+thh:
+ url: "http://www.example.org"
+ key: "1234"
\ No newline at end of file
diff --git a/config/suppliers.yml b/config/suppliers.yml
index 36a58c261..025ca5204 100644
--- a/config/suppliers.yml
+++ b/config/suppliers.yml
@@ -71,3 +71,10 @@ Avantio:
availabilities:
every: "3h"
aggregated: true
+
+THH:
+ workers:
+ metadata:
+ every: "1d"
+ availabilities:
+ every: "5h"
\ No newline at end of file
diff --git a/lib/concierge/suppliers/avantio/mappers/roomorama_property.rb b/lib/concierge/suppliers/avantio/mappers/roomorama_property.rb
index 0d09e88b3..5d7aac2c0 100644
--- a/lib/concierge/suppliers/avantio/mappers/roomorama_property.rb
+++ b/lib/concierge/suppliers/avantio/mappers/roomorama_property.rb
@@ -14,6 +14,7 @@ class RoomoramaProperty
14 => {type: 'apartment', subtype: 'studio_bachelor'}, # Studio
20 => {type: 'house', subtype: 'cottage'}, # Chalet
9 => {type: 'house', subtype: 'cottage'}, # Cottage
+ 19 => {type: 'house', subtype: 'cottage'}, # House
21 => {type: 'house', subtype: 'bungalow'} # Bungalow
})
diff --git a/lib/concierge/suppliers/thh/booking.rb b/lib/concierge/suppliers/thh/booking.rb
new file mode 100644
index 000000000..c04bc808c
--- /dev/null
+++ b/lib/concierge/suppliers/thh/booking.rb
@@ -0,0 +1,47 @@
+module THH
+
+ # +THH::Booking+
+ #
+ # This class is responsible for wrapping the logic related to making a reservation
+ # to THH, parsing the response, and building the +Reservation+ object with the data
+ # returned from their API.
+ #
+ # Usage
+ #
+ # result = THH::Booking.new(credentials).book(reservation_params)
+ # if result.success?
+ # process_reservation(result.value)
+ # else
+ # handle_error(result.error)
+ # end
+ #
+ # The +book+ method returns a +Result+ object that, when successful, encapsulates the
+ # resulting +Reservation+ object.
+
+ class Booking
+ attr_reader :credentials
+
+ def initialize(credentials)
+ @credentials = credentials
+ end
+
+ # Makes booking. Used booking/easy endpoint in "BOOKED" mode.
+ def book(params)
+ fetcher = THH::Commands::Booking.new(credentials)
+ raw_booking = fetcher.call(params)
+
+ return raw_booking unless raw_booking.success?
+
+ reservation = build_reservation(params, raw_booking.value)
+ Result.new(reservation)
+ end
+
+ private
+
+ def build_reservation(params, response)
+ Reservation.new(params).tap do |r|
+ r.reference_number = response['booking_id']
+ end
+ end
+ end
+end
diff --git a/lib/concierge/suppliers/thh/calendar.rb b/lib/concierge/suppliers/thh/calendar.rb
new file mode 100644
index 000000000..6f551355f
--- /dev/null
+++ b/lib/concierge/suppliers/thh/calendar.rb
@@ -0,0 +1,134 @@
+module THH
+ # +THH::Calendar+
+ #
+ # Util class for work with THH rates and calendar.
+ # Allows to calculate min_stay, min_rate and get day by day
+ # rates and book info
+ class Calendar
+ attr_reader :rates, :booked_periods, :length
+
+ # Arguments:
+ # * raw_rates - +Array+ of +Concierge::SafeAccessHash+ getting from raw
+ # THH property response (parsed by Nori) by key 'response.rates.rate'
+ # * raw_booked_periods - +Array+ of +Concierge::SafeAccessHash+ getting from
+ # raw THH property response (parsed by Nori)
+ # by key 'response.calendar.periods.period'
+ # * length - count of days from today calendar works with
+ def initialize(raw_rates, raw_booked_periods, length)
+ @length = length
+
+ from = calendar_start
+ to = calendar_end
+
+ @booked_periods = actual_booked_periods(raw_booked_periods, from, to)
+ @rates = actual_rates(raw_rates, from, to)
+ end
+
+ def min_stay
+ available_days.values.map { |r| r[:min_nights] }.min
+ end
+
+ def min_rate
+ available_days.values.map { |r| r[:night] }.min
+ end
+
+ # Returns Hash where keys are days and values are Hash with rate and min stay info
+ def rates_days
+ @rates_days ||= {}.tap do |days|
+ rates.each do |r|
+ from = [calendar_start, r[:start_date]].max
+ to = [calendar_end, r[:end_date]].min
+ if from <= to
+ (from..to).each do |day|
+ # One date can have several rates with different min_nights,
+ # To fill calendar Concierge uses min minimum stay and max price.
+ cur_r = days[day]
+ if cur_r
+ days[day] = {
+ min_nights: [cur_r[:min_nights], r[:min_nights]].min,
+ night: [cur_r[:night], r[:night]].max
+ }
+ else
+ days[day] = {
+ min_nights: r[:min_nights],
+ night: r[:night]
+ }
+ end
+ end
+ end
+ end
+ end
+ end
+
+ # Returns Set of booked dates
+ def booked_days
+ @booked_days ||= Set.new.tap do |days|
+ booked_periods.each do |p|
+ from = [calendar_start, p[:date_from]].max
+ to = [calendar_end, p[:date_to]].min
+ if from <= to
+ days.merge(from..to)
+ end
+ end
+ end
+ end
+
+ def has_available_days?
+ ! (Set.new(rates_days.keys) - booked_days).empty?
+ end
+
+ private
+
+ def actual_rates(raw_rates, from, to)
+ raw_rates.map do |r|
+ {
+ start_date: Date.parse(r['start_date']),
+ end_date: Date.parse(r['end_date']),
+ night: rate_to_f(r['night']),
+ min_nights: r['min_nights'].to_i
+ }
+ end.select do |r|
+ r[:start_date] <= to && from <= r[:end_date]
+ end
+ end
+
+ def actual_booked_periods(raw_booked_periods, from, to)
+ # NOTE: End date of period is not booked
+ raw_booked_periods.map do |p|
+ {
+ date_from: Date.parse(p['@date_from']),
+ date_to: Date.parse(p['@date_to']) - 1
+ }
+ end.select do |p|
+ p[:date_from] <= to && from <= p[:date_to]
+ end
+ end
+
+ def calendar_start
+ Date.today
+ end
+
+ def calendar_end
+ calendar_start + length
+ end
+
+ def available_days
+ @available_days ||= begin
+ keys = Set.new(rates_days.keys) - booked_days
+ slice(rates_days, keys)
+ end
+ end
+
+ def slice(hash, keys)
+ {}.tap do |h|
+ keys.each do |k|
+ h[k] = hash[k]
+ end
+ end
+ end
+
+ def rate_to_f(rate)
+ rate.gsub(/[,\s]/, '').to_f
+ end
+ end
+end
\ No newline at end of file
diff --git a/lib/concierge/suppliers/thh/client.rb b/lib/concierge/suppliers/thh/client.rb
new file mode 100644
index 000000000..976477bac
--- /dev/null
+++ b/lib/concierge/suppliers/thh/client.rb
@@ -0,0 +1,29 @@
+module THH
+ # +THH::Client+
+ #
+ # This class is a convenience class for the smaller classes under +THH+.
+ # For now, it allows the caller to get price quotations.
+ #
+ # For more information on how to interact with THH, check the project Wiki.
+ class Client
+ SUPPLIER_NAME = 'THH'
+
+ attr_reader :credentials
+
+ def initialize(credentials)
+ @credentials = credentials
+ end
+
+ def quote(params)
+ THH::Price.new(credentials).quote(params)
+ end
+
+ def book(params)
+ THH::Booking.new(credentials).book(params)
+ end
+
+ def cancel(params)
+ THH::Commands::Cancel.new(credentials).call(params[:reference_number])
+ end
+ end
+end
diff --git a/lib/concierge/suppliers/thh/commands/base_fetcher.rb b/lib/concierge/suppliers/thh/commands/base_fetcher.rb
new file mode 100644
index 000000000..792ab5316
--- /dev/null
+++ b/lib/concierge/suppliers/thh/commands/base_fetcher.rb
@@ -0,0 +1,62 @@
+module THH
+ module Commands
+ # +THH::Commands::BaseCommand+
+ #
+ # Base class for all call THH API commands. Each child should
+ # implement:
+ # - action - name of API action
+ class BaseFetcher
+ TIMEOUT = 10
+
+ attr_reader :credentials
+
+ def initialize(credentials)
+ @credentials = credentials
+ end
+
+ def api_call(params)
+ result = http.get('', params.merge(additional_params), headers)
+ return result unless result.success?
+
+ to_safe_hash(result.value.body)
+ end
+
+ protected
+
+ def action
+ NotImplementedError
+ end
+
+ def timeout
+ TIMEOUT
+ end
+
+ private
+
+ def additional_params
+ {
+ 'action' => action,
+ 'key' => credentials.key
+ }
+ end
+
+ def http
+ @http_client ||= Concierge::HTTPClient.new(credentials.url, timeout: timeout)
+ end
+
+ def headers
+ { "Content-Type" => "application/xml" }
+ end
+
+ def to_safe_hash(str)
+ # Sometimes THH provides XML with syntax errors, to handle them
+ # run Nokogiri parser in soft mode, convert result to xml string
+ # and convert it to the hash
+ xml = Nokogiri::XML(str)
+ valid_xml = xml.to_s
+ parser = Nori.new(advanced_typecasting: false)
+ Result.new(Concierge::SafeAccessHash.new(parser.parse(valid_xml)))
+ end
+ end
+ end
+end
diff --git a/lib/concierge/suppliers/thh/commands/booking.rb b/lib/concierge/suppliers/thh/commands/booking.rb
new file mode 100644
index 000000000..1231b6f19
--- /dev/null
+++ b/lib/concierge/suppliers/thh/commands/booking.rb
@@ -0,0 +1,105 @@
+module THH
+ module Commands
+ # +THH::Commands::Booking+
+ #
+ # This class is responsible for wrapping the logic related to
+ # making a THH booking.
+ #
+ # Usage
+ #
+ # command = THH::Commands::Booking.new(credentials)
+ # result = command.call(params)
+ #
+ # if result.success?
+ # result.value['booking_id'] # reservation id
+ # end
+ # The +call+ method returns a +Result+ object that, when successful,
+ # encapsulates the +Hash+.
+ class Booking < BaseFetcher
+ ROOMORAMA_DATE_FORMAT = '%Y-%m-%d'
+ THH_DATE_FORMAT = '%d/%m/%Y'
+ VILLA_STATUS = 'response.villa_status'
+ BOOKING_STATUS = 'response.booking_status'
+ BOOKING_ID = 'response.booking_id'
+
+
+ def call(params)
+ result = api_call(params(params))
+ return result unless result.success?
+
+ response = Concierge::SafeAccessHash.new(result.value)
+ result = validate_response(response, params)
+ return result unless result.success?
+
+ Result.new(response['response'])
+ end
+
+ protected
+
+ def action
+ 'book'
+ end
+
+ private
+
+ def validate_response(response, params)
+ villa_status = response.get(VILLA_STATUS)
+ unless villa_status
+ return Result.error(
+ :unrecognised_response,
+ "Booking response for params `#{params.to_h}` does not contain `#{VILLA_STATUS}` field")
+ end
+ # Possible values for villa status: on_request, instant, not_available
+ if villa_status != 'instant'
+ return Result.error(
+ :unrecognised_response,
+ "Booking response for params `#{params.to_h}` contains unexpected value for `#{VILLA_STATUS}` field: `#{villa_status}`")
+ end
+
+ booking_status = response.get(BOOKING_STATUS)
+ if booking_status.nil?
+ return Result.error(
+ :unrecognised_response,
+ "Booking response for params `#{params.to_h}` does not contain `#{BOOKING_STATUS}` field")
+ end
+ # Possible values for booking status: success, false
+ if booking_status != 'success'
+ return Result.error(
+ :unrecognised_response,
+ "Booking response for params `#{params.to_h}` contains unexpected value for `#{BOOKING_STATUS}` field: `#{booking_status}`")
+ end
+
+ booking_id = response.get(BOOKING_ID)
+ unless booking_id
+ return Result.error(
+ :unrecognised_response,
+ "Booking response for params `#{params.to_h}` does not contain `#{BOOKING_ID}` field")
+ end
+
+ Result.new(true)
+ end
+
+ def params(params)
+ {
+ 'id' => params[:property_id],
+ 'arrival' => convert_date(params[:check_in]),
+ 'departure' => convert_date(params[:check_out]),
+ 'curr' => THH::Commands::PropertiesFetcher::CURRENCY,
+ 'firstname' => params[:customer][:first_name],
+ 'lastname' => params[:customer][:last_name],
+ 'phone' => params[:customer][:phone] || '',
+ 'mail' => params[:customer][:email],
+ 'country' => params[:customer][:country] || '',
+ 'adults' => params[:guests],
+ 'children' => '',
+ 'infants' => ''
+ }
+ end
+
+ # Converts date string to THH expected format
+ def convert_date(date)
+ Date.strptime(date, ROOMORAMA_DATE_FORMAT).strftime(THH_DATE_FORMAT)
+ end
+ end
+ end
+end
diff --git a/lib/concierge/suppliers/thh/commands/cancel.rb b/lib/concierge/suppliers/thh/commands/cancel.rb
new file mode 100644
index 000000000..86c69579b
--- /dev/null
+++ b/lib/concierge/suppliers/thh/commands/cancel.rb
@@ -0,0 +1,57 @@
+module THH
+ module Commands
+ # +THH::Commands::Cancel+
+ #
+ # This class is responsible for wrapping the logic related to cancel
+ # a THH booking, parsing the response.
+ #
+ # Usage
+ #
+ # command = THH::Commands::Cancel.new(credentials)
+ # result = command.call(booking_id)
+ #
+ # if result.success?
+ # result.value # booking_id
+ # end
+ #
+ # The +call+ method returns a +Result+ object that, when successful,
+ # encapsulates booking_id.
+ class Cancel < BaseFetcher
+ STATUS_FIELD = 'response.status'
+
+ def call(booking_id)
+ result = api_call(params(booking_id))
+ return result unless result.success?
+
+ response = Concierge::SafeAccessHash.new(result.value)
+ result = validate_response(response, booking_id)
+ return result unless result.success?
+
+ Result.new(booking_id)
+ end
+
+ protected
+
+ def action
+ 'book_cancel'
+ end
+
+ private
+
+ def validate_response(response, booking_id)
+ status = response.get(STATUS_FIELD)
+ unless status
+ return Result.error(:unrecognised_response, "Cancel booking `#{booking_id}` response does not contain `#{STATUS_FIELD}` field")
+ end
+ unless status == 'ok'
+ return Result.error(:unrecognised_response, "Cancel booking `#{booking_id}` response contains unexpected value for `#{STATUS_FIELD}` field: `#{status}`")
+ end
+ Result.new(true)
+ end
+
+ def params(booking_id)
+ { 'booking_id' => booking_id }
+ end
+ end
+ end
+end
diff --git a/lib/concierge/suppliers/thh/commands/properties_fetcher.rb b/lib/concierge/suppliers/thh/commands/properties_fetcher.rb
new file mode 100644
index 000000000..b8aab288f
--- /dev/null
+++ b/lib/concierge/suppliers/thh/commands/properties_fetcher.rb
@@ -0,0 +1,74 @@
+module THH
+ module Commands
+ # +THH::Commands::PropertiesFetcher+
+ #
+ # This class is responsible for fetching a list of all properties
+ # from THH API and parsing the response to Array of +Concierge::SafeAccessHash+.
+ #
+ # Usage
+ #
+ # result = THH::Commands::PropertiesFetcher.new(credentials).call
+ # if result.success?
+ # result.value # Array
+ # end
+ #
+ # The +call+ method returns a +Result+ object that, when successful,
+ # encapsulates +Array+ of +Concierge::SafeAccessHash+.
+ class PropertiesFetcher < BaseFetcher
+ # Currency response contains prices in.
+ # Currently THH has a bug with security deposit amount,
+ # it is always in THB. If you want to change this value
+ # check the behavior of security deposit
+ CURRENCY = 'THB'
+ LANGUAGE = 'en'
+ TIMEOUT = 60
+ PROPERTIES_KEY = 'response.property'
+
+ def call
+ result = api_call(params)
+ return result unless result.success?
+
+ response = result.value
+
+ return unrecognised_response_error('response') unless response.to_h.key?('response')
+
+ properties = response.get(PROPERTIES_KEY)
+
+ Result.new(to_array(properties))
+ end
+
+ protected
+
+ def action
+ 'data_all'
+ end
+
+ def timeout
+ TIMEOUT
+ end
+
+ private
+
+ def to_array(properties)
+ Array(properties).map do |p|
+ Concierge::SafeAccessHash.new(p)
+ end
+ end
+
+ def unrecognised_response_error(field)
+ Result.error(:unrecognised_response, "Response does not contain `#{field}` field")
+ end
+
+ def params
+ {
+ 'rate' => 'd', # rate per day
+ 'date' => '3', # YYYY-MM-DD format
+ 'booked' => '2', # booked dates as periods
+ 'text' => '2', # Clean Text without HTML
+ 'curr' => CURRENCY,
+ 'lang' => LANGUAGE
+ }
+ end
+ end
+ end
+end
diff --git a/lib/concierge/suppliers/thh/commands/property_fetcher.rb b/lib/concierge/suppliers/thh/commands/property_fetcher.rb
new file mode 100644
index 000000000..ce662e476
--- /dev/null
+++ b/lib/concierge/suppliers/thh/commands/property_fetcher.rb
@@ -0,0 +1,57 @@
+module THH
+ module Commands
+ # +THH::Commands::PropertyFetcher+
+ #
+ # This class is responsible for fetching a property details
+ # from THH API and parsing the response to +Concierge::SafeAccessHash+.
+ #
+ # Usage
+ #
+ # result = THH::Commands::PropertyFetcher.new(credentials).call(property_id)
+ # if result.success?
+ # result.value # SafeAccessHash with details
+ # end
+ #
+ # The +call+ method returns a +Result+ object that, when successful,
+ # encapsulates SafeAccessHash.
+ class PropertyFetcher < BaseFetcher
+ LANGUAGE = 'en'
+ PROPERTY_KEY = 'response.property'
+
+ def call(property_id)
+ result = api_call(params(property_id))
+ return result unless result.success?
+
+ response = result.value
+ property = response.get(PROPERTY_KEY)
+ return unrecognised_response_error(PROPERTY_KEY, property_id) unless property
+
+ Result.new(Concierge::SafeAccessHash.new(property))
+ end
+
+ protected
+
+ def action
+ 'data'
+ end
+
+ private
+
+ def unrecognised_response_error(field, property_id)
+ Result.error(:unrecognised_response, "Property response for id `#{property_id}` does not contain `#{field}` field")
+ end
+
+ def params(property_id)
+ {
+ 'rate' => 'd', # rate per day
+ 'date' => '3', # YYYY-MM-DD format
+ 'booked' => '2', # booked dates as periods
+ 'text' => '2', # Clean Text without HTML
+ 'curr' => THH::Commands::PropertiesFetcher::CURRENCY,
+ 'lang' => LANGUAGE,
+ 'id' => property_id
+ }
+ end
+ end
+ end
+end
diff --git a/lib/concierge/suppliers/thh/commands/quote_fetcher.rb b/lib/concierge/suppliers/thh/commands/quote_fetcher.rb
new file mode 100644
index 000000000..8faf1fea0
--- /dev/null
+++ b/lib/concierge/suppliers/thh/commands/quote_fetcher.rb
@@ -0,0 +1,66 @@
+module THH
+ module Commands
+ # +THH::Commands::PropertyFetcher+
+ #
+ # This class is responsible for fetching availability information
+ # (including price) from THH API and parsing the response
+ # to +Concierge::SafeAccessHash+.
+ #
+ # Usage
+ #
+ # result = THH::Commands::QuoteFetcher.new(credentials).call(params)
+ # if result.success?
+ # result.value # SafeAccessHash with availability info
+ # end
+ #
+ # The +call+ method returns a +Result+ object that, when successful,
+ # encapsulates +Concierge::SafeAccessHash+.
+ class QuoteFetcher < BaseFetcher
+ ROOMORAMA_DATE_FORMAT = '%Y-%m-%d'
+ THH_DATE_FORMAT = '%d/%m/%Y'
+ REQUIRED_FIELDS = ['response.available', 'response.price']
+
+ def call(params)
+ result = api_call(params(params))
+ return result unless result.success?
+
+ response = Concierge::SafeAccessHash.new(result.value)
+ result = validate_response(response, params)
+ return result unless result.success?
+
+ Result.new(response['response'])
+ end
+
+ protected
+
+ def action
+ 'availability'
+ end
+
+ private
+
+ def validate_response(response, params)
+ REQUIRED_FIELDS.each do |field|
+ unless response.get(field)
+ return Result.error(:unrecognised_response, "Available response for params `#{params.to_h}` does not contain `#{field}` field")
+ end
+ end
+ Result.new(true)
+ end
+
+ def params(params)
+ {
+ 'arrival' => convert_date(params[:check_in]),
+ 'departure' => convert_date(params[:check_out]),
+ 'curr' => THH::Commands::PropertiesFetcher::CURRENCY,
+ 'id' => params[:property_id]
+ }
+ end
+
+ # Converts date string to THH expected format
+ def convert_date(date)
+ Date.strptime(date, ROOMORAMA_DATE_FORMAT).strftime(THH_DATE_FORMAT)
+ end
+ end
+ end
+end
diff --git a/lib/concierge/suppliers/thh/country_code_converter.rb b/lib/concierge/suppliers/thh/country_code_converter.rb
new file mode 100644
index 000000000..23e4a0f6c
--- /dev/null
+++ b/lib/concierge/suppliers/thh/country_code_converter.rb
@@ -0,0 +1,33 @@
+module THH
+ # +THH::CountryCodeConverter+
+ #
+ # This class acts as a facade between country codes converter library and
+ # our source code for abstracting library's entities and using only needed
+ # part of the library API.
+ class CountryCodeConverter
+
+ # Finds country code by its name.
+ #
+ # Arguments
+ # * +name+ [String] name of the country
+ #
+ # Example
+ #
+ # converter = CountryCodeConverter.new
+ # country_code = converter.code_by_name("Korea")
+ # country_code.value # => "KR"
+ #
+ # Returns +Result+ wrapping country code
+ def code_by_name(name)
+ name = name.to_s.strip
+
+ return if name.empty?
+
+ country = IsoCountryCodes.search_by_name(name) do
+ return
+ end.first
+
+ country.alpha2
+ end
+ end
+end
\ No newline at end of file
diff --git a/lib/concierge/suppliers/thh/importer.rb b/lib/concierge/suppliers/thh/importer.rb
new file mode 100644
index 000000000..e6e6933dd
--- /dev/null
+++ b/lib/concierge/suppliers/thh/importer.rb
@@ -0,0 +1,27 @@
+module THH
+ # +THH::Importer+
+ #
+ # This class wraps supplier API and provides data for building properties.
+ class Importer
+
+ attr_reader :credentials
+
+ def initialize(credentials)
+ @credentials = credentials
+ end
+
+ # Fetches all properties from THH API
+ # Returns the +Result+ wrapping the +Array+ of +Concierge::SafeAccessHash+.
+ def fetch_properties
+ fetcher = Commands::PropertiesFetcher.new(credentials)
+ fetcher.call
+ end
+
+ # Fetches property details from THH API
+ # Returns the +Result+ wrapping the +Concierge::SafeAccessHash+.
+ def fetch_property(property_id)
+ fetcher = Commands::PropertyFetcher.new(credentials)
+ fetcher.call(property_id)
+ end
+ end
+end
\ No newline at end of file
diff --git a/lib/concierge/suppliers/thh/mappers/roomorama_calendar.rb b/lib/concierge/suppliers/thh/mappers/roomorama_calendar.rb
new file mode 100644
index 000000000..ee8807588
--- /dev/null
+++ b/lib/concierge/suppliers/thh/mappers/roomorama_calendar.rb
@@ -0,0 +1,52 @@
+module THH
+ module Mappers
+ # +THH::Mappers::RoomoramaCalendar+
+ #
+ # This class is responsible for building a +Roomorama::Calendar+ object
+ # from data getting from THH API.
+ class RoomoramaCalendar
+ # Arguments
+ #
+ # * +property+ [SafeAccessHash] raw property fetched from THH API
+ def build(property)
+ Roomorama::Calendar.new(property['property_id']).tap do |result|
+ entries = build_entries(property)
+ entries.each { |entry| result.add(entry) }
+ end
+ end
+
+ def calendar_start
+ Date.today
+ end
+
+ def calendar_end
+ calendar_start + THH::Mappers::RoomoramaProperty::SYNC_PERIOD
+ end
+
+ private
+
+ def build_entries(property)
+ rates = Array(property.get('rates.rate'))
+ booked_periods = Array(property.get('calendar.periods.period'))
+
+ calendar = THH::Calendar.new(rates, booked_periods, THH::Mappers::RoomoramaProperty::SYNC_PERIOD)
+
+ rates_days = calendar.rates_days
+ booked_days = calendar.booked_days
+
+ (calendar_start..calendar_end).map do |date|
+ rate = rates_days[date]
+ available = rate && !booked_days.include?(date)
+ nightly_rate = rate ? rate[:night] : 0
+
+ Roomorama::Calendar::Entry.new(
+ date: date,
+ available: available,
+ nightly_rate: nightly_rate,
+ minimum_stay: (rate[:min_nights] if rate)
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/lib/concierge/suppliers/thh/mappers/roomorama_property.rb b/lib/concierge/suppliers/thh/mappers/roomorama_property.rb
new file mode 100644
index 000000000..0382deb37
--- /dev/null
+++ b/lib/concierge/suppliers/thh/mappers/roomorama_property.rb
@@ -0,0 +1,177 @@
+module THH
+ module Mappers
+ # +THH::Mappers::RoomoramaProperty+
+ #
+ # This class is responsible for building a +Roomorama::Property+ object
+ # from data getting from THH API.
+ class RoomoramaProperty
+ SYNC_PERIOD = 365
+ SECURITY_DEPOSIT_TYPE = 'cash'
+ SECURITY_DEPOSIT_CURRENCY = 'THB'
+ CANCELLATION_POLICY = Roomorama::CancellationPolicy::STRICT
+
+ # Maps THH type to Roomorama property type/subtype.
+ PROPERTY_TYPES = Concierge::SafeAccessHash.new({
+ 'Villa' => {type: 'house', subtype: 'villa'},
+ 'Apartment' => {type: 'apartment'},
+ })
+
+ # Maps THH API raw property to +Roomorama::Property+
+ # Arguments
+ #
+ # * +raw_property+ [Hash] property from +data_all+ response
+ #
+ # Returns +Roomorama::Property+
+ def build(raw_property)
+ property = Roomorama::Property.new(raw_property['property_id'])
+ property.instant_booking!
+
+ set_base_info!(property, raw_property)
+ set_images!(property, raw_property)
+ set_amenities!(property, raw_property)
+
+ result = set_rates_and_min_stay!(property, raw_property)
+ return result unless result.success?
+
+ set_security_deposit!(property, raw_property)
+
+ Result.new(property)
+ end
+
+ private
+
+ def set_base_info!(property, raw_property)
+ property.default_to_available = false
+ property.title = raw_property['property_name']
+ property.type = PROPERTY_TYPES.get("#{raw_property['type']}.type")
+ property.subtype = PROPERTY_TYPES.get("#{raw_property['type']}.subtype")
+ property.country_code = country_converter.code_by_name(raw_property['country'])
+ property.city = raw_property['city']
+ property.description = build_description(raw_property)
+ property.number_of_bedrooms = raw_property['bedrooms']
+ property.max_guests = raw_property['pax']
+ property.lat = raw_property.get('geodata.lat')
+ property.lng = raw_property.get('geodata.lng')
+ property.number_of_bathrooms = raw_property.get('bathrooms')
+ property.number_of_double_beds = raw_property.get('beds.double_beds')
+ property.number_of_single_beds = raw_property.get('beds.single_beds')
+ property.number_of_sofa_beds = raw_property.get('beds.sofa_beds')
+ property.cancellation_policy = CANCELLATION_POLICY
+ end
+
+ def set_amenities!(property, raw_property)
+ attributes = raw_property['attributes']
+
+ amenities = []
+
+ if attributes
+ amenities << 'kitchen' if has_kitchen?(attributes)
+ amenities << 'wifi' if attributes.get('amenities.wifi')
+ amenities << 'cabletv' if attributes.get('equipment.living_room.cable_tv')
+ amenities << 'parking' if attributes.get('outside.parking')
+ amenities << 'airconditioning' if attributes.get('amenities.air_conditioning')
+ amenities << 'laundry' if has_laundry?(attributes)
+ amenities << 'pool' if has_pool?(attributes)
+ amenities << 'balcony' if attributes.get('outside.balcony')
+ amenities << 'outdoor_space' if has_outdoor_space?(attributes)
+ amenities << 'gym' if attributes.get('amenities.gym')
+ amenities << 'bed_linen_and_towels' if has_linen_and_towels?(attributes)
+ end
+
+ property.amenities = amenities
+ end
+
+ def has_linen_and_towels?(attributes)
+ attributes.get('amenities.linen_provided') &&
+ attributes.get('amenities.towels_provided')
+ end
+
+ def has_outdoor_space?(attributes)
+ attributes.get('outside.bbq') ||
+ attributes.get('outside.private_garden') ||
+ attributes.get('outside.private_lake')
+ end
+
+ def has_laundry?(attributes)
+ attributes.get('equipment.other.washing_machine') ||
+ attributes.get('equipment.other.clothes_dryer')
+ end
+
+ def has_kitchen?(attributes)
+ kitchen = attributes.get('equipment.kitchen')
+ # Possible values in kitchen: cooker, fridge, freezer, microwave,
+ # toaster, oven, hob, kettle, dishwasher
+ kitchen && kitchen.to_h.length > 1
+ end
+
+ def has_pool?(attributes)
+ attributes.get('pool.communal_pool') ||
+ attributes.get('pool.private_pool')
+ end
+
+ def set_rates_and_min_stay!(property, raw_property)
+ rates = Array(raw_property.get('rates.rate'))
+ booked_periods = Array(raw_property.get('calendar.periods.period'))
+
+ calendar = THH::Calendar.new(rates, booked_periods, SYNC_PERIOD)
+ return Result.error(:no_available_dates, 'All available days of the property are booked') unless calendar.has_available_days?
+
+ property.minimum_stay = calendar.min_stay
+ property.nightly_rate = calendar.min_rate
+ if property.nightly_rate
+ property.weekly_rate = property.nightly_rate * 7
+ property.monthly_rate = property.nightly_rate * 30
+ property.currency = THH::Commands::PropertiesFetcher::CURRENCY
+ end
+
+ Result.new(true)
+ end
+
+ def set_images!(property, raw_property)
+ urls = Array(raw_property.get('pictures.picture'))
+
+ main_picture = raw_property.get('pictures.picture_main')
+ urls << main_picture if main_picture
+
+ urls.each do |url|
+ identifier = Digest::MD5.hexdigest(url)
+ image = Roomorama::Image.new(identifier)
+ image.url = url
+ property.add_image(image)
+ end
+ end
+
+ def build_description(raw_property)
+ [
+ raw_property.get('descriptions.description_short.brief'),
+ raw_property.get('descriptions.description_full.text'),
+ raw_property.get('descriptions.rooms.living_area'),
+ raw_property.get('descriptions.rooms.kitchen'),
+ raw_property.get('descriptions.rooms.dining_room'),
+ raw_property.get('descriptions.rooms.bedrooms'),
+ raw_property.get('descriptions.rooms.bathrooms'),
+ raw_property.get('descriptions.rooms.utility_room'),
+ raw_property.get('descriptions.rooms.other'),
+ raw_property.get('descriptions.rooms.cleaning'),
+ ].compact.join("\n\n")
+ end
+
+ def set_security_deposit!(property, raw_property)
+ value = raw_property.get('additional_information.deposit')
+ if value
+ property.security_deposit_amount = rate_to_f(value)
+ property.security_deposit_type = SECURITY_DEPOSIT_TYPE
+ property.security_deposit_currency_code = SECURITY_DEPOSIT_CURRENCY
+ end
+ end
+
+ def rate_to_f(rate)
+ rate.gsub(/[,\s]/, '').to_f
+ end
+
+ def country_converter
+ THH::CountryCodeConverter.new
+ end
+ end
+ end
+end
\ No newline at end of file
diff --git a/lib/concierge/suppliers/thh/price.rb b/lib/concierge/suppliers/thh/price.rb
new file mode 100644
index 000000000..fdd5623ee
--- /dev/null
+++ b/lib/concierge/suppliers/thh/price.rb
@@ -0,0 +1,80 @@
+module THH
+
+ # +THH::Price+
+ #
+ # This class is responsible for performing price quotations for properties coming
+ # from THH, parsing the response and building the +Quotation+ object according
+ # with the data returned by their API. THH available method doesn't have guests param,
+ # Concierge checks it using property data from DB.
+ #
+ # Usage
+ #
+ # result = THH::Price.new.quote(stay_params)
+ # if result.success?
+ # process_quotation(result.value)
+ # else
+ # handle_error(result.error)
+ # end
+ #
+ # The +quote+ method returns a +Result+ object that, when successful, encapsulates
+ # the resulting +Quotation+ object. Possible errors at this stage are:
+ #
+ # * +max_guests_exceeded+
+ class Price
+ include Concierge::Errors::Quote
+
+ attr_reader :credentials
+
+ def initialize(credentials)
+ @credentials = credentials
+ end
+
+ def quote(params)
+ max_guests = max_guests(params[:property_id])
+
+ return max_guests_exceeded(max_guests) if params[:guests] > max_guests
+
+ quote = retrieve_quote(params)
+ return quote unless quote.success?
+
+ Result.new(build_quotation(params, quote.value))
+ end
+
+ private
+
+ def build_quotation(params, quote)
+ quotation = Quotation.new(params)
+ quotation.available = (quote['available'] == 'yes')
+
+ if quotation.available
+ quotation.total = rate_to_f(quote['price'])
+ quotation.currency = THH::Commands::PropertiesFetcher::CURRENCY
+ end
+
+ quotation
+ end
+
+ def rate_to_f(rate)
+ rate.gsub(/[,\s]/, '').to_f
+ end
+
+ def max_guests(property_id)
+ property = find_property(property_id)
+ property&.data&.get('max_guests').to_i
+ end
+
+ def find_property(identifier)
+ PropertyRepository.identified_by(identifier).
+ from_supplier(supplier).first
+ end
+
+ def supplier
+ @supplier ||= SupplierRepository.named THH::Client::SUPPLIER_NAME
+ end
+
+ def retrieve_quote(params)
+ fetcher = THH::Commands::QuoteFetcher.new(credentials)
+ fetcher.call(params)
+ end
+ end
+end
diff --git a/lib/concierge/suppliers/thh/validators/property_validator.rb b/lib/concierge/suppliers/thh/validators/property_validator.rb
new file mode 100644
index 000000000..e85dd3dc1
--- /dev/null
+++ b/lib/concierge/suppliers/thh/validators/property_validator.rb
@@ -0,0 +1,29 @@
+module THH
+ module Validators
+ # +THH::Validators::PropertyValidator+
+ #
+ # This class responsible for properties validation.
+ # cases when property invalid:
+ #
+ # * no instant confirmation
+ #
+ class PropertyValidator
+
+ attr_reader :property
+
+ def initialize(property)
+ @property = property
+ end
+
+ def valid?
+ instant_confirmation?
+ end
+
+ private
+
+ def instant_confirmation?
+ property['instant_confirmation'] == 'true'
+ end
+ end
+ end
+end
diff --git a/lib/concierge/version.rb b/lib/concierge/version.rb
index ba286c1a6..35e8089c5 100644
--- a/lib/concierge/version.rb
+++ b/lib/concierge/version.rb
@@ -1,3 +1,3 @@
module Concierge
- VERSION = "1.0.0"
+ VERSION = "1.1.0"
end
diff --git a/spec/api/controllers/thh/booking_spec.rb b/spec/api/controllers/thh/booking_spec.rb
new file mode 100644
index 000000000..a33c04699
--- /dev/null
+++ b/spec/api/controllers/thh/booking_spec.rb
@@ -0,0 +1,61 @@
+require "spec_helper"
+require_relative "../shared/booking_validations"
+
+RSpec.describe API::Controllers::THH::Booking do
+ include Support::Fixtures
+ include Support::HTTPStubbing
+
+ let(:customer) do
+ {
+ first_name: 'John',
+ last_name: 'Buttler',
+ address: 'Long Island 100',
+ email: 'my@email.com',
+ phone: '+3 675 45879',
+ }
+ end
+
+ let(:params) do
+ {
+ property_id: '15',
+ check_in: '2016-12-09',
+ check_out: '2016-12-17',
+ guests: 3,
+ subtotal: 2000,
+ customer: customer
+ }
+ end
+ let(:credentials) { double(key: 'Foo', url: url) }
+
+ it_behaves_like "performing booking parameters validations", controller_generator: -> { described_class.new }
+
+ describe '#call' do
+
+ it 'returns proper error if external request failed' do
+ allow_any_instance_of(THH::Booking).to receive(:book) { Result.error(:some_error, 'Some error') }
+
+ response = parse_response(described_class.new.call(params))
+
+ expect(response.status).to eq 503
+ expect(response.body['status']).to eq 'error'
+ expect(response.body['errors']['booking']).to eq 'Some error'
+ end
+
+ it 'fills reservation with right attributes when response is correct' do
+ reservation = Reservation.new(params)
+ reservation.reference_number = 'test_code'
+ allow_any_instance_of(THH::Booking).to receive(:book) { Result.new(reservation) }
+
+ response = parse_response(described_class.new.call(params))
+
+ expect(response.status).to eq 200
+ expect(response.body['status']).to eq 'ok'
+ expect(response.body['reference_number']).to eq 'test_code'
+ expect(response.body['property_id']).to eq '15'
+ expect(response.body['check_in']).to eq '2016-12-09'
+ expect(response.body['check_out']).to eq '2016-12-17'
+ expect(response.body['guests']).to eq 3
+ expect(response.body['customer']).to eq customer
+ end
+ end
+end
\ No newline at end of file
diff --git a/spec/api/controllers/thh/cancel_spec.rb b/spec/api/controllers/thh/cancel_spec.rb
new file mode 100644
index 000000000..fec2ef0d1
--- /dev/null
+++ b/spec/api/controllers/thh/cancel_spec.rb
@@ -0,0 +1,40 @@
+require "spec_helper"
+require_relative "../shared/cancel"
+
+RSpec.describe API::Controllers::THH::Cancel do
+
+ it_behaves_like 'cancel action' do
+ let(:success_cases) do
+ [
+ { params: {reference_number: '5486789', inquiry_id: '125'}, cancelled_reference_number: '5486789' },
+ { params: {reference_number: '2154254', inquiry_id: '128'}, cancelled_reference_number: '2154254' },
+ ]
+ end
+
+ let(:error_cases) do
+ [
+ { params: {reference_number: '658794', inquiry_id: '392'}, error: 'Could not cancel with remote supplier' },
+ { params: {reference_number: '245784', inquiry_id: '399'}, error: 'Already cancelled' }
+ ]
+ end
+ end
+
+ before do
+ allow_any_instance_of(THH::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
diff --git a/spec/api/controllers/thh/quote_spec.rb b/spec/api/controllers/thh/quote_spec.rb
new file mode 100644
index 000000000..3e0ca385b
--- /dev/null
+++ b/spec/api/controllers/thh/quote_spec.rb
@@ -0,0 +1,100 @@
+require "spec_helper"
+require_relative "../shared/quote_validations"
+require_relative "../shared/external_error_reporting"
+
+RSpec.describe API::Controllers::THH::Quote do
+ include Support::HTTPStubbing
+ include Support::Fixtures
+
+ include Support::Factories
+
+ let!(:supplier) { create_supplier(name: THH::Client::SUPPLIER_NAME) }
+ let!(:host) { create_host(supplier_id: supplier.id, fee_percentage: 5) }
+ let!(:property) do
+ create_property(
+ identifier: '15',
+ host_id: host.id,
+ data: { max_guests: 2}
+ )
+ end
+ let(:params) {
+ { property_id: property.identifier, check_in: '2016-12-09', check_out: '2016-12-17', guests: 2 }
+ }
+ let(:url) { 'http://example.org' }
+ let(:credentials) { double(key: 'Foo', url: url) }
+ let(:quote_response) do
+ Concierge::SafeAccessHash.new(
+ {
+ available: 'yes',
+ price: '48,000'
+ }
+ )
+ end
+ let(:unavailable_quote_response) do
+ Concierge::SafeAccessHash.new(
+ {
+ available: 'no',
+ price: '48,000'
+ }
+ )
+ end
+
+ 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(:supplier_name) { THH::Client::SUPPLIER_NAME }
+
+ def provoke_failure!
+ stub_call(:get, url) { raise Faraday::TimeoutError }
+ Struct.new(:code).new('connection_timeout')
+ end
+ end
+
+ describe '#call' do
+ it "returns a proper error if Price fails" do
+ allow_any_instance_of(THH::Price).to receive(:quote) { Result.error(:error, 'Some error') }
+
+ response = parse_response(described_class.new.call(params))
+
+ expect(response.status).to eq 503
+ expect(response.body['status']).to eq 'error'
+ expect(response.body['errors']['quote']).to eq 'Some error'
+ end
+
+ it 'returns unavailable quotation when the supplier responds so' do
+ allow_any_instance_of(THH::Commands::QuoteFetcher).to receive(:call) { Result.new(unavailable_quote_response) }
+
+ response = parse_response(described_class.new.call(params))
+
+ 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 '15'
+ expect(response.body['check_in']).to eq '2016-12-09'
+ expect(response.body['check_out']).to eq '2016-12-17'
+ 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 quotations with price when the call is successful' do
+ allow_any_instance_of(THH::Commands::QuoteFetcher).to receive(:call) { Result.new(quote_response) }
+
+
+ response = parse_response(described_class.new.call(params))
+
+ 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 '15'
+ expect(response.body['check_in']).to eq '2016-12-09'
+ expect(response.body['check_out']).to eq '2016-12-17'
+ expect(response.body['guests']).to eq 2
+ expect(response.body['currency']).to eq 'THB'
+ expect(response.body['total']).to eq 48000.0
+ end
+
+ end
+end
diff --git a/spec/fixtures/thh/availability_response.xml b/spec/fixtures/thh/availability_response.xml
new file mode 100644
index 000000000..4cd45918d
--- /dev/null
+++ b/spec/fixtures/thh/availability_response.xml
@@ -0,0 +1,6 @@
+
+ yes
+ 8
+ 1,406
+ USD
+
\ No newline at end of file
diff --git a/spec/fixtures/thh/booking_response.xml b/spec/fixtures/thh/booking_response.xml
new file mode 100644
index 000000000..d115f2db9
--- /dev/null
+++ b/spec/fixtures/thh/booking_response.xml
@@ -0,0 +1,9 @@
+
+
+ instant
+ success
+ 80385
+ 8
+ 48,000
+ THB
+
diff --git a/spec/fixtures/thh/cancel_response.xml b/spec/fixtures/thh/cancel_response.xml
new file mode 100644
index 000000000..61fc99884
--- /dev/null
+++ b/spec/fixtures/thh/cancel_response.xml
@@ -0,0 +1,5 @@
+
+
+ ok
+ Booking successfully canceled
+
\ No newline at end of file
diff --git a/spec/fixtures/thh/empty_properties_response.xml b/spec/fixtures/thh/empty_properties_response.xml
new file mode 100644
index 000000000..73a930941
--- /dev/null
+++ b/spec/fixtures/thh/empty_properties_response.xml
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/spec/fixtures/thh/many_properties_response.xml b/spec/fixtures/thh/many_properties_response.xml
new file mode 100644
index 000000000..3caf118b3
--- /dev/null
+++ b/spec/fixtures/thh/many_properties_response.xml
@@ -0,0 +1,798 @@
+
+
+
+ 3
+ View Talay (1 Bed Pool Villa)
+ http://www.thailandholidayhomes.com/pattaya/view-talay-villa-1-bedroom-house-private-pool-garden-jomtien-pattaya.html
+ true
+ Thailand
+ Chonburi
+ Pattaya
+ Villa
+ 2
+ 1
+
+ 1
+
+ 1
+
+ 12.89784
+ 100.871744
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1
+
+
+ 1
+ 1
+ 1
+ 1
+ 1
+ 1
+
+
+ 2
+ 4
+
+
+
+ 1
+ 1
+ 1
+ 1
+ 1
+
+
+ 1
+
+
+
+
+ 1
+
+
+
+
+ beach
+ 400
+ meter
+
+
+ restaurant
+ 500
+ meter
+
+
+ nightlife
+ 500
+ meter
+
+
+ shops
+ 500
+ meter
+
+
+ massage
+ 500
+ meter
+
+
+
+ http://img.thailandholidayhomes.com/cache/villa_3-530x354-1.jpg
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 5000
+ 57 KW/day
+ free
+ 7 THB/KW
+ Landline: +66 (0) 38-757-142
+ Mobil 1: +66 (0) 88-212-3556
+ Mobil 2: +66 (0) 83-306-9417
+
+
+
+
+ 01.02.2015
+ 31.08.2015
+ high1
+ 5,825
+ THB
+ 3
+
+
+ 01.09.2015
+ 30.09.2015
+ low1
+ 5,364
+ THB
+ 3
+
+
+ 01.10.2015
+ 14.12.2015
+ high2
+ 5,825
+ THB
+ 3
+
+
+ 15.12.2015
+ 31.01.2016
+ peak1
+ 6,334
+ THB
+ 3
+
+
+ 01.02.2016
+ 31.08.2016
+ High 1 / 2016
+ 5,825
+ THB
+ 3
+
+
+ 01.09.2016
+ 30.09.2016
+ Low / 2016
+ 5,364
+ THB
+ 3
+
+
+ 01.10.2016
+ 17.12.2016
+ High 2 / 2016
+ 5,825
+ THB
+ 3
+
+
+ 18.12.2016
+ 10.01.2017
+ Peak / 2016
+ 6,334
+ THB
+ 10
+
+
+ 11.01.2017
+ 31.08.2017
+ High 1 / 2017
+ 5,825
+ THB
+ 3
+
+
+ 01.09.2017
+ 30.09.2017
+ Low / 2017
+ 5,364
+ THB
+ 3
+
+
+ 01.10.2017
+ 17.12.2017
+ High 2 / 2017
+ 5,825
+ THB
+ 3
+
+
+ 18.12.2017
+ 10.01.2018
+ Peak / 2017
+ 6,334
+ THB
+ 10
+
+
+ 11.01.2018
+ 31.08.2018
+ High1/2018
+ 5,825
+ THB
+ 3
+
+
+ 01.09.2018
+ 30.09.2018
+ Low/2018
+ 5,364
+ THB
+ 3
+
+
+ 01.10.2018
+ 17.12.2018
+ High2/2018
+ 5,825
+ THB
+ 3
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ http://img.thailandholidayhomes.com/cache/56299-80x80-1.jpg
+
+
+
+
+
+
+
+
+ http://img.thailandholidayhomes.com/cache/6518-80x80-1.jpg
+
+
+
+
+ 15
+ Baan Duan Chai
+ http://www.thailandholidayhomes.com/pattaya/baan-duan-chai-5-bedroom-pool-villa-jomtien-pattaya.html
+ true
+ Thailand
+ Chonburi
+ Pattaya
+ Villa
+ 10
+ 5
+
+ 4
+ 2
+
+ 5
+ 1
+
+ 12.884067
+ 100.896267
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1
+ 1
+
+
+ 1
+ 1
+ 1
+ 1
+ 1
+ 1
+ 1
+
+
+ 6
+ 6
+
+
+
+ 1
+ 1
+ 1
+ 1
+ 1
+ 1
+ 1
+ 1
+
+
+ 1
+ 1
+
+
+
+
+ 1
+ 1
+ 1
+
+
+
+
+ beach
+ 1000
+ meter
+
+
+ restaurant
+ 500
+ meter
+
+
+ nightlife
+ 1500
+ meter
+
+
+ shops
+ 500
+ meter
+
+
+ massage
+ 700
+ meter
+
+
+
+ http://img.thailandholidayhomes.com/cache/villa_15-530x354-1.jpg
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 10000
+ 100 KW/day
+ free
+ 7 THB/KW
+ Landline: +66 (0) 38-757-142
+ Mobil 1: +66 (0) 88-212-3556
+ Mobil 2: +66 (0) 83-306-9417
+
+
+
+
+ 15.12.2015
+ 05.01.2016
+ Peak/2015
+ 8,820
+ THB
+ 3
+
+
+ 06.04.2016
+ 31.08.2016
+ high1
+ 7,800
+ THB
+ 1
+
+
+ 06.04.2016
+ 31.08.2016
+ high1
+ 7,475
+ THB
+ 2
+
+
+ 06.04.2016
+ 31.08.2016
+ high1
+ 6,500
+ THB
+ 3
+
+
+ 01.09.2016
+ 30.09.2016
+ Low
+ 7,680
+ THB
+ 1
+
+
+ 01.09.2016
+ 30.09.2016
+ Low
+ 7,360
+ THB
+ 2
+
+
+ 01.09.2016
+ 30.09.2016
+ Low
+ 6,400
+ THB
+ 3
+
+
+ 01.10.2016
+ 17.12.2016
+ high2
+ 8,510
+ THB
+ 1
+
+
+ 01.10.2016
+ 17.12.2016
+ high2
+ 8,510
+ THB
+ 2
+
+
+ 01.10.2016
+ 17.12.2016
+ high2
+ 6,000
+ THB
+ 3
+
+
+ 18.12.2016
+ 10.01.2017
+ peak1
+ 9,400
+ THB
+ 3
+
+
+ 11.01.2017
+ 31.08.2017
+ high1 2017
+ 10,068
+ THB
+ 1
+
+
+ 11.01.2017
+ 31.08.2017
+ high1 2017
+ 9,649
+ THB
+ 2
+
+
+ 11.01.2017
+ 31.08.2017
+ high1 2017
+ 8,390
+ THB
+ 3
+
+
+ 01.09.2017
+ 30.09.2017
+ Low 2017
+ 8,808
+ THB
+ 1
+
+
+ 01.09.2017
+ 30.09.2017
+ Low 2017
+ 8,441
+ THB
+ 2
+
+
+ 01.09.2017
+ 30.09.2017
+ Low 2017
+ 7,340
+ THB
+ 3
+
+
+ 01.10.2017
+ 17.12.2017
+ high2 2017
+ 10,068
+ THB
+ 1
+
+
+ 01.10.2017
+ 17.12.2017
+ high2 2017
+ 9,649
+ THB
+ 2
+
+
+ 01.10.2017
+ 17.12.2017
+ high2 2017
+ 8,390
+ THB
+ 3
+
+
+ 18.12.2017
+ 10.01.2018
+ peak1 2017
+ 11,538
+ THB
+ 10
+
+
+ 11.01.2018
+ 31.08.2018
+ high12017/2018
+ 10,068
+ THB
+ 1
+
+
+ 11.01.2018
+ 31.08.2018
+ high12017/2018
+ 9,649
+ THB
+ 2
+
+
+ 11.01.2018
+ 31.08.2018
+ high12017/2018
+ 8,390
+ THB
+ 3
+
+
+ 01.09.2018
+ 30.09.2018
+ Low2017/2018
+ 8,808
+ THB
+ 1
+
+
+ 01.09.2018
+ 30.09.2018
+ Low2017/2018
+ 8,441
+ THB
+ 2
+
+
+ 01.09.2018
+ 30.09.2018
+ Low2017/2018
+ 7,340
+ THB
+ 3
+
+
+ 01.10.2018
+ 17.12.2018
+ high22017/2018
+ 10,068
+ THB
+ 1
+
+
+ 01.10.2018
+ 17.12.2018
+ high22017/2018
+ 9,649
+ THB
+ 2
+
+
+ 01.10.2018
+ 17.12.2018
+ high22017/2018
+ 8,390
+ THB
+ 3
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ http://img.thailandholidayhomes.com/cache/4548-80x80-1.jpg
+
+
+
+
+
+
+
+
+ http://img.thailandholidayhomes.com/cache/118375-80x80-1.jpg
+
+
+
+
+
+
+
+
+ http://img.thailandholidayhomes.com/cache/58235-80x80-1.jpg
+
+
+
+
+
+
+
+
+ http://img.thailandholidayhomes.com/cache/59487-80x80-1.jpg
+
+
+
+
\ No newline at end of file
diff --git a/spec/fixtures/thh/no_available_availability_response.xml b/spec/fixtures/thh/no_available_availability_response.xml
new file mode 100644
index 000000000..338933e89
--- /dev/null
+++ b/spec/fixtures/thh/no_available_availability_response.xml
@@ -0,0 +1,5 @@
+
+ 8
+ 1,406
+ USD
+
\ No newline at end of file
diff --git a/spec/fixtures/thh/no_booking_id_booking_response.xml b/spec/fixtures/thh/no_booking_id_booking_response.xml
new file mode 100644
index 000000000..37b234612
--- /dev/null
+++ b/spec/fixtures/thh/no_booking_id_booking_response.xml
@@ -0,0 +1,8 @@
+
+
+ instant
+ success
+ 8
+ 48,000
+ THB
+
diff --git a/spec/fixtures/thh/no_booking_status_booking_response.xml b/spec/fixtures/thh/no_booking_status_booking_response.xml
new file mode 100644
index 000000000..22c5afe33
--- /dev/null
+++ b/spec/fixtures/thh/no_booking_status_booking_response.xml
@@ -0,0 +1,8 @@
+
+
+ instant
+ 80385
+ 8
+ 48,000
+ THB
+
diff --git a/spec/fixtures/thh/no_price_availability_response.xml b/spec/fixtures/thh/no_price_availability_response.xml
new file mode 100644
index 000000000..46c97f78c
--- /dev/null
+++ b/spec/fixtures/thh/no_price_availability_response.xml
@@ -0,0 +1,5 @@
+
+ yes
+ 8
+ USD
+
\ No newline at end of file
diff --git a/spec/fixtures/thh/no_status_cancel_response.xml b/spec/fixtures/thh/no_status_cancel_response.xml
new file mode 100644
index 000000000..928ee7bee
--- /dev/null
+++ b/spec/fixtures/thh/no_status_cancel_response.xml
@@ -0,0 +1,4 @@
+
+
+ Booking successfully canceled
+
\ No newline at end of file
diff --git a/spec/fixtures/thh/no_villa_status_booking_response.xml b/spec/fixtures/thh/no_villa_status_booking_response.xml
new file mode 100644
index 000000000..e9cb637b3
--- /dev/null
+++ b/spec/fixtures/thh/no_villa_status_booking_response.xml
@@ -0,0 +1,8 @@
+
+
+ success
+ 80385
+ 8
+ 48,000
+ THB
+
diff --git a/spec/fixtures/thh/properties_response.xml b/spec/fixtures/thh/properties_response.xml
new file mode 100644
index 000000000..59bc78820
--- /dev/null
+++ b/spec/fixtures/thh/properties_response.xml
@@ -0,0 +1,511 @@
+
+
+
+ 15
+ Baan Duan Chai
+ http://www.thailandholidayhomes.com/pattaya/baan-duan-chai-5-bedroom-pool-villa-jomtien-pattaya.html
+ true
+ Thailand
+ Chonburi
+ Pattaya
+ Villa
+ 10
+ 5
+
+ 4
+ 2
+
+ 5
+ 1
+
+ 12.884067
+ 100.896267
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1
+ 1
+
+
+ 1
+ 1
+ 1
+ 1
+ 1
+ 1
+ 1
+ 1
+
+
+ 6
+ 6
+
+
+
+ 1
+ 1
+ 1
+ 1
+ 1
+ 1
+ 1
+ 1
+
+
+ 1
+ 1
+
+
+ 1
+
+
+
+ 1
+ 1
+ 1
+
+
+
+
+ beach
+ 1000
+ meter
+
+
+ restaurant
+ 500
+ meter
+
+
+ nightlife
+ 1500
+ meter
+
+
+ shops
+ 500
+ meter
+
+
+ massage
+ 700
+ meter
+
+
+
+ http://img.thailandholidayhomes.com/cache/villa_15-530x354-1.jpg
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 10000
+ 100 KW/day
+ free
+ 7 THB/KW
+ Landline: +66 (0) 38-757-142
+ Mobil 1: +66 (0) 88-212-3556
+ Mobil 2: +66 (0) 83-306-9417
+
+
+
+
+ 15.12.2015
+ 05.01.2016
+ Peak/2015
+ 8,820
+ THB
+ 3
+
+
+ 06.04.2016
+ 31.08.2016
+ high1
+ 7,800
+ THB
+ 1
+
+
+ 06.04.2016
+ 31.08.2016
+ high1
+ 7,475
+ THB
+ 2
+
+
+ 06.04.2016
+ 31.08.2016
+ high1
+ 6,500
+ THB
+ 3
+
+
+ 01.09.2016
+ 30.09.2016
+ Low
+ 7,680
+ THB
+ 1
+
+
+ 01.09.2016
+ 30.09.2016
+ Low
+ 7,360
+ THB
+ 2
+
+
+ 01.09.2016
+ 30.09.2016
+ Low
+ 6,400
+ THB
+ 3
+
+
+ 01.10.2016
+ 17.12.2016
+ high2
+ 8,510
+ THB
+ 1
+
+
+ 01.10.2016
+ 17.12.2016
+ high2
+ 8,510
+ THB
+ 2
+
+
+ 01.10.2016
+ 17.12.2016
+ high2
+ 6,000
+ THB
+ 3
+
+
+ 18.12.2016
+ 10.01.2017
+ peak1
+ 9,400
+ THB
+ 3
+
+
+ 12.01.2017
+ 31.08.2017
+ high1 2017
+ 10,068
+ THB
+ 1
+
+
+ 12.01.2017
+ 31.08.2017
+ high1 2017
+ 9,649
+ THB
+ 2
+
+
+ 12.01.2017
+ 31.08.2017
+ high1 2017
+ 8,390
+ THB
+ 3
+
+
+ 01.09.2017
+ 30.09.2017
+ Low 2017
+ 8,808
+ THB
+ 1
+
+
+ 01.09.2017
+ 30.09.2017
+ Low 2017
+ 8,441
+ THB
+ 2
+
+
+ 01.09.2017
+ 30.09.2017
+ Low 2017
+ 7,340
+ THB
+ 3
+
+
+ 01.10.2017
+ 17.12.2017
+ high2 2017
+ 10,068
+ THB
+ 1
+
+
+ 01.10.2017
+ 17.12.2017
+ high2 2017
+ 9,649
+ THB
+ 2
+
+
+ 01.10.2017
+ 17.12.2017
+ high2 2017
+ 8,390
+ THB
+ 3
+
+
+ 18.12.2017
+ 10.01.2018
+ peak1 2017
+ 11,538
+ THB
+ 10
+
+
+ 11.01.2018
+ 31.08.2018
+ high12017/2018
+ 10,068
+ THB
+ 1
+
+
+ 11.01.2018
+ 31.08.2018
+ high12017/2018
+ 9,649
+ THB
+ 2
+
+
+ 11.01.2018
+ 31.08.2018
+ high12017/2018
+ 8,390
+ THB
+ 3
+
+
+ 01.09.2018
+ 30.09.2018
+ Low2017/2018
+ 8,808
+ THB
+ 1
+
+
+ 01.09.2018
+ 30.09.2018
+ Low2017/2018
+ 8,441
+ THB
+ 2
+
+
+ 01.09.2018
+ 30.09.2018
+ Low2017/2018
+ 7,340
+ THB
+ 3
+
+
+ 01.10.2018
+ 17.12.2018
+ high22017/2018
+ 10,068
+ THB
+ 1
+
+
+ 01.10.2018
+ 17.12.2018
+ high22017/2018
+ 9,649
+ THB
+ 2
+
+
+ 01.10.2018
+ 17.12.2018
+ high22017/2018
+ 8,390
+ THB
+ 3
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ http://img.thailandholidayhomes.com/cache/4548-80x80-1.jpg
+
+
+
+
+
+
+
+
+ http://img.thailandholidayhomes.com/cache/118375-80x80-1.jpg
+
+
+
+
+
+
+
+
+ http://img.thailandholidayhomes.com/cache/58235-80x80-1.jpg
+
+
+
+
+
+
+
+
+ http://img.thailandholidayhomes.com/cache/59487-80x80-1.jpg
+
+
+
+
\ No newline at end of file
diff --git a/spec/fixtures/thh/properties_without_available_days_response.xml b/spec/fixtures/thh/properties_without_available_days_response.xml
new file mode 100644
index 000000000..287c344f7
--- /dev/null
+++ b/spec/fixtures/thh/properties_without_available_days_response.xml
@@ -0,0 +1,201 @@
+
+
+
+ 960
+ Baan Sanun 4 – 1-Bed-Studio
+ http://www.thailandholidayhomes.com/phuket/baan-sanun-4-studio-condo-patong-beach.html
+ true
+ Thailand
+ Phuket
+ Phuket
+ Apartment
+ 2
+ 1
+
+ 1
+
+ 1
+
+ 7.89128
+ 98.303539
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1
+
+
+ 1
+ 1
+ 1
+ 1
+
+
+ 2
+
+
+
+ 1
+ 1
+ 1
+ 1
+ 1
+ 1
+
+
+ 1
+ 1
+
+
+ 1
+
+
+
+ 1
+ 1
+
+
+
+
+ beach
+ 1400
+ meter
+
+
+ restaurant
+ 50
+ meter
+
+
+ nightlife
+ 200
+ meter
+
+
+ shops
+ 50
+ meter
+
+
+ massage
+ 100
+ meter
+
+
+
+ http://img.thailandholidayhomes.com/cache/villa_960-530x354-1.jpg
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 15000
+ 57 KW/day
+ free
+ 7 THB/KW
+ Landline: +66 (0) 38-757-142
+ Mobil 1: +66 (0) 88-212-3556
+ Mobil 2: +66 (0) 83-306-9417
+
+
+
+
+ 16.09.2015
+ 14.04.2016
+ High1/2015
+ 5,481
+ THB
+ 3
+
+
+ 15.04.2016
+ 15.09.2016
+ Low/2016
+ 3,308
+ THB
+ 3
+
+
+ 16.09.2016
+ 14.04.2017
+ High1/2016
+ 5,481
+ THB
+ 3
+
+
+ 15.04.2017
+ 15.09.2017
+ Low/2017
+ 3,308
+ THB
+ 3
+
+
+ 16.09.2017
+ 14.04.2018
+ High1/2017
+ 5,481
+ THB
+ 3
+
+
+ 15.04.2018
+ 15.09.2018
+ Low/2018
+ 3,308
+ THB
+ 3
+
+
+ 16.09.2018
+ 14.04.2019
+ High1/2019
+ 5,481
+ THB
+ 3
+
+
+ 15.04.2019
+ 15.09.2019
+ Low/2019
+ 3,308
+ THB
+ 3
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/spec/fixtures/thh/property_response.xml b/spec/fixtures/thh/property_response.xml
new file mode 100644
index 000000000..364bbd48c
--- /dev/null
+++ b/spec/fixtures/thh/property_response.xml
@@ -0,0 +1,497 @@
+
+
+
+ 15
+ Baan Duan Chai
+ http://www.thailandholidayhomes.com/pattaya/baan-duan-chai-5-bedroom-pool-villa-jomtien-pattaya.html
+ true
+ Thailand
+ Chonburi
+ Pattaya
+ Villa
+ 10
+ 5
+
+ 4
+ 2
+
+ 5
+ 1
+
+ 12.884067
+ 100.896267
+
+
+
+ This modern tropical five-bedroom house at Jomtein is the holiday home of your dreams. Picture relaxing in your private swimming pool minutes from the best amenities and facilities Jomtein has to offer. With a hire car inclusive in our attractive rates, you can be at the beach within minutes, be it Jomtein or Pattaya.
+
+
+ Crossing the finely landscaped gardens, and entering this property through a set of French doors, the occupant is greeted by a stylish, modern decor theme, which is repeated throughout the entire villa. The large lounge, which incorporates a spacious dining area, runs the full length of the property. A well-equipped modern kitchen, with black granite worktops, is tucked away in a separate room, with easy access to the dining area. Moving upstairs, three of the four bedrooms seem to vie for the title of “Master Bedroom”, each being elegantly decorated and entirely comfortable, with their own en-suite wet rooms. This is a beautiful holiday property, decorated in a fun and fresh style, a wonderful home away from home in which to enjoy your vacation. Conveniently close to all of the major amenities, in the quite gated community of Viewpoint, just a few minutes from the beach.
+
+
+ The living area is light and spacious with modern furnishings, seating for eight persons with Cable TV, DVD/CD player and separate radio / CD player, please note this player will not accept copy CD’s. The living area opens to a dining facility for six persons.
+ Fully fitted with granite worktops and tiled floors, appliances include fridge/freezer, microwave, oven, cooker, rice cooker, toaster and all utensils. There are place settings for eight people. A washing machine is available at the covered utility area. Iron and ironing board are also provided.
+ Bedroom 1 Queen-size bed with ample furniture. Bedroom 2 Queen-size bed with ample furniture. Bedroom 3 Queen-size bed with ample furniture. Bedroom 4 Queen-size bed with ample furniture.
+ Bedroom 1 offers a full bathroom - bedrooms 2 & 3 have good size shower rooms with toilet and wash hand basins. There is a cloakroom off the living area for your convenience.
+
+
+
+
+ 1
+ 1
+
+
+ 1
+ 1
+ 1
+ 1
+ 1
+ 1
+ 1
+
+
+ 6
+ 6
+
+
+
+ 1
+ 1
+ 1
+ 1
+ 1
+ 1
+ 1
+ 1
+
+
+ 1
+ 1
+
+
+
+
+ 1
+ 1
+ 1
+
+
+
+
+ beach
+ 1000
+ meter
+
+
+ restaurant
+ 500
+ meter
+
+
+ nightlife
+ 1500
+ meter
+
+
+ shops
+ 500
+ meter
+
+
+ massage
+ 700
+ meter
+
+
+
+ http://img.thailandholidayhomes.com/cache/villa_15-530x354-1.jpg
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 10,000 THB
+ 100 KW/day
+ free
+ 7 THB/KW
+ Landline: +66 (0) 38-757-142
+ Mobil 1: +66 (0) 88-212-3556
+ Mobil 2: +66 (0) 83-306-9417
+
+
+
+
+ 15.12.2015
+ 05.01.2016
+ Peak/2015
+ 8,820
+ THB
+ 3
+
+
+ 06.04.2016
+ 31.08.2016
+ high1
+ 7,800
+ THB
+ 1
+
+
+ 06.04.2016
+ 31.08.2016
+ high1
+ 7,475
+ THB
+ 2
+
+
+ 06.04.2016
+ 31.08.2016
+ high1
+ 6,500
+ THB
+ 3
+
+
+ 01.09.2016
+ 30.09.2016
+ Low
+ 7,680
+ THB
+ 1
+
+
+ 01.09.2016
+ 30.09.2016
+ Low
+ 7,360
+ THB
+ 2
+
+
+ 01.09.2016
+ 30.09.2016
+ Low
+ 6,400
+ THB
+ 3
+
+
+ 01.10.2016
+ 17.12.2016
+ high2
+ 8,510
+ THB
+ 1
+
+
+ 01.10.2016
+ 17.12.2016
+ high2
+ 8,510
+ THB
+ 2
+
+
+ 01.10.2016
+ 17.12.2016
+ high2
+ 6,000
+ THB
+ 3
+
+
+ 18.12.2016
+ 10.01.2017
+ peak1
+ 9,400
+ THB
+ 3
+
+
+ 11.01.2017
+ 31.08.2017
+ high1 2017
+ 10,068
+ THB
+ 1
+
+
+ 11.01.2017
+ 31.08.2017
+ high1 2017
+ 9,649
+ THB
+ 2
+
+
+ 11.01.2017
+ 31.08.2017
+ high1 2017
+ 8,390
+ THB
+ 3
+
+
+ 01.09.2017
+ 30.09.2017
+ Low 2017
+ 8,808
+ THB
+ 1
+
+
+ 01.09.2017
+ 30.09.2017
+ Low 2017
+ 8,441
+ THB
+ 2
+
+
+ 01.09.2017
+ 30.09.2017
+ Low 2017
+ 7,340
+ THB
+ 3
+
+
+ 01.10.2017
+ 17.12.2017
+ high2 2017
+ 10,068
+ THB
+ 1
+
+
+ 01.10.2017
+ 17.12.2017
+ high2 2017
+ 9,649
+ THB
+ 2
+
+
+ 01.10.2017
+ 17.12.2017
+ high2 2017
+ 8,390
+ THB
+ 3
+
+
+ 18.12.2017
+ 10.01.2018
+ peak1 2017
+ 11,538
+ THB
+ 10
+
+
+ 11.01.2018
+ 31.08.2018
+ high12017/2018
+ 10,068
+ THB
+ 1
+
+
+ 11.01.2018
+ 31.08.2018
+ high12017/2018
+ 9,649
+ THB
+ 2
+
+
+ 11.01.2018
+ 31.08.2018
+ high12017/2018
+ 8,390
+ THB
+ 3
+
+
+ 01.09.2018
+ 30.09.2018
+ Low2017/2018
+ 8,808
+ THB
+ 1
+
+
+ 01.09.2018
+ 30.09.2018
+ Low2017/2018
+ 8,441
+ THB
+ 2
+
+
+ 01.09.2018
+ 30.09.2018
+ Low2017/2018
+ 7,340
+ THB
+ 3
+
+
+ 01.10.2018
+ 17.12.2018
+ high22017/2018
+ 10,068
+ THB
+ 1
+
+
+ 01.10.2018
+ 17.12.2018
+ high22017/2018
+ 9,649
+ THB
+ 2
+
+
+ 01.10.2018
+ 17.12.2018
+ high22017/2018
+ 8,390
+ THB
+ 3
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ http://img.thailandholidayhomes.com/cache/4548-80x80-1.jpg
+
+
+
+
+
+
+
+
+ http://img.thailandholidayhomes.com/cache/118375-80x80-1.jpg
+
+
+
+
+
+
+
+
+ http://img.thailandholidayhomes.com/cache/58235-80x80-1.jpg
+
+
+
+
+
+
+
+
+ http://img.thailandholidayhomes.com/cache/59487-80x80-1.jpg
+
+
+
+
\ No newline at end of file
diff --git a/spec/fixtures/thh/unexpected_booking_status_booking_response.xml b/spec/fixtures/thh/unexpected_booking_status_booking_response.xml
new file mode 100644
index 000000000..6fb63448a
--- /dev/null
+++ b/spec/fixtures/thh/unexpected_booking_status_booking_response.xml
@@ -0,0 +1,9 @@
+
+
+ instant
+ false
+ 80385
+ 8
+ 48,000
+ THB
+
diff --git a/spec/fixtures/thh/unexpected_response.xml b/spec/fixtures/thh/unexpected_response.xml
new file mode 100644
index 000000000..d2328ee5f
--- /dev/null
+++ b/spec/fixtures/thh/unexpected_response.xml
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/spec/fixtures/thh/unexpected_status_cancel_response.xml b/spec/fixtures/thh/unexpected_status_cancel_response.xml
new file mode 100644
index 000000000..b6a096a9f
--- /dev/null
+++ b/spec/fixtures/thh/unexpected_status_cancel_response.xml
@@ -0,0 +1,5 @@
+
+
+ false
+ Booking ID does not exist
+
\ No newline at end of file
diff --git a/spec/fixtures/thh/unexpected_villa_status_booking_response.xml b/spec/fixtures/thh/unexpected_villa_status_booking_response.xml
new file mode 100644
index 000000000..b90794146
--- /dev/null
+++ b/spec/fixtures/thh/unexpected_villa_status_booking_response.xml
@@ -0,0 +1,9 @@
+
+
+ on_request
+ success
+ 80385
+ 8
+ 48,000
+ THB
+
diff --git a/spec/lib/concierge/suppliers/thh/availabilities_spec.rb b/spec/lib/concierge/suppliers/thh/availabilities_spec.rb
new file mode 100644
index 000000000..fde599980
--- /dev/null
+++ b/spec/lib/concierge/suppliers/thh/availabilities_spec.rb
@@ -0,0 +1,58 @@
+require 'spec_helper'
+
+RSpec.describe Workers::Suppliers::THH::Availabilities do
+ include Support::Fixtures
+ include Support::Factories
+
+ before(:example) { create_property(host_id: host.id) }
+
+ let(:supplier) { create_supplier(name: THH::Client::SUPPLIER_NAME) }
+ let(:host) { create_host(supplier_id: supplier.id) }
+
+ subject { described_class.new(host) }
+
+ context 'fetching property' do
+ before do
+ allow_any_instance_of(THH::Importer).to receive(:fetch_property) { Result.error(:error, 'Some test') }
+ end
+
+ it 'announces an error if fetching property fails' do
+ subject.perform
+
+ error = ExternalErrorRepository.last
+
+ expect(error.operation).to eq 'sync'
+ expect(error.supplier).to eq THH::Client::SUPPLIER_NAME
+ expect(error.code).to eq 'error'
+ expect(error.description).to eq 'Some test'
+ end
+
+ it 'doesnt finalize synchronisation with external error' do
+ expect(Roomorama::Client::Operations).to_not receive(:disable)
+ subject.perform
+ end
+ end
+
+
+ context 'success' do
+
+ let(:property) { read_property('thh/property_response.xml') }
+
+ before do
+ allow_any_instance_of(THH::Importer).to receive(:fetch_property) { Result.new(property) }
+ end
+
+ it 'finalizes synchronisation' do
+ allow_any_instance_of(Roomorama::Client).to receive(:perform) { Result.new('success') }
+
+ expect(subject.synchronisation).to receive(:finish!)
+ subject.perform
+ end
+ end
+
+ def read_property(name)
+ parser = Nori.new(advanced_typecasting: false)
+ response = parser.parse(read_fixture(name))['response']
+ Concierge::SafeAccessHash.new(response['property'])
+ end
+end
diff --git a/spec/lib/concierge/suppliers/thh/booking_spec.rb b/spec/lib/concierge/suppliers/thh/booking_spec.rb
new file mode 100644
index 000000000..d23c3f462
--- /dev/null
+++ b/spec/lib/concierge/suppliers/thh/booking_spec.rb
@@ -0,0 +1,70 @@
+require "spec_helper"
+
+RSpec.describe THH::Booking do
+ include Support::Fixtures
+ include Support::Factories
+
+ let(:customer) do
+ {
+ first_name: 'John',
+ last_name: 'Butler',
+ email: 'john@email.com',
+ phone: '+3 5486 4560'
+ }
+ end
+
+ let(:params) do
+ API::Controllers::Params::Booking.new(
+ property_id: '15',
+ check_in: '2016-12-09',
+ check_out: '2016-12-17',
+ guests: 3,
+ subtotal: 3000.0,
+ customer: customer
+ )
+ end
+ let(:credentials) { double(key: 'Foo', url: 'http://example.org') }
+
+ let(:success_response) do
+ Concierge::SafeAccessHash.new({
+ 'villa_status' => 'instant',
+ 'booking_status' => 'success',
+ 'booking_id' => '80385',
+ 'booked_nights' => '8',
+ 'price_total' => '48,000',
+ 'currency' => 'THB'
+ })
+ end
+
+ subject { described_class.new(credentials) }
+
+ describe '#book' do
+
+ it 'returns the error if any happened in the command call' do
+ allow_any_instance_of(THH::Commands::Booking).to receive(:call) { Result.error(:error, 'Some error') }
+
+ result = subject.book(params)
+
+ expect(result).not_to be_success
+ expect(result.error.code).to eq :error
+ expect(result.error.data).to eq 'Some error'
+ end
+
+ it 'returns mapped reservation' do
+ allow_any_instance_of(THH::Commands::Booking).to receive(:call) { Result.new(success_response) }
+
+ result = subject.book(params)
+
+ expect(result).to be_success
+ reservation = result.value
+
+ expect(reservation).to be_a Reservation
+ expect(reservation.check_in).to eq('2016-12-09')
+ expect(reservation.check_out).to eq('2016-12-17')
+ expect(reservation.guests).to eq(3)
+ expect(reservation.property_id).to eq('15')
+ expect(reservation.reference_number).to eq('80385')
+ expect(reservation.customer).to eq(customer)
+ end
+ end
+end
diff --git a/spec/lib/concierge/suppliers/thh/calendar_spec.rb b/spec/lib/concierge/suppliers/thh/calendar_spec.rb
new file mode 100644
index 000000000..39eacf57e
--- /dev/null
+++ b/spec/lib/concierge/suppliers/thh/calendar_spec.rb
@@ -0,0 +1,148 @@
+require 'spec_helper'
+
+RSpec.describe THH::Calendar do
+ include Support::Fixtures
+
+ let(:property) { parsed_property('thh/properties_response.xml') }
+ let(:rates) { property.get('rates.rate') }
+ let(:booked_periods) { property.get('calendar.periods.period') }
+ let(:length) { 365 }
+
+ subject { described_class.new(rates, booked_periods, length)}
+
+ before do
+ allow(Date).to receive(:today).and_return(Date.new(2016, 12, 10))
+ end
+
+ describe '#has_available_days?' do
+ it 'returns true if property has available days' do
+ expect(subject.has_available_days?).to be true
+ end
+
+ context 'no available days' do
+ let(:rates) do
+ [
+ {"start_date" => "15.12.2015", "end_date" => "20.12.2016", "title" => "Peak/2015", "night" => "8,820", "currency" => "THB", "min_nights" => "3"},
+ {"start_date" => "01.01.2017", "end_date" => "05.01.2017", "title" => "High", "night" => "8,830", "currency" => "THB", "min_nights" => "3"}
+ ]
+ end
+ let(:booked_periods) do
+ [
+ {"@date_from" => "01.12.2016", "@date_to" => "2016-12-31"},
+ {"@date_from" => "01.01.2017", "@date_to" => "2017-01-10"}
+ ]
+ end
+
+ it 'returns false' do
+ expect(subject.has_available_days?).to be false
+ end
+ end
+ end
+
+ describe '#min_stay' do
+ it 'returns minimum stay' do
+ expect(subject.min_stay).to eq(1)
+ end
+ end
+
+ describe '#min_rate' do
+ it 'returns minimum rate' do
+ expect(subject.min_rate).to eq(8510.0)
+ end
+ end
+
+ describe '#rates_days' do
+ it 'returns hash of rates' do
+ rates_days = subject.rates_days
+
+ expect(rates_days).to be_a(Hash)
+ expect(rates_days.keys).to all(be_a(Date))
+ expect(rates_days.values).to all(be_a(Hash))
+ end
+
+ it 'does not return days less then today' do
+ rates_days = subject.rates_days
+
+ days = rates_days.keys.select { |d| d < Date.today }
+ expect(days).to be_empty
+ end
+
+ it 'does not return days after the length' do
+ rates_days = subject.rates_days
+
+ days = rates_days.keys.select { |d| Date.today + length < d }
+ expect(days).to be_empty
+ end
+
+ it 'does not return days without rates' do
+ rates_days = subject.rates_days
+
+ rate = rates_days[Date.new(2017, 1, 11)]
+ expect(rate).to be_nil
+ end
+
+ it 'returns days with rates' do
+ rates_days = subject.rates_days
+
+ rate = rates_days[Date.new(2017, 1, 9)]
+ expect(rate).not_to be_nil
+ expect(rate[:night]).to eq(9400.0)
+ expect(rate[:min_nights]).to eq(3)
+ end
+ end
+
+ describe '#booked_days' do
+ it 'returns set of booked days' do
+ booked_days = subject.booked_days
+
+ expect(booked_days).to be_a(Set)
+ expect(booked_days).to all(be_a(Date))
+ end
+
+ it 'does not return days less then today' do
+ booked_days = subject.booked_days
+
+ days = booked_days.select { |d| d < Date.today }
+ expect(days).to be_empty
+ end
+
+ it 'does not return days after the length' do
+ booked_days = subject.booked_days
+
+ days = booked_days.select { |d| Date.today + length < d }
+ expect(days).to be_empty
+ end
+
+ it 'does not return not booked days' do
+ booked_days = subject.booked_days
+
+ expect(booked_days.include?(Date.new(2016, 12, 29))).to be false
+ end
+
+ it 'returns booked day' do
+ booked_days = subject.booked_days
+
+ expect(booked_days.include?(Date.new(2016, 12, 30))).to be true
+ end
+
+ it 'does not include end date of booked periods to the result' do
+ booked_days = subject.booked_days
+ rates_days = subject.rates_days
+
+ expect(booked_days.include?(Date.new(2017, 1, 12))).to be false
+ expect(rates_days.include?(Date.new(2017, 1, 13))).to be true
+ end
+
+ it 'include first date of booked period' do
+ booked_days = subject.booked_days
+
+ expect(booked_days.include?(Date.new(2016, 12, 30))).to be true
+ end
+ end
+
+ def parsed_property(name)
+ parser = Nori.new(advanced_typecasting: false)
+ response = parser.parse(read_fixture(name))['response']
+ Concierge::SafeAccessHash.new(response['property'])
+ end
+end
diff --git a/spec/lib/concierge/suppliers/thh/client_spec.rb b/spec/lib/concierge/suppliers/thh/client_spec.rb
new file mode 100644
index 000000000..a4393e5b3
--- /dev/null
+++ b/spec/lib/concierge/suppliers/thh/client_spec.rb
@@ -0,0 +1,72 @@
+require "spec_helper"
+
+RSpec.describe THH::Client do
+ let(:params) {
+ { property_id: '15', check_in: '2016-12-09', check_out: '2016-12-17', guests: 2 }
+ }
+ let(:credentials) { double(key: 'Foo', url: 'http://example.org') }
+
+ subject { described_class.new(credentials) }
+
+ describe '#quote' do
+ it 'returns the wrapped quotation when successful' do
+ successful_quotation = Quotation.new(total: 999)
+ allow_any_instance_of(THH::Price).to receive(:quote) { Result.new(successful_quotation) }
+
+ quote_result = subject.quote(params)
+ expect(quote_result).to be_success
+
+ quote = quote_result.value
+ expect(quote).to be_a Quotation
+ expect(quote.total).to eq 999
+ end
+
+ it 'returns a quotation object with a generic error message on failure' do
+ failed_operation = Result.error(:something_failed, 'error message')
+ allow_any_instance_of(THH::Price).to receive(:quote) { failed_operation }
+
+ quote_result = subject.quote(params)
+ expect(quote_result).to_not be_success
+ expect(quote_result.error.code).to eq :something_failed
+ expect(quote_result.error.data).to eq 'error message'
+
+ quote = quote_result.value
+ expect(quote).to be_nil
+ end
+ end
+
+ describe '#book' do
+ it 'returns the wrapped reservation when successful' do
+ successful_reservation = Reservation.new(reference_number: '654987')
+ allow_any_instance_of(THH::Booking).to receive(:book) { Result.new(successful_reservation) }
+
+ book_result = subject.book(params)
+ expect(book_result).to be_success
+
+ reservation = book_result.value
+ expect(reservation).to be_a Reservation
+ expect(reservation.reference_number).to eq '654987'
+ end
+
+ it 'returns a reservation object with a generic error message on failure' do
+ failed_operation = Result.error(:something_failed)
+ allow_any_instance_of(THH::Booking).to receive(:book) { failed_operation }
+
+ book_result = subject.book(params)
+ expect(book_result).to_not be_success
+ expect(book_result.error.code).to eq :something_failed
+
+ reservation = book_result.value
+ expect(reservation).to be_nil
+ end
+ end
+
+ describe '#cancel' do
+ let(:params) { { reference_number: '123' } }
+
+ it 'calls cancel command class' do
+ expect_any_instance_of(THH::Commands::Cancel).to(receive(:call).with('123'))
+ subject.cancel(params)
+ end
+ end
+end
diff --git a/spec/lib/concierge/suppliers/thh/commands/base_fetcher_spec.rb b/spec/lib/concierge/suppliers/thh/commands/base_fetcher_spec.rb
new file mode 100644
index 000000000..72ed5fb8d
--- /dev/null
+++ b/spec/lib/concierge/suppliers/thh/commands/base_fetcher_spec.rb
@@ -0,0 +1,70 @@
+require 'spec_helper'
+
+RSpec.describe THH::Commands::BaseFetcher do
+ include Support::Fixtures
+ include Support::HTTPStubbing
+
+ let(:url) { 'http://example.org' }
+ let(:credentials) { double(key: 'Foo', url: url) }
+ let(:params) { {} }
+
+ before do
+ allow(subject).to receive(:action).and_return('some_action')
+ end
+
+ subject { described_class.new(credentials) }
+
+ describe '#call' do
+ context 'when remote call internal error happened' do
+ it 'returns result with error' do
+ stub_call(:get, url) { raise Faraday::TimeoutError }
+
+ result = subject.api_call(params)
+
+ expect(result).not_to be_success
+ expect(result.error.code).to eq :connection_timeout
+ expect(result.error.data).to be_nil
+ end
+ end
+
+ context 'when xml response is correct' do
+ it 'returns success a hash' do
+ stub_with_fixture('thh/properties_response.xml')
+
+ result = subject.api_call(params)
+
+ expect(result).to be_a Result
+ expect(result).to be_success
+ expect(result.value).to be_a Concierge::SafeAccessHash
+ end
+ end
+
+ context 'when xml has unexpected structure' do
+ it 'returns an empty hash for empty response' do
+ stub_call(:get, url) { [200, {}, ''] }
+
+ result = subject.api_call(params)
+
+ expect(result).to be_a Result
+ expect(result).to be_success
+ expect(result.value).to be_a Concierge::SafeAccessHash
+ expect(result.value.to_h).to be_empty
+ end
+
+ it 'returns restored xml for invalid xml' do
+ stub_call(:get, url) { [200, {}, 'invalid xml'] }
+
+ result = subject.api_call(params)
+
+ expect(result).to be_a Result
+ expect(result).to be_success
+ expect(result.value).to be_a Concierge::SafeAccessHash
+ end
+ end
+ end
+
+ def stub_with_fixture(name)
+ response = read_fixture(name)
+ stub_call(:get, url) { [200, {}, response] }
+ end
+end
diff --git a/spec/lib/concierge/suppliers/thh/commands/booking_fetcher_spec.rb b/spec/lib/concierge/suppliers/thh/commands/booking_fetcher_spec.rb
new file mode 100644
index 000000000..46ae05919
--- /dev/null
+++ b/spec/lib/concierge/suppliers/thh/commands/booking_fetcher_spec.rb
@@ -0,0 +1,118 @@
+require 'spec_helper'
+
+RSpec.describe THH::Commands::Booking do
+ include Support::Fixtures
+ include Support::HTTPStubbing
+
+ let(:url) { 'http://example.org' }
+ let(:customer) do
+ {
+ first_name: 'John',
+ last_name: 'Butler',
+ email: 'john@email.com',
+ phone: '+3 5486 4560'
+ }
+ end
+
+ let(:params) do
+ API::Controllers::Params::Booking.new(
+ property_id: '15',
+ check_in: '2016-12-09',
+ check_out: '2016-12-17',
+ guests: 3,
+ subtotal: 3000.0,
+ customer: customer
+ )
+ end
+ let(:credentials) { double(key: 'Foo', url: url) }
+
+ subject { described_class.new(credentials) }
+
+ describe '#call' do
+ context 'when remote call internal error happened' do
+ it 'returns result with error' do
+ stub_call(:get, url) { raise Faraday::TimeoutError }
+
+ result = subject.call(params)
+
+ expect(result).not_to be_success
+ expect(result.error.code).to eq :connection_timeout
+ expect(result.error.data).to be_nil
+ end
+ end
+
+ context 'when xml response is correct' do
+ it 'returns raw property' do
+ stub_with_fixture('thh/booking_response.xml')
+
+ result = subject.call(params)
+
+ expect(result).to be_a Result
+ expect(result).to be_success
+ expect(result.value).to be_a Concierge::SafeAccessHash
+ end
+ end
+
+ context 'when xml has unexpected structure' do
+ it 'returns an error if no villa status field' do
+ stub_with_fixture('thh/no_villa_status_booking_response.xml')
+
+ result = subject.call(params)
+
+ expect(result).to be_a Result
+ expect(result.success?).to be false
+ expect(result.error.code).to eq(:unrecognised_response)
+ expect(result.error.data).to eq('Booking response for params `{"property_id"=>"15", "check_in"=>"2016-12-09", "check_out"=>"2016-12-17", "guests"=>3, "subtotal"=>3000, "customer"=>{"first_name"=>"John", "last_name"=>"Butler", "email"=>"john@email.com", "phone"=>"+3 5486 4560"}}` does not contain `response.villa_status` field')
+ end
+
+ it 'returns an error if unexpected villa status' do
+ stub_with_fixture('thh/unexpected_villa_status_booking_response.xml')
+
+ result = subject.call(params)
+
+ expect(result).to be_a Result
+ expect(result.success?).to be false
+ expect(result.error.code).to eq(:unrecognised_response)
+ expect(result.error.data).to eq('Booking response for params `{"property_id"=>"15", "check_in"=>"2016-12-09", "check_out"=>"2016-12-17", "guests"=>3, "subtotal"=>3000, "customer"=>{"first_name"=>"John", "last_name"=>"Butler", "email"=>"john@email.com", "phone"=>"+3 5486 4560"}}` contains unexpected value for `response.villa_status` field: `on_request`')
+ end
+
+ it 'returns an error if no booking status field' do
+ stub_with_fixture('thh/no_booking_status_booking_response.xml')
+
+ result = subject.call(params)
+
+ expect(result).to be_a Result
+ expect(result.success?).to be false
+ expect(result.error.code).to eq(:unrecognised_response)
+ expect(result.error.data).to eq('Booking response for params `{"property_id"=>"15", "check_in"=>"2016-12-09", "check_out"=>"2016-12-17", "guests"=>3, "subtotal"=>3000, "customer"=>{"first_name"=>"John", "last_name"=>"Butler", "email"=>"john@email.com", "phone"=>"+3 5486 4560"}}` does not contain `response.booking_status` field')
+ end
+
+ it 'returns an error if unexpected booking status' do
+ stub_with_fixture('thh/unexpected_booking_status_booking_response.xml')
+
+ result = subject.call(params)
+
+ expect(result).to be_a Result
+ expect(result.success?).to be false
+ expect(result.error.code).to eq(:unrecognised_response)
+ expect(result.error.data).to eq('Booking response for params `{"property_id"=>"15", "check_in"=>"2016-12-09", "check_out"=>"2016-12-17", "guests"=>3, "subtotal"=>3000, "customer"=>{"first_name"=>"John", "last_name"=>"Butler", "email"=>"john@email.com", "phone"=>"+3 5486 4560"}}` contains unexpected value for `response.booking_status` field: `false`')
+ end
+
+ it 'returns an error if no booking id' do
+ stub_with_fixture('thh/no_booking_id_booking_response.xml')
+
+ result = subject.call(params)
+
+ expect(result).to be_a Result
+ expect(result.success?).to be false
+ expect(result.error.code).to eq(:unrecognised_response)
+ expect(result.error.data).to eq('Booking response for params `{"property_id"=>"15", "check_in"=>"2016-12-09", "check_out"=>"2016-12-17", "guests"=>3, "subtotal"=>3000, "customer"=>{"first_name"=>"John", "last_name"=>"Butler", "email"=>"john@email.com", "phone"=>"+3 5486 4560"}}` does not contain `response.booking_id` field')
+ end
+ end
+ end
+
+ def stub_with_fixture(name)
+ response = read_fixture(name)
+ stub_call(:get, url) { [200, {}, response] }
+ end
+end
diff --git a/spec/lib/concierge/suppliers/thh/commands/cancel_spec.rb b/spec/lib/concierge/suppliers/thh/commands/cancel_spec.rb
new file mode 100644
index 000000000..0ef1c192d
--- /dev/null
+++ b/spec/lib/concierge/suppliers/thh/commands/cancel_spec.rb
@@ -0,0 +1,67 @@
+require 'spec_helper'
+
+RSpec.describe THH::Commands::Cancel do
+ include Support::Fixtures
+ include Support::HTTPStubbing
+
+ let(:url) { 'http://example.org' }
+ let(:booking_id) { '30884' }
+ let(:credentials) { double(key: 'Foo', url: url) }
+
+ subject { described_class.new(credentials) }
+
+ describe '#call' do
+ context 'when remote call internal error happened' do
+ it 'returns result with error' do
+ stub_call(:get, url) { raise Faraday::TimeoutError }
+
+ result = subject.call(booking_id)
+
+ expect(result).not_to be_success
+ expect(result.error.code).to eq :connection_timeout
+ expect(result.error.data).to be_nil
+ end
+ end
+
+ context 'when xml response is correct' do
+ it 'returns reference number' do
+ stub_with_fixture('thh/cancel_response.xml')
+
+ result = subject.call(booking_id)
+
+ expect(result).to be_a Result
+ expect(result).to be_success
+ expect(result.value).to eq booking_id
+ end
+ end
+
+ context 'when xml has unexpected structure' do
+ it 'returns an error if no status field' do
+ stub_with_fixture('thh/no_status_cancel_response.xml')
+
+ result = subject.call(booking_id)
+
+ expect(result).to be_a Result
+ expect(result.success?).to be false
+ expect(result.error.code).to eq(:unrecognised_response)
+ expect(result.error.data).to eq('Cancel booking `30884` response does not contain `response.status` field')
+ end
+
+ it 'returns an error if unexpected status value' do
+ stub_with_fixture('thh/unexpected_status_cancel_response.xml')
+
+ result = subject.call(booking_id)
+
+ expect(result).to be_a Result
+ expect(result.success?).to be false
+ expect(result.error.code).to eq(:unrecognised_response)
+ expect(result.error.data).to eq('Cancel booking `30884` response contains unexpected value for `response.status` field: `false`')
+ end
+ end
+ end
+
+ def stub_with_fixture(name)
+ response = read_fixture(name)
+ stub_call(:get, url) { [200, {}, response] }
+ end
+end
diff --git a/spec/lib/concierge/suppliers/thh/commands/properties_fetcher_spec.rb b/spec/lib/concierge/suppliers/thh/commands/properties_fetcher_spec.rb
new file mode 100644
index 000000000..c1efd648f
--- /dev/null
+++ b/spec/lib/concierge/suppliers/thh/commands/properties_fetcher_spec.rb
@@ -0,0 +1,100 @@
+require 'spec_helper'
+
+RSpec.describe THH::Commands::PropertiesFetcher do
+ include Support::Fixtures
+ include Support::HTTPStubbing
+
+ let(:url) { 'http://example.org' }
+ let(:credentials) do
+ double(key: 'Foo',
+ url: url)
+ end
+
+ subject { described_class.new(credentials) }
+
+ describe '#call' do
+ context 'when remote call internal error happened' do
+ it 'returns result with error' do
+ stub_call(:get, url) { raise Faraday::TimeoutError }
+
+ result = subject.call
+
+ expect(result).not_to be_success
+ expect(result.error.code).to eq :connection_timeout
+ expect(result.error.data).to be_nil
+ end
+ end
+
+ context 'when xml response is correct' do
+ it 'returns success array of properties' do
+ stub_with_fixture('thh/properties_response.xml')
+
+ result = subject.call
+
+ expect(result).to be_a Result
+ expect(result).to be_success
+ expect(result.value).to all(be_a Concierge::SafeAccessHash)
+ end
+
+ it 'can fetch many properties' do
+ stub_with_fixture('thh/many_properties_response.xml')
+
+ result = subject.call
+
+ expect(result).to be_a Result
+ expect(result).to be_success
+ expect(result.value).to all(be_a Concierge::SafeAccessHash)
+ expect(result.value.length).to eq(2)
+ end
+
+ it 'returns empty array for empty response' do
+ stub_with_fixture('thh/empty_properties_response.xml')
+
+ result = subject.call
+
+ properties = result.value
+ expect(properties).to be_empty
+ end
+ end
+
+ context 'when xml has unexpected structure' do
+ it 'returns an error for empty response' do
+ stub_call(:get, url) { [200, {}, ''] }
+
+ result = subject.call
+
+ expect(result).to be_a Result
+ expect(result.success?).to be false
+ expect(result.error.code).to eq(:unrecognised_response)
+ expect(result.error.data).to eq('Response does not contain `response` field')
+ end
+
+ it 'returns an error for invalid xml' do
+ stub_call(:get, url) { [200, {}, 'invalid xml'] }
+
+ result = subject.call
+
+ expect(result).to be_a Result
+ expect(result.success?).to be false
+ expect(result.error.code).to eq(:unrecognised_response)
+ expect(result.error.data).to eq('Response does not contain `response` field')
+ end
+
+ it 'returns an error' do
+ stub_with_fixture('thh/unexpected_response.xml')
+
+ result = subject.call
+
+ expect(result).to be_a Result
+ expect(result.success?).to be false
+ expect(result.error.code).to eq(:unrecognised_response)
+ expect(result.error.data).to eq('Response does not contain `response` field')
+ end
+ end
+ end
+
+ def stub_with_fixture(name)
+ response = read_fixture(name)
+ stub_call(:get, url) { [200, {}, response] }
+ end
+end
diff --git a/spec/lib/concierge/suppliers/thh/commands/property_fetcher_spec.rb b/spec/lib/concierge/suppliers/thh/commands/property_fetcher_spec.rb
new file mode 100644
index 000000000..68b7699f4
--- /dev/null
+++ b/spec/lib/concierge/suppliers/thh/commands/property_fetcher_spec.rb
@@ -0,0 +1,59 @@
+require 'spec_helper'
+
+RSpec.describe THH::Commands::PropertyFetcher do
+ include Support::Fixtures
+ include Support::HTTPStubbing
+
+ let(:url) { 'http://example.org' }
+ let(:property_id) { '15' }
+ let(:credentials) do
+ double(key: 'Foo',
+ url: url)
+ end
+
+ subject { described_class.new(credentials) }
+
+ describe '#call' do
+ context 'when remote call internal error happened' do
+ it 'returns result with error' do
+ stub_call(:get, url) { raise Faraday::TimeoutError }
+
+ result = subject.call(property_id)
+
+ expect(result).not_to be_success
+ expect(result.error.code).to eq :connection_timeout
+ expect(result.error.data).to be_nil
+ end
+ end
+
+ context 'when xml response is correct' do
+ it 'returns raw property' do
+ stub_with_fixture('thh/property_response.xml')
+
+ result = subject.call(property_id)
+
+ expect(result).to be_a Result
+ expect(result).to be_success
+ expect(result.value).to be_a Concierge::SafeAccessHash
+ end
+ end
+
+ context 'when xml has unexpected structure' do
+ it 'returns an error' do
+ stub_with_fixture('thh/unexpected_response.xml')
+
+ result = subject.call(property_id)
+
+ expect(result).to be_a Result
+ expect(result.success?).to be false
+ expect(result.error.code).to eq(:unrecognised_response)
+ expect(result.error.data).to eq('Property response for id `15` does not contain `response.property` field')
+ end
+ end
+ end
+
+ def stub_with_fixture(name)
+ response = read_fixture(name)
+ stub_call(:get, url) { [200, {}, response] }
+ end
+end
diff --git a/spec/lib/concierge/suppliers/thh/commands/quote_fetcher_spec.rb b/spec/lib/concierge/suppliers/thh/commands/quote_fetcher_spec.rb
new file mode 100644
index 000000000..30c668a00
--- /dev/null
+++ b/spec/lib/concierge/suppliers/thh/commands/quote_fetcher_spec.rb
@@ -0,0 +1,73 @@
+require 'spec_helper'
+
+RSpec.describe THH::Commands::QuoteFetcher do
+ include Support::Fixtures
+ include Support::HTTPStubbing
+
+ let(:url) { 'http://example.org' }
+ let(:params) do
+ API::Controllers::Params::Quote.new(
+ property_id: '15',
+ check_in: '2016-12-09',
+ check_out: '2016-12-17',
+ )
+ end
+ let(:credentials) { double(key: 'Foo', url: url) }
+
+ subject { described_class.new(credentials) }
+
+ describe '#call' do
+ context 'when remote call internal error happened' do
+ it 'returns result with error' do
+ stub_call(:get, url) { raise Faraday::TimeoutError }
+
+ result = subject.call(params)
+
+ expect(result).not_to be_success
+ expect(result.error.code).to eq :connection_timeout
+ expect(result.error.data).to be_nil
+ end
+ end
+
+ context 'when xml response is correct' do
+ it 'returns raw property' do
+ stub_with_fixture('thh/availability_response.xml')
+
+ result = subject.call(params)
+
+ expect(result).to be_a Result
+ expect(result).to be_success
+ expect(result.value).to be_a Concierge::SafeAccessHash
+ end
+ end
+
+ context 'when xml has unexpected structure' do
+ it 'returns an error if no available field' do
+ stub_with_fixture('thh/no_available_availability_response.xml')
+
+ result = subject.call(params)
+
+ expect(result).to be_a Result
+ expect(result.success?).to be false
+ expect(result.error.code).to eq(:unrecognised_response)
+ expect(result.error.data).to eq('Available response for params `{"property_id"=>"15", "check_in"=>"2016-12-09", "check_out"=>"2016-12-17"}` does not contain `response.available` field')
+ end
+
+ it 'returns an error if no price field' do
+ stub_with_fixture('thh/no_price_availability_response.xml')
+
+ result = subject.call(params)
+
+ expect(result).to be_a Result
+ expect(result.success?).to be false
+ expect(result.error.code).to eq(:unrecognised_response)
+ expect(result.error.data).to eq('Available response for params `{"property_id"=>"15", "check_in"=>"2016-12-09", "check_out"=>"2016-12-17"}` does not contain `response.price` field')
+ end
+ end
+ end
+
+ def stub_with_fixture(name)
+ response = read_fixture(name)
+ stub_call(:get, url) { [200, {}, response] }
+ end
+end
diff --git a/spec/lib/concierge/suppliers/thh/country_code_converter_spec.rb b/spec/lib/concierge/suppliers/thh/country_code_converter_spec.rb
new file mode 100644
index 000000000..9b7581530
--- /dev/null
+++ b/spec/lib/concierge/suppliers/thh/country_code_converter_spec.rb
@@ -0,0 +1,28 @@
+require 'spec_helper'
+
+RSpec.describe THH::CountryCodeConverter do
+
+ subject { described_class.new }
+
+ describe '#code_by_name' do
+
+ it 'returns code for country name input' do
+ code = subject.code_by_name('Hungary')
+ expect(code).to eq('HU')
+ code = subject.code_by_name('Mexico')
+ expect(code).to eq('MX')
+ code = subject.code_by_name('United States of America')
+ expect(code).to eq('US')
+ end
+
+ it 'returns nil for unknown input' do
+ code = subject.code_by_name('dsfe')
+ expect(code).to be_nil
+ end
+
+ it 'returns nil for nil input' do
+ code = subject.code_by_name(nil)
+ expect(code).to be_nil
+ end
+ end
+end
diff --git a/spec/lib/concierge/suppliers/thh/mappers/roomorama_calendar_spec.rb b/spec/lib/concierge/suppliers/thh/mappers/roomorama_calendar_spec.rb
new file mode 100644
index 000000000..62355fae3
--- /dev/null
+++ b/spec/lib/concierge/suppliers/thh/mappers/roomorama_calendar_spec.rb
@@ -0,0 +1,67 @@
+require 'spec_helper'
+
+RSpec.describe THH::Mappers::RoomoramaCalendar do
+ include Support::Fixtures
+
+ let(:property) { parsed_property('thh/properties_response.xml') }
+ let(:property_id) { property['property_id'] }
+
+ subject { described_class.new }
+
+ let(:calendar) { subject.build(property) }
+
+ before do
+ allow(Date).to receive(:today).and_return(Date.new(2016, 12, 10))
+ end
+
+ describe '#build' do
+ it 'returns roomorama calendar' do
+ expect(calendar).to be_a(Roomorama::Calendar)
+ expect { calendar.validate! }.to_not raise_error
+ expect(calendar.identifier).to eq(property_id)
+ end
+
+ it 'returns not empty calendar' do
+ expect(calendar.entries).not_to be_empty
+ end
+
+
+ it 'returns calendar only from synced period' do
+ invalid_entries = calendar.entries.select { |e| e.date < subject.calendar_start || subject.calendar_end < e.date }
+
+ expect(invalid_entries).to be_empty
+ end
+
+ it 'returns reserved days as not available' do
+ entry = calendar.entries.detect { |e| e.date == Date.new(2016, 12, 25) }
+
+ expect(entry.available).to be false
+ end
+
+ it 'allows to arrive on day of departure' do
+ entry = calendar.entries.detect { |e| e.date == Date.new(2016, 12, 26) }
+
+ expect(entry.available).to be true
+ end
+
+ it 'does not allow to arrive on start day of booking period' do
+ entry = calendar.entries.detect { |e| e.date == Date.new(2016, 12, 21) }
+
+ expect(entry.available).to be false
+ end
+
+ it 'returns filled entries' do
+ entry = calendar.entries.detect { |e| e.date == Date.new(2017, 10, 2) }
+
+ expect(entry.nightly_rate).to eq(10068.0)
+ expect(entry.available).to be true
+ expect(entry.minimum_stay).to eq(1)
+ end
+ end
+
+ def parsed_property(name)
+ parser = Nori.new(advanced_typecasting: false)
+ response = parser.parse(read_fixture(name))['response']
+ Concierge::SafeAccessHash.new(response['property'])
+ end
+end
diff --git a/spec/lib/concierge/suppliers/thh/mappers/roomorama_property_spec.rb b/spec/lib/concierge/suppliers/thh/mappers/roomorama_property_spec.rb
new file mode 100644
index 000000000..29b2e9dbb
--- /dev/null
+++ b/spec/lib/concierge/suppliers/thh/mappers/roomorama_property_spec.rb
@@ -0,0 +1,89 @@
+require 'spec_helper'
+
+RSpec.describe THH::Mappers::RoomoramaProperty do
+ include Support::Fixtures
+
+ let(:raw_property) { parsed_property('thh/properties_response.xml') }
+ let(:length) { 365 }
+ let(:description) do
+ "This modern tropical five-bedroom house at Jomtein is the holiday home of your dreams. Picture relaxing in your private swimming pool minutes from the best amenities and facilities Jomtein has to offer. With a hire car inclusive in our attractive rates, you can be at the beach within minutes, be it Jomtein or Pattaya.\n\n"\
+ " Crossing the finely landscaped gardens, and entering this property through a set of French doors, the occupant is greeted by a stylish, modern decor theme, which is repeated throughout the entire villa. The large lounge, which incorporates a spacious dining area, runs the full length of the property. A well-equipped modern kitchen,"\
+ " with black granite worktops, is tucked away in a separate room, with easy access to the dining area. Moving upstairs, three of the four bedrooms seem to vie for the title of “Master Bedroom”, each being elegantly decorated and entirely comfortable, with their own en-suite wet rooms. This is a beautiful holiday property, "\
+ "decorated in a fun and fresh style, a wonderful home away from home in which to enjoy your vacation. Conveniently close to all of the major amenities, in the quite gated community of Viewpoint, just a few minutes from the beach. \n\n"\
+ "The living area is light and spacious with modern furnishings, seating for eight persons with Cable TV, DVD/CD player and separate radio / CD player, please note this player will not accept copy CD’s. The living area opens to a dining facility for six persons.\n\n"\
+ "Fully fitted with granite worktops and tiled floors, appliances include fridge/freezer, microwave, oven, cooker, rice cooker, toaster and all utensils. There are place settings for eight people. A washing machine is available at the covered utility area. Iron and ironing board are also provided.\n\n"\
+ "Bedroom 1 Queen-size bed with ample furniture. Bedroom 2 Queen-size bed with ample furniture. Bedroom 3 Queen-size bed with ample furniture. Bedroom 4 Queen-size bed with ample furniture.\n\n"\
+ "Bedroom 1 offers a full bathroom - bedrooms 2 & 3 have good size shower rooms with toilet and wash hand basins. There is a cloakroom off the living area for your convenience."
+ end
+ let(:amenities) do
+ ['kitchen', 'wifi', 'cabletv', 'parking', 'airconditioning', 'laundry', 'pool', 'balcony', 'outdoor_space', 'gym', 'bed_linen_and_towels']
+ end
+ let(:today) { Date.new(2016, 12, 10) }
+
+ before do
+ allow(Date).to receive(:today).and_return(today)
+ end
+
+ describe '#build' do
+ it 'returns mapped roomorama property' do
+ result = subject.build(raw_property)
+
+ expect(result).to be_a(Result)
+ expect(result).to be_success
+ property = result.value
+ expect(property.identifier).to eq('15')
+ expect(property.default_to_available).to be false
+ expect(property.type).to eq('house')
+ expect(property.subtype).to eq('villa')
+ expect(property.title).to eq('Baan Duan Chai')
+ expect(property.city).to eq('Pattaya')
+ expect(property.description).to eq(description)
+ expect(property.number_of_bedrooms).to eq('5')
+ expect(property.max_guests).to eq('10')
+ expect(property.country_code).to eq('TH')
+ expect(property.lat).to eq('12.884067')
+ expect(property.lng).to eq('100.896267')
+ expect(property.number_of_bathrooms).to eq('5')
+ expect(property.number_of_double_beds).to eq('4')
+ expect(property.number_of_single_beds).to be_nil
+ expect(property.number_of_sofa_beds).to eq('2')
+ expect(property.amenities).to eq(amenities)
+ expect(property.currency).to eq('THB')
+ expect(property.cancellation_policy).to eq('strict')
+
+ expect(property.images.length).to eq(21)
+ image = property.images.first
+ expect(image.identifier).to eq 'db41cdc9d16bd1504daffedbc2652de9'
+ expect(image.url).to eq 'http://img.thailandholidayhomes.com/cache/villa_15_6863-530x354-1.jpg'
+
+ expect(property.minimum_stay).to eq(1)
+ expect(property.nightly_rate).to eq(8510.0)
+ expect(property.weekly_rate).to eq(59570.0)
+ expect(property.monthly_rate).to eq(255300.0)
+
+ expect(property.security_deposit_amount).to eq(10000.0)
+ expect(property.security_deposit_currency_code).to eq('THB')
+ expect(property.security_deposit_type).to eq('cash')
+ end
+
+ context 'when no available days' do
+ let(:raw_property) { parsed_property('thh/properties_without_available_days_response.xml') }
+ let(:today) { Date.new(2016, 11, 14) }
+
+ it 'returns an error' do
+ result = subject.build(raw_property)
+
+ expect(result).to be_a(Result)
+ expect(result.success?).to eq false
+ expect(result.error.code).to eq :no_available_dates
+ expect(result.error.data).to eq 'All available days of the property are booked'
+ end
+ end
+ end
+
+ def parsed_property(name)
+ parser = Nori.new(advanced_typecasting: false)
+ response = parser.parse(read_fixture(name))['response']
+ Concierge::SafeAccessHash.new(response['property'])
+ end
+end
diff --git a/spec/lib/concierge/suppliers/thh/metadata_spec.rb b/spec/lib/concierge/suppliers/thh/metadata_spec.rb
new file mode 100644
index 000000000..0b49e2d46
--- /dev/null
+++ b/spec/lib/concierge/suppliers/thh/metadata_spec.rb
@@ -0,0 +1,122 @@
+require 'spec_helper'
+
+RSpec.describe Workers::Suppliers::THH::Metadata do
+ include Support::Fixtures
+ include Support::Factories
+
+ let(:supplier) { create_supplier(name: THH::Client::SUPPLIER_NAME) }
+ let(:host) { create_host(supplier_id: supplier.id) }
+
+ let(:today) { Date.new(2016, 12, 10) }
+
+ before do
+ allow(Date).to receive(:today).and_return(today)
+ end
+
+ subject { described_class.new(host) }
+
+ context 'there are events from previous syncs in current context' do
+ before do
+ Concierge.context = Concierge::Context.new(type: "batch")
+
+ sync_process = Concierge::Context::SyncProcess.new(
+ worker: "metadata",
+ host_id: "UNRELATED_HOST",
+ identifier: "UNRELATED_PROPERTY"
+ )
+ Concierge.context.augment(sync_process)
+ allow_any_instance_of(THH::Importer).to receive(:fetch_properties) { Result.error(:error) }
+ end
+
+ it 'announces an error without any unrelated context' do
+ subject.perform
+ error = ExternalErrorRepository.last
+ expect(error.context.get("events").to_s).to_not include("UNRELATED_PROPERTY")
+ end
+ end
+
+ it 'announces an error if fetching properties fails' do
+ allow_any_instance_of(THH::Importer).to receive(:fetch_properties) { Result.error(:error, 'Some error') }
+
+ subject.perform
+
+ error = ExternalErrorRepository.last
+
+ expect(error.operation).to eq 'sync'
+ expect(error.supplier).to eq THH::Client::SUPPLIER_NAME
+ expect(error.code).to eq 'error'
+ expect(error.description).to eq 'Some error'
+ end
+
+ it 'saves sync process even if error occurs before start call' do
+ allow_any_instance_of(THH::Importer).to receive(:fetch_properties) { Result.error(:error, 'Some error') }
+
+ expect(subject.synchronisation).to receive(:skip_purge!).once.and_call_original
+ subject.perform
+
+ sync = SyncProcessRepository.last
+
+ expect(sync).not_to be_nil
+ expect(sync.successful).to be false
+ expect(sync.host_id).to eq(host.id)
+ end
+
+ context 'success' do
+ let(:properties) { read_properties('thh/properties_response.xml') }
+
+ before do
+ allow_any_instance_of(THH::Importer).to receive(:fetch_properties) { Result.new(properties) }
+ end
+
+ it 'finalizes synchronisation' do
+ allow_any_instance_of(Roomorama::Client).to receive(:perform) { Result.new('success') }
+
+ expect(subject.synchronisation).to receive(:finish!)
+ subject.perform
+ end
+
+ it 'doesnt create property with unsuccessful publishing' do
+ allow_any_instance_of(Roomorama::Client).to receive(:perform) { Result.error('fail') }
+ expect {
+ subject.perform
+ }.to_not change { PropertyRepository.count }
+ end
+
+ it 'does not create invalid properties in database' do
+ allow_any_instance_of(Roomorama::Client).to receive(:perform) { Result.new('success') }
+ allow_any_instance_of(THH::Validators::PropertyValidator).to receive(:valid?) { false }
+
+ expect(subject.synchronisation).to receive(:skip_property).once.and_call_original
+ expect {
+ subject.perform
+ }.to_not change { PropertyRepository.count }
+ end
+
+ it 'creates valid properties in database' do
+ allow_any_instance_of(Roomorama::Client).to receive(:perform) { Result.new('success') }
+ expect {
+ subject.perform
+ }.to change { PropertyRepository.count }.by(1)
+ end
+
+ described_class::SKIPPABLE_ERROR_CODES.each do |error_code|
+ it "skips property if mapper returns skipable error #{error_code}" do
+ allow_any_instance_of(THH::Mappers::RoomoramaProperty).to receive(:build) { Result.error(error_code, 'Description') }
+
+ subject.perform
+
+ sync = SyncProcessRepository.last
+ expect(sync.successful).to eq true
+ expect(sync.skipped_properties_count).to eq 1
+ expect(sync.stats[:properties_skipped].length).to eq 1
+ expect(sync.stats[:properties_skipped][0]['ids']).to eq ['15']
+ end
+ end
+ end
+
+ def read_properties(name)
+ parser = Nori.new(advanced_typecasting: false)
+ response = parser.parse(read_fixture(name))['response']
+ Array(Concierge::SafeAccessHash.new(response['property']))
+ end
+end
diff --git a/spec/lib/concierge/suppliers/thh/price_spec.rb b/spec/lib/concierge/suppliers/thh/price_spec.rb
new file mode 100644
index 000000000..32c958ec7
--- /dev/null
+++ b/spec/lib/concierge/suppliers/thh/price_spec.rb
@@ -0,0 +1,95 @@
+require "spec_helper"
+
+RSpec.describe THH::Price do
+ include Support::Fixtures
+ include Support::Factories
+
+ let!(:supplier) { create_supplier(name: THH::Client::SUPPLIER_NAME) }
+ let!(:host) { create_host(supplier_id: supplier.id, fee_percentage: 5) }
+ let!(:property) do
+ create_property(
+ identifier: '15',
+ host_id: host.id,
+ data: { max_guests: 3 }
+ )
+ end
+ let(:credentials) { double(key: 'Foo', url: 'http://example.org') }
+ let(:params) {
+ { property_id: '15', check_in: '2016-12-17', check_out: '2016-12-26', guests: 3 }
+ }
+ let(:quote_response) do
+ Concierge::SafeAccessHash.new(
+ {
+ available: 'yes',
+ price: '48,000'
+ }
+ )
+ end
+
+ let(:unavailable_quote_response) do
+ Concierge::SafeAccessHash.new(
+ {
+ available: 'no',
+ price: '48,000'
+ }
+ )
+ end
+
+ subject { described_class.new(credentials) }
+
+ describe '#quote' do
+ it 'returns an error if fetcher fails' do
+ params[:guests] = 4
+ result = subject.quote(params)
+
+ expect(result).not_to be_success
+ expect(result.error.code).to eq :max_guests_exceeded
+ expect(result.error.data).to eq 'The maximum number of guests to book this apartment is 3'
+ end
+
+ it 'returns an error if guest exceeds max guests of property' do
+ allow_any_instance_of(THH::Commands::QuoteFetcher).to receive(:call) { Result.error(:error, 'Some error') }
+ result = subject.quote(params)
+
+ expect(result).not_to be_success
+ expect(result.error.code).to eq :error
+ expect(result.error.data).to eq 'Some error'
+ end
+
+ it 'returns an unavailable quotation' do
+ allow_any_instance_of(THH::Commands::QuoteFetcher).to receive(:call) { Result.new(unavailable_quote_response) }
+ result = subject.quote(params)
+
+ expect(result).to be_success
+ quotation = result.value
+
+ expect(quotation).to be_a Quotation
+ expect(quotation.available).to eq false
+ expect(quotation.property_id).to eq '15'
+ expect(quotation.check_in).to eq '2016-12-17'
+ expect(quotation.check_out).to eq '2016-12-26'
+ expect(quotation.guests).to eq 3
+ expect(quotation.currency).to be_nil
+ expect(quotation.total).to be_nil
+ end
+
+ it 'returns an available quotation properly priced according to the response' do
+ allow_any_instance_of(THH::Commands::QuoteFetcher).to receive(:call) { Result.new(quote_response) }
+
+ result = subject.quote(params)
+
+ expect(result).to be_success
+ quotation = result.value
+
+ expect(quotation).to be_a Quotation
+ expect(quotation.available).to eq true
+ expect(quotation.property_id).to eq '15'
+ expect(quotation.check_in).to eq '2016-12-17'
+ expect(quotation.check_out).to eq '2016-12-26'
+ expect(quotation.guests).to eq 3
+ expect(quotation.currency).to eq 'THB'
+ expect(quotation.host_fee_percentage).to eq 5
+ expect(quotation.total).to eq 48000.0 # rental + mandatory services
+ end
+ end
+end
diff --git a/spec/lib/concierge/suppliers/thh/validators/property_validator_spec.rb b/spec/lib/concierge/suppliers/thh/validators/property_validator_spec.rb
new file mode 100644
index 000000000..e6dc97f2d
--- /dev/null
+++ b/spec/lib/concierge/suppliers/thh/validators/property_validator_spec.rb
@@ -0,0 +1,19 @@
+require 'spec_helper'
+
+RSpec.describe THH::Validators::PropertyValidator do
+
+ let(:invalid_property) { { 'instant_confirmation' => 'false' } }
+ let(:valid_property) { { 'instant_confirmation' => 'true' } }
+
+ describe '#valid?' do
+ it 'returns true for valid property' do
+ validator = described_class.new(valid_property)
+ expect(validator.valid?).to be_truthy
+ end
+
+ it 'returns false for invalid cases' do
+ validator = described_class.new(invalid_property)
+ expect(validator.valid?).to be_falsey
+ end
+ end
+end