diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8925f0e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,73 @@ +name: CI + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + ruby-version: ['3.0', '3.1', '3.2', '3.3'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby-version }} + bundler-cache: true + + - name: Install dependencies + run: bundle install + + - name: Run tests with coverage + run: bundle exec rake test + + - name: Upload coverage reports to Codecov + if: matrix.ruby-version == '3.3' + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage/lcov.info + fail_ci_if_error: true + + - name: Run RuboCop + run: bundle exec rubocop + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + bundler-cache: true + + - name: Install dependencies + run: bundle install + + - name: Run RuboCop + run: bundle exec rubocop --parallel + + security: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + bundler-cache: true + + - name: Run bundle audit + run: | + gem install bundler-audit + bundle audit --update diff --git a/.gitignore b/.gitignore index ae97099..7ec8274 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ Gemfile.lock pkg/* *.sw* .open_table +coverage/ diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..36bff1e --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,61 @@ +plugins: + - rubocop-minitest + - rubocop-rake + +AllCops: + TargetRubyVersion: 3.0 + NewCops: enable + Exclude: + - 'vendor/**/*' + - 'tmp/**/*' + - 'bin/**/*' + +# Prefer double quotes for consistency +Style/StringLiterals: + EnforcedStyle: single_quotes + +# Allow longer line lengths for readability +Layout/LineLength: + Max: 120 + +# Documentation is helpful but not required for every class +Style/Documentation: + Enabled: false + +# Allow both compact and expanded module/class nesting +Style/ClassAndModuleChildren: + Enabled: false + +# Prefer modern Hash syntax +Style/HashSyntax: + EnforcedStyle: ruby19 + +# Allow empty methods without body +Style/EmptyMethod: + EnforcedStyle: expanded + +# Metrics +Metrics/MethodLength: + Max: 20 + Exclude: + - 'test/**/*' + +Metrics/ClassLength: + Max: 100 + Exclude: + - 'test/**/*' + +Metrics/BlockLength: + Exclude: + - 'test/**/*' + - '*.gemspec' + - 'Rakefile' + +Metrics/AbcSize: + Max: 20 + Exclude: + - 'test/**/*' + +# Minitest +Minitest/MultipleAssertions: + Max: 15 diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..15a2799 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +3.3.0 diff --git a/.rvmrc b/.rvmrc deleted file mode 100644 index 665d463..0000000 --- a/.rvmrc +++ /dev/null @@ -1 +0,0 @@ -rvm ruby-1.9.3-p385@hungrytable --create diff --git a/.simplecov b/.simplecov new file mode 100644 index 0000000..ee9d0ba --- /dev/null +++ b/.simplecov @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +SimpleCov.configure do + add_filter '/test/' + add_filter '/spec/' + add_filter '/.bundle/' + add_filter '/vendor/' + + minimum_coverage 90 + + # Track all files in lib/ + track_files 'lib/**/*.rb' +end diff --git a/Gemfile b/Gemfile index 6d5e8d9..b8038c9 100644 --- a/Gemfile +++ b/Gemfile @@ -1,5 +1,22 @@ -source "http://rubygems.org" +# frozen_string_literal: true + +source 'https://rubygems.org' # Specify your gem's dependencies in hungrytable.gemspec gemspec +group :development, :test do + gem 'guard', '~> 2.18' + gem 'guard-minitest', '~> 2.4' + gem 'minitest', '~> 5.20' + gem 'minitest-reporters', '~> 1.6' + gem 'mocha', '~> 2.1' + gem 'pry', '~> 0.14' + gem 'rake', '~> 13.0' + gem 'rubocop', '~> 1.60' + gem 'rubocop-minitest', '~> 0.35' + gem 'rubocop-rake', '~> 0.6' + gem 'simplecov', '~> 0.22.0', require: false + gem 'simplecov-lcov', require: false + gem 'webmock', '~> 3.20' +end diff --git a/Guardfile b/Guardfile index ed92062..5341e16 100644 --- a/Guardfile +++ b/Guardfile @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # A sample Guardfile # More info at https://github.com/guard/guard#readme @@ -5,9 +7,9 @@ notification :libnotify guard 'minitest' do # with Minitest::Unit - watch(%r|^test/(.*)_test\.rb|) - watch(%r|^lib/(.*)/([^/]+)\.rb|) { |m| "test/unit/#{m[1]}/#{m[2]}_test.rb" } - watch(%r|^test/test_helper\.rb|) { "test" } + watch(%r{^test/(.*)_test\.rb}) + watch(%r{^lib/(.*)/([^/]+)\.rb}) { |m| "test/unit/#{m[1]}/#{m[2]}_test.rb" } + watch(%r{^test/test_helper\.rb}) { 'test' } # with Minitest::Spec # watch(%r|^spec/(.*)_spec\.rb|) diff --git a/README.md b/README.md index 92a99b2..d3b1975 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,331 @@ # Hungrytable -The purpose of this gem is to interact with the [OpenTable](http://www.opentable.com) REST API. +[![CI](https://github.com/dchapman1988/hungrytable/actions/workflows/ci.yml/badge.svg)](https://github.com/dchapman1988/hungrytable/actions/workflows/ci.yml) +[![codecov](https://codecov.io/gh/dchapman1988/hungrytable/graph/badge.svg)](https://codecov.io/gh/dchapman1988/hungrytable) +[![Ruby Version](https://img.shields.io/badge/ruby-3.0%2B-red.svg)](https://www.ruby-lang.org/) +[![Gem Version](https://badge.fury.io/rb/hungrytable.svg)](https://badge.fury.io/rb/hungrytable) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +Ruby client for the OpenTable REST API. Supports restaurant search, availability lookup, reservations, and booking management. + +## Features + +- Fetch restaurant details +- Search available reservation times +- Lock time slots before booking +- Create and cancel reservations +- Check reservation status +- Requires Ruby 3.0+ +- No `method_missing` - all methods explicitly defined +- Error classes for different failure types ## Installation Add this line to your application's Gemfile: - gem 'hungrytable' +```ruby +gem 'hungrytable' +``` And then execute: - $ bundle +```bash +$ bundle install +``` Or install it yourself as: - $ gem install hungrytable +```bash +$ gem install hungrytable +``` + +## Configuration + +### Environment Variables + +Set the following environment variables (recommended for production): + +```bash +export OT_PARTNER_ID= +export OT_OAUTH_KEY= +export OT_OAUTH_SECRET= +``` + +### Programmatic Configuration + +Alternatively, configure programmatically in your code: + +```ruby +Hungrytable.configure do |config| + config.partner_id = 'your_partner_id' + config.oauth_key = 'your_oauth_key' + config.oauth_secret = 'your_oauth_secret' +end +``` ## Usage -You need to set some environment variable for this gem to work properly: +### 1. Get Restaurant Details + +```ruby +restaurant = Hungrytable::Restaurant.new(82591) + +if restaurant.valid? + puts restaurant.restaurant_name + puts restaurant.address + puts restaurant.phone + puts restaurant.primary_food_type + puts restaurant.price_range +else + puts "Error: #{restaurant.error_message}" +end +``` + +Available restaurant attributes: +- `restaurant_name`, `restaurant_ID` +- `address`, `city`, `state`, `postal_code` +- `phone`, `url` +- `latitude`, `longitude` +- `neighborhood_name`, `metro_name` +- `primary_food_type`, `price_range` +- `restaurant_description` +- `parking`, `parking_details` +- `image_link` + +### 2. Search for Availability + +```ruby +restaurant = Hungrytable::Restaurant.new(82591) + +# Search for a table for 4 people, 5 days from now at 7pm +search_time = 5.days.from_now.change(hour: 19, min: 0) +search = Hungrytable::RestaurantSearch.new( + restaurant, + date_time: search_time, + party_size: 4 +) + +if search.valid? + puts "Exact time: #{search.exact_time}" if search.exact_time + puts "Early time: #{search.early_time}" if search.early_time + puts "Later time: #{search.later_time}" if search.later_time + puts "Best time: #{search.ideal_time}" +else + puts "No availability: #{search.error_message}" +end +``` + +### 3. Lock a Time Slot + +```ruby +slotlock = Hungrytable::RestaurantSlotlock.new(search) + +if slotlock.successful? + puts "Slot locked! ID: #{slotlock.slotlock_id}" +else + puts "Failed to lock slot: #{slotlock.errors}" +end +``` + +### 4. Make a Reservation + +```ruby +reservation = Hungrytable::ReservationMake.new( + slotlock, + email_address: 'john.doe@example.com', + firstname: 'John', + lastname: 'Doe', + phone: '5551234567', + specialinstructions: 'Window seat please' +) + +if reservation.successful? + puts "Reservation confirmed! Number: #{reservation.confirmation_number}" +else + puts "Reservation failed: #{reservation.error_message}" +end +``` + +### 5. Check Reservation Status + +```ruby +status = Hungrytable::ReservationStatus.new( + restaurant_id: 82591, + confirmation_number: 'ABC123XYZ' +) + +if status.successful? + puts "Status: #{status.status}" + details = status.reservation_details + puts "Restaurant: #{details[:restaurant_name]}" + puts "Date/Time: #{details[:date_time]}" + puts "Party Size: #{details[:party_size]}" +end +``` + +### 6. Cancel a Reservation + +```ruby +cancel = Hungrytable::ReservationCancel.new( + email_address: 'john.doe@example.com', + confirmation_number: 'ABC123XYZ', + restaurant_id: 82591 +) -Observe: +if cancel.successful? + puts "Reservation cancelled successfully" +else + puts "Cancellation failed: #{cancel.error_message}" +end +``` - # In ~/.bashrc - export OT_PARTNER_ID= - export OT_PARTNER_AUTH= # For XML feed only... - export OT_OAUTH_KEY= - export OT_OAUTH_SECRET= +### Complete Example: Full Reservation Flow -## Example Run +```ruby +require 'hungrytable' - $ restaurant = Hungrytable::Restaurant.new(82591) +# Configure +Hungrytable.configure do |config| + config.partner_id = ENV['OT_PARTNER_ID'] + config.oauth_key = ENV['OT_OAUTH_KEY'] + config.oauth_secret = ENV['OT_OAUTH_SECRET'] +end - > # +# 1. Get restaurant details +restaurant = Hungrytable::Restaurant.new(82591) +raise "Restaurant not found" unless restaurant.valid? - $ search = Hungrytable::RestaurantSearch.new(restaurant, {date_time: 5.days.from_now, party_size: 3}) +puts "Booking at: #{restaurant.restaurant_name}" - > # +# 2. Search for availability +search_time = 3.days.from_now.change(hour: 19, min: 0) +search = Hungrytable::RestaurantSearch.new( + restaurant, + date_time: search_time, + party_size: 2 +) - $ slotlock = Hungrytable::RestaurantSlotlock.new(search) +raise "No availability" unless search.valid? - > # +puts "Found time slot: #{search.ideal_time}" - $ reservation = Hungrytable::ReservationMake.new(slotlock, {email_address: 'foo@bar.com', firstname: 'Mike', lastname: 'Jones', phone: '2813308004'}) +# 3. Lock the slot +slotlock = Hungrytable::RestaurantSlotlock.new(search) +raise "Could not lock slot: #{slotlock.errors}" unless slotlock.successful? + +# 4. Make the reservation +reservation = Hungrytable::ReservationMake.new( + slotlock, + email_address: 'diner@example.com', + firstname: 'Jane', + lastname: 'Smith', + phone: '5551234567' +) + +if reservation.successful? + puts "Success! Confirmation: #{reservation.confirmation_number}" +else + puts "Failed: #{reservation.error_message}" +end +``` + +## Error Handling + +Available error classes: + +```ruby +begin + restaurant = Hungrytable::Restaurant.new(invalid_id) +rescue Hungrytable::ConfigurationError => e + puts "Configuration error: #{e.message}" +rescue Hungrytable::HTTPError => e + puts "HTTP error: #{e.message}" +rescue Hungrytable::APIError => e + puts "API error #{e.error_code}: #{e.error_message}" +rescue Hungrytable::MissingRequiredFieldError => e + puts "Missing required field: #{e.message}" +end +``` + +Error hierarchy: +- `Hungrytable::Error` (base class) + - `ConfigurationError` - Missing or invalid configuration + - `HTTPError` - HTTP-level errors + - `NotFoundError` - 404 errors + - `UnauthorizedError` - 401 authentication errors + - `ServerError` - 5xx server errors + - `APIError` - OpenTable API errors + - `ValidationError` - Input validation errors + - `MissingRequiredFieldError` - Missing required parameters + +## Development + +After checking out the repo, run `bundle install` to install dependencies. Then, run `rake test` to run the tests. + +### Running Tests + +```bash +bundle exec rake test +``` + +### Running RuboCop + +```bash +bundle exec rubocop +``` + +### Auto-fixing RuboCop Issues + +```bash +bundle exec rubocop -A +``` + +## Requirements + +- Ruby >= 3.0.0 +- OpenTable Partner API credentials + +## Dependencies + +Runtime: +- `activesupport` (>= 6.0) +- `http` (~> 5.0) +- `oauth` (~> 1.1) + +Development: +- `minitest` (~> 5.20) - Testing framework +- `webmock` (~> 3.20) - HTTP mocking for tests +- `rubocop` (~> 1.60) - Code quality and style +- `mocha` (~> 2.1) - Mocking and stubbing ## Contributing -1. Fork it +1. Fork it (https://github.com/dchapman1988/hungrytable/fork) 2. Create your feature branch (`git checkout -b my-new-feature`) -3. Commit your changes (`git commit -am 'Added some feature'`) +3. Commit your changes (`git commit -am 'Add some feature'`) 4. Push to the branch (`git push origin my-new-feature`) -5. Create new Pull Request +5. Create a new Pull Request + +Please ensure: +- All tests pass (`bundle exec rake test`) +- RuboCop is clean (`bundle exec rubocop`) +- New features have tests +- Code follows existing style + +## License + +The gem is available as open source under the terms of the [MIT License](LICENSE). + +## Maintainers + +**Active:** +- David Chapman ([@dchapman1988](https://github.com/dchapman1988)) + +**Inactive:** +- Nicholas Fine ([@yrgoldteeth](https://github.com/yrgoldteeth)) +- Ryan T. Hosford ([@rthbound](https://github.com/rthbound)) + +## Changelog + +See [RELEASE_NOTES.md](RELEASE_NOTES.md) for version history. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 60efda8..661faaf 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,21 @@ # Hungrytable - Ruby API Client for the OpenTable REST API +## v1.0.0 (2025-01-XX) + +### Breaking Changes +- Requires Ruby 3.0+ +- Replaced `curb` with `http` gem +- Replaced `method_missing` with explicit method definitions + +### Changes +- Fixed deprecated `URI.encode`/`URI.decode` → `CGI.escape`/`CGI.unescape` +- Added `oauth` gem for OAuth 1.0 +- RuboCop clean, Ruby 3.0+ code style +- Added 152 tests +- Added GitHub Actions CI + +--- + ## v0.0.1 (05/11/2012) * Get relevant details about an individual restaurant. diff --git a/Rakefile b/Rakefile index 5153ade..faabe99 100644 --- a/Rakefile +++ b/Rakefile @@ -1,4 +1,6 @@ -require "bundler/gem_tasks" +# frozen_string_literal: true + +require 'bundler/gem_tasks' require 'rake/testtask' Rake::TestTask.new do |t| diff --git a/hungrytable.gemspec b/hungrytable.gemspec index a9a0641..aef71a0 100644 --- a/hungrytable.gemspec +++ b/hungrytable.gemspec @@ -1,37 +1,33 @@ -# -*- encoding: utf-8 -*- -$:.push File.expand_path("../lib", __FILE__) -require "hungrytable/version" +# frozen_string_literal: true + +require_relative 'lib/hungrytable/version' Gem::Specification.new do |s| - s.name = "hungrytable" + s.name = 'hungrytable' s.version = Hungrytable::VERSION - s.authors = ["David Chapman", "Nicholas Fine"] - s.email = ["david@isotope11.com", 'nicholas.fine@gmail.com'] - s.homepage = "http://www.isotope11.com/" - s.summary = %q{Gem to interact with OpenTable's REST API} - s.description = %q{Gem to interact with OpenTable's REST API} - - s.rubyforge_project = "hungrytable" + s.authors = ['David Chapman'] + s.email = ['dchapman1988@gmail.com'] + s.homepage = 'https://github.com/dchapman1988/hungrytable' + s.summary = 'Ruby client for the OpenTable REST API' + s.description = 'A Ruby gem providing a clean, object-oriented interface to interact with the OpenTable REST API ' \ + 'for restaurant reservations' + s.license = 'MIT' - s.files = `git ls-files`.split("\n") - s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") - s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } - s.require_paths = ["lib"] + s.required_ruby_version = '>= 3.0.0' + s.metadata = { + 'bug_tracker_uri' => 'https://github.com/dchapman1988/hungrytable/issues', + 'changelog_uri' => 'https://github.com/dchapman1988/hungrytable/blob/master/RELEASE_NOTES.md', + 'source_code_uri' => 'https://github.com/dchapman1988/hungrytable', + 'rubygems_mfa_required' => 'true' + } - # specify any dependencies here; for example: - # s.add_development_dependency "rspec" - # s.add_runtime_dependency "rest-client" + s.files = Dir['lib/**/*', 'LICENSE', 'README.md', 'RELEASE_NOTES.md'] + s.require_paths = ['lib'] - s.add_runtime_dependency 'json', '~> 1.7.1' - s.add_runtime_dependency 'activesupport' - s.add_runtime_dependency 'i18n' - s.add_runtime_dependency 'curb' + # Runtime dependencies - modernized versions + s.add_dependency 'activesupport', '>= 6.0', '< 8.0' + s.add_dependency 'http', '~> 5.0' # Modern HTTP client replacing curb + s.add_dependency 'oauth', '~> 1.1' # Dedicated OAuth library - s.add_development_dependency 'rake' - s.add_development_dependency 'minitest' - s.add_development_dependency 'minitest-reporters' - s.add_development_dependency 'mocha' - s.add_development_dependency 'pry' - s.add_development_dependency 'webmock' + # Development dependencies are specified in Gemfile end - diff --git a/lib/hungrytable.rb b/lib/hungrytable.rb index 815e23c..29bb4a9 100644 --- a/lib/hungrytable.rb +++ b/lib/hungrytable.rb @@ -1,73 +1,118 @@ +# frozen_string_literal: true + require 'uri' require 'json' -require 'openssl' -require 'base64' require 'cgi' -require 'curb' -require 'active_support/core_ext' -require 'hungrytable/version' -require 'hungrytable/config' -require 'hungrytable/request_extensions' -require 'hungrytable/request_header' -require 'hungrytable/request' -require 'hungrytable/get_request' -require 'hungrytable/post_request' -require 'hungrytable/restaurant' -require 'hungrytable/restaurant_search' -require 'hungrytable/restaurant_slotlock' -require 'hungrytable/reservation_make' -require 'hungrytable/reservation_status' -require 'hungrytable/reservation_cancel' +require 'http' +require 'oauth' +require 'active_support' +require 'active_support/core_ext/string/inflections' +require 'active_support/concern' + +require_relative 'hungrytable/version' +require_relative 'hungrytable/errors' +require_relative 'hungrytable/config' +require_relative 'hungrytable/request_extensions' +require_relative 'hungrytable/request_header' +require_relative 'hungrytable/request' +require_relative 'hungrytable/get_request' +require_relative 'hungrytable/post_request' +require_relative 'hungrytable/restaurant' +require_relative 'hungrytable/restaurant_search' +require_relative 'hungrytable/restaurant_slotlock' +require_relative 'hungrytable/reservation_make' +require_relative 'hungrytable/reservation_status' +require_relative 'hungrytable/reservation_cancel' module Hungrytable - class HungrytableError < StandardError;end + # OpenTable API error codes + # @return [Hash] mapping of error codes to error details + ERROR_CODES = { + 152 => { message: 'The email address is not valid. Please try again.', name: 'VALIDEMAIL' }, + 160 => { + message: "We're sorry but we were unable to complete you request. Our technical team has been notified " \ + 'of the problem and will resolve it shortly. Thank you.', + name: 'GENERALERROR' + }, + 197 => { message: 'You must select at least one restaurant to search.', name: 'PROVIDERESTAURANT' }, + 202 => { + message: 'The time you selected has already passed. You may wish to check that your computer clock ' \ + 'is correct.', + name: 'PASSEDTIME' + }, + 274 => { message: 'Your phone number must be numeric.', name: 'NUMERICPHONE' }, + 279 => { message: 'Your IP address is not listed as an OpenTable Partner IP.', name: 'INVALIDIP' }, + 280 => { message: 'Key authentication failure', name: 'AUTHENTICATEFAIL' }, + 281 => { message: 'The phone length for the number provided was not a valid length for the country code.', + name: 'INVALIDPHONELENGTH' }, + 282 => { + message: 'We are currently unable to connect to the restaurant to complete this action. ' \ + 'Please try again later.', + name: 'ERBERROR' + }, + 283 => { message: 'The time you have chosen for your reservation is no longer available.', + name: 'RESERVATIONNOTAVAIL' }, + 285 => { message: 'Credit Card transactions are not allowed via OT Web Services.', name: 'CCNOTALLOWED' }, + 286 => { message: 'Large parties are not allowed via OT Web Services.', name: 'LARGEPARTYNOTALLOWED' }, + 287 => { message: 'The restaurant is currently offline.', name: 'RESTOFFLINE' }, + 288 => { message: 'The restaurant is currently unreachable.', name: 'RESTUNREACHABLE' }, + 289 => { message: 'Cancel transaction failed.', name: 'CANCELFAIL' }, + 294 => { message: 'You have not provided enough information to perform the search.', + name: 'INSUFFICIENTINFORMATION' }, + 295 => { message: 'No restaurants were found in your search. Please try again.', name: 'NORESTAURANTSRETURNED' }, + 296 => { message: 'Your search produced no times.', name: 'NOTIMESMESSAGE' }, + 298 => { message: 'The confirmation number is invalid.', name: 'VALIDCONFIRMNUMBER' }, + 299 => { message: 'The reservation status is not available on reservations older than 30 days.', + name: 'VALIDSTATUSDATE' }, + 301 => { + message: 'The user for the reservation request already has a reservation within 2 hours ' \ + 'of the requested time.', + name: 'VALIDDUPRESERVATION' + }, + 302 => { message: 'No Reservation Activity found.', name: 'NORESOHISTORYAVAILABLE' }, + 303 => { message: 'Invalid User Email. Please re-enter your UserEmail.', name: 'INVALIDUSEREMAIL' }, + 304 => { message: 'Invalid User Password. Please re-enter your password.', name: 'INVALIDUSERPASSWORD' }, + 305 => { message: 'Unable to authenticate user to login. Please try again.', name: 'USERAUTHENTICATIONFAIL' }, + 306 => { message: 'The entered area to search is too large please limit your search to 20 miles.', + name: 'SEARCHAREATOOLARGE' }, + 307 => { message: 'The email address(es) you have provided are not in the correct format. Please try again.', + name: 'INVALIDEMAILADDRESSES' }, + 308 => { + message: 'Your reservation has already been cancelled, please hit the Reload button on your profile ' \ + 'to refresh your page', + name: 'ALREADYCANCELLED' + }, + 309 => { message: 'You must use SSL for this request.', name: 'SSLCONNECTIONREQUIRED' }, + 310 => { message: 'The points requested for this time slot are not available.', name: 'INVALIDPOINTREQUEST' }, + 311 => { message: 'The user account is deactivated.', name: 'ACCTDEACTIVATED' }, + 313 => { message: 'The requested Restaurant was not found.', name: 'INVALIDRESTAURANTID' }, + 314 => { message: 'We already have an account registered to ', name: 'MATCHACCOUNT' }, + 315 => { message: 'A default metro must be provided for a use', name: 'PROVIDEMETRO' }, + 316 => { message: 'The password must be a minimum of 6 characters.', name: 'MALFORMEDPASSWORD' }, + 317 => { message: 'The phone length for the number provided was not a valid length for the country code.', + name: 'INVALIDPHONE' }, + 318 => { message: 'Please enter a valid country id for the phone number.', name: 'PROVIDEPHONECOUNTRY' }, + 319 => { message: 'Please enter a valid country id for the mobile phone number.', + name: 'PROVIDEMOBILEPHONECOUNTRY' }, + 321 => { + message: "We're sorry, but we could not complete your reservation request because an account cannot " \ + 'have more than two confirmed reservations into the same restaurant for the same day.', + name: 'VALIDTOOMANYSAMEREST' + }, + 322 => { message: 'The time slot for this reservation is no longer available.', name: 'SLOTLOCKUNAVAILABLE' }, + 323 => { message: 'The service you are trying to access is currently unavailable, please try again later.', + name: 'SERVICEUNAVAILABLE' }, + 324 => { message: 'The service experienced a timeout. Please try again later.', name: 'SERVICETIMEOUT' }, + 325 => { message: 'Invalid number of lookback days, the most you can look back is 8 days.', + name: 'INVALIDLOOKBACKDAYS' }, + 326 => { message: 'This service is currently disabled. Please try again later.', name: 'CURRENTLYDISABLED' } + }.freeze - def error_codes - { - 152 => { message: 'The email address is not valid. Please try again.', name: 'VALIDEMAIL' }, - 160 => { message: "We're sorry but we were unable to complete you request. Our technical team has been notified of the problem and will resolve it shortly. Thank you.", name: "GENERALERROR" }, - 197 => { message: "You must select at least one restaurant to search.", name: "PROVIDERESTAURANT" }, - 202 => { message: "The time you selected has already passed. You may wish to check that your computer clock is correct.", name: "PASSEDTIME" }, - 274 => { message: "Your phone number must be numeric.", name: "NUMERICPHONE" }, - 279 => { message: "Your IP address is not listed as an OpenTable Partner IP.", name: "INVALIDIP" }, - 280 => { message: "Key authentication failure", name: "AUTHENTICATEFAIL" }, - 281 => { message: "The phone length for the number provided was not a valid length for the country code.", name: "INVALIDPHONELENGTH" }, - 282 => { message: "We are currently unable to connect to the restaurant to complete this action. Please try again later.", name: "ERBERROR" }, - 283 => { message: "The time you have chosen for your reservation is no longer available.", name: "RESERVATIONNOTAVAIL" }, - 285 => { message: "Credit Card transactions are not allowed via OT Web Services.", name: "CCNOTALLOWED" }, - 286 => { message: "Large parties are not allowed via OT Web Services.", name: "LARGEPARTYNOTALLOWED" }, - 287 => { message: "The restaurant is currently offline.", name: "RESTOFFLINE" }, - 288 => { message: "The restaurant is currently unreachable.", name: "RESTUNREACHABLE" }, - 289 => { message: "Cancel transaction failed.", name: "CANCELFAIL" }, - 294 => { message: "You have not provided enough information to perform the search.", name: "INSUFFICIENTINFORMATION" }, - 295 => { message: "No restaurants were found in your search. Please try again.", name: "NORESTAURANTSRETURNED" }, - 296 => { message: "Your search produced no times.", name: "NOTIMESMESSAGE" }, - 298 => { message: "The confirmation number is invalid.", name: "VALIDCONFIRMNUMBER" }, - 299 => { message: "The reservation status is not available on reservations older than 30 days.", name: "VALIDSTATUSDATE" }, - 301 => { message: "The user for the reservation request already has a reservation within 2 hours of the requested time.", name: "VALIDDUPRESERVATION" }, - 302 => { message: "No Reservation Activity found.", name: "NORESOHISTORYAVAILABLE" }, - 303 => { message: "Invalid User Email. Please re-enter your UserEmail.", name: "INVALIDUSEREMAIL" }, - 304 => { message: "Invalid User Password. Please re-enter your password.", name: "INVALIDUSERPASSWORD" }, - 305 => { message: "Unable to authenticate user to login. Please try again.", name: "USERAUTHENTICATIONFAIL" }, - 306 => { message: "The entered area to search is too large please limit your search to 20 miles.", name: "SEARCHAREATOOLARGE" }, - 307 => { message: "The email address(es) you have provided are not in the correct format. Please try again.", name: "INVALIDEMAILADDRESSES" }, - 308 => { message: "Your reservation has already been cancelled, please hit the Reload button on your profile to refresh your page", name: "ALREADYCANCELLED" }, - 309 => { message: "You must use SSL for this request.", name: "SSLCONNECTIONREQUIRED" }, - 310 => { message: "The points requested for this time slot are not available.", name: "INVALIDPOINTREQUEST" }, - 311 => { message: "The user account is deactivated.", name: "ACCTDEACTIVATED" }, - 313 => { message: "The requested Restaurant was not found.", name: "INVALIDRESTAURANTID" }, - 314 => { message: "We already have an account registered to ", name: "MATCHACCOUNT" }, - 315 => { message: "A default metro must be provided for a use", name: "PROVIDEMETRO" }, - 316 => { message: "The password must be a minimum of 6 characters.", name: "MALFORMEDPASSWORD" }, - 317 => { message: "The phone length for the number provided was not a valid length for the country code.", name: "INVALIDPHONE" }, - 318 => { message: "Please enter a valid country id for the phone number.", name: "PROVIDEPHONECOUNTRY" }, - 319 => { message: "Please enter a valid country id for the mobile phone number.", name: "PROVIDEMOBILEPHONECOUNTRY" }, - 321 => { message: "We're sorry, but we could not complete your reservation request because an account cannot have more than two confirmed reservations into the same restaurant for the same day.", name: "VALIDTOOMANYSAMEREST" }, - 322 => { message: "The time slot for this reservation is no longer available.", name: "SLOTLOCKUNAVAILABLE" }, - 323 => { message: "The service you are trying to access is currently unavailable, please try again later.", name: "SERVICEUNAVAILABLE" }, - 324 => { message: "The service experienced a timeout. Please try again later.", name: "SERVICETIMEOUT" }, - 325 => { message: "Invalid number of lookback days, the most you can look back is 8 days.", name: "INVALIDLOOKBACKDAYS" }, - 326 => { message: "This service is currently disabled. Please try again later.", name: "CURRENTLYDISABLED" } - } + class << self + # Configure the gem programmatically + # @yield [Config] configuration object + def configure + yield Config if block_given? + end end end diff --git a/lib/hungrytable/config.rb b/lib/hungrytable/config.rb index f3d0acc..f564141 100644 --- a/lib/hungrytable/config.rb +++ b/lib/hungrytable/config.rb @@ -1,26 +1,48 @@ +# frozen_string_literal: true + module Hungrytable + # Configuration module for Hungrytable module Config - extend self + class << self + attr_writer :partner_id, :oauth_key, :oauth_secret, :base_url - def partner_id - ENV['OT_PARTNER_ID'] || config_error('OT_PARTNER_ID') - end + def partner_id + @partner_id ||= ENV.fetch('OT_PARTNER_ID') do + raise ConfigurationError, 'OT_PARTNER_ID must be set via ENV or Config.partner_id=' + end + end - def oauth_key - ENV['OT_OAUTH_KEY'] || config_error('OT_OAUTH_KEY') - end + def oauth_key + @oauth_key ||= ENV.fetch('OT_OAUTH_KEY') do + raise ConfigurationError, 'OT_OAUTH_KEY must be set via ENV or Config.oauth_key=' + end + end - def oauth_secret - ENV['OT_OAUTH_SECRET'] || config_error('OT_OAUTH_SECRET') - end + def oauth_secret + @oauth_secret ||= ENV.fetch('OT_OAUTH_SECRET') do + raise ConfigurationError, 'OT_OAUTH_SECRET must be set via ENV or Config.oauth_secret=' + end + end - def base_url - 'https://secure.opentable.com/api/otapi_v3.ashx' - end + def base_url + @base_url ||= 'https://secure.opentable.com/api/otapi_v3.ashx' + end + + # Reset configuration to defaults (useful for testing) + def reset! + @partner_id = nil + @oauth_key = nil + @oauth_secret = nil + @base_url = nil + end - private - def config_error var - raise HungrytableError, "ENV variable #{var} must be set." + # Check if all required configuration is present + def valid? + partner_id && oauth_key && oauth_secret + true + rescue ConfigurationError + false + end end end end diff --git a/lib/hungrytable/errors.rb b/lib/hungrytable/errors.rb new file mode 100644 index 0000000..c280848 --- /dev/null +++ b/lib/hungrytable/errors.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Hungrytable + # Base error class for all Hungrytable errors + class Error < StandardError; end + + # Configuration errors + class ConfigurationError < Error; end + + # API errors + class APIError < Error + attr_reader :error_code, :error_message + + def initialize(error_code, error_message) + @error_code = error_code + @error_message = error_message + super("OpenTable API Error #{error_code}: #{error_message}") + end + end + + # HTTP errors + class HTTPError < Error; end + class NotFoundError < HTTPError; end + class UnauthorizedError < HTTPError; end + class ServerError < HTTPError; end + + # Validation errors + class ValidationError < Error; end + class MissingRequiredFieldError < ValidationError; end + + # OpenTable specific errors mapped from error codes + class ReservationError < APIError; end + class SlotlockError < APIError; end + class RestaurantNotFoundError < APIError; end + class NoAvailabilityError < APIError; end +end diff --git a/lib/hungrytable/get_request.rb b/lib/hungrytable/get_request.rb index ba70b33..6c9b5e3 100644 --- a/lib/hungrytable/get_request.rb +++ b/lib/hungrytable/get_request.rb @@ -1,15 +1,26 @@ +# frozen_string_literal: true + module Hungrytable + # HTTP GET request handler class GetRequest < Request + # Default timeout for HTTP requests (in seconds) + DEFAULT_TIMEOUT = 30 + private - def auth_header - Hungrytable::RequestHeader.new(:get, @uri, {}, {}) - end def make_request - Curl::Easy.perform(@uri) do |curl| - curl.headers['Authorization'] = auth_header - end + response = HTTP + .timeout(DEFAULT_TIMEOUT) + .headers('Authorization' => auth_header) + .get(uri) + + handle_http_errors(response) + rescue HTTP::Error => e + raise HTTPError, "HTTP request failed: #{e.message}" end + def auth_header + RequestHeader.new(:get, uri, {}, {}).to_s + end end end diff --git a/lib/hungrytable/post_request.rb b/lib/hungrytable/post_request.rb index 2aad360..081daec 100644 --- a/lib/hungrytable/post_request.rb +++ b/lib/hungrytable/post_request.rb @@ -1,15 +1,26 @@ +# frozen_string_literal: true + module Hungrytable + # HTTP POST request handler class PostRequest < Request + # Default timeout for HTTP requests (in seconds) + DEFAULT_TIMEOUT = 30 + private - def auth_header - Hungrytable::RequestHeader.new(:post, @uri, {}, {}) - end def make_request - Curl::Easy.http_post(@uri, @params.to_query) do |curl| - curl.headers['Authorization'] = auth_header - end + response = HTTP + .timeout(DEFAULT_TIMEOUT) + .headers('Authorization' => auth_header) + .post(uri, form: params) + + handle_http_errors(response) + rescue HTTP::Error => e + raise HTTPError, "HTTP request failed: #{e.message}" end + def auth_header + RequestHeader.new(:post, uri, params, {}).to_s + end end end diff --git a/lib/hungrytable/request.rb b/lib/hungrytable/request.rb index 6b6050a..11936a3 100644 --- a/lib/hungrytable/request.rb +++ b/lib/hungrytable/request.rb @@ -1,17 +1,54 @@ +# frozen_string_literal: true + module Hungrytable + # Base class for API requests class Request - def initialize uri, params={} - @uri = Hungrytable::Config.base_url << uri + attr_reader :uri, :params + + def initialize(uri, params = {}) + @uri = "#{Hungrytable::Config.base_url}#{uri}" # Fixed: don't mutate base_url @params = params end + # Parse the JSON response + # @return [Hash] parsed JSON response + # @raise [Hungrytable::HTTPError] if the response is invalid def parsed_response - JSON.parse(response) + JSON.parse(response_body) + rescue JSON::ParserError => e + raise HTTPError, "Failed to parse response: #{e.message}" end private - def response - @response ||= make_request.body_str + + # Get the response body from the HTTP request + # @return [String] response body + def response_body + @response_body ||= make_request + end + + # Make the HTTP request (to be implemented by subclasses) + # @return [String] response body + def make_request + raise NotImplementedError, 'Subclasses must implement make_request' + end + + # Handle HTTP errors + # @param response [HTTP::Response] the HTTP response object + # @raise [Hungrytable::HTTPError] if the response indicates an error + def handle_http_errors(response) + case response.status.code + when 200..299 + response.body.to_s + when 401 + raise UnauthorizedError, 'Authentication failed. Check your OAuth credentials.' + when 404 + raise NotFoundError, "Resource not found: #{uri}" + when 500..599 + raise ServerError, "OpenTable server error (#{response.status.code})" + else + raise HTTPError, "HTTP error #{response.status.code}: #{response.body}" + end end end end diff --git a/lib/hungrytable/request_extensions.rb b/lib/hungrytable/request_extensions.rb index 2b28094..7562c4c 100644 --- a/lib/hungrytable/request_extensions.rb +++ b/lib/hungrytable/request_extensions.rb @@ -1,19 +1,32 @@ +# frozen_string_literal: true + module Hungrytable + # Shared functionality for classes that make API requests + # Follows DRY principle - don't repeat yourself module RequestExtensions extend ActiveSupport::Concern private + + # Get the request object (lazy-loaded) + # @return [Request] the request object def request @request ||= @requester.new(request_uri, params) end + # Ensure all required options are present + # @raise [MissingRequiredFieldError] if any required field is missing def ensure_required_opts - required_opts.each do |key| - raise ArgumentError, "options must include a value for #{key}" unless opts.has_key?(key) - end + return unless respond_to?(:required_opts, true) + + missing = required_opts.reject { |key| opts.key?(key) } + return if missing.empty? + + raise MissingRequiredFieldError, "Missing required fields: #{missing.join(', ')}" end - # Will be overwritten in objects that send post requests + # Default params (can be overridden in classes that send POST requests) + # @return [Hash] request parameters def params {} end diff --git a/lib/hungrytable/request_header.rb b/lib/hungrytable/request_header.rb index 8441ddd..8f505e3 100644 --- a/lib/hungrytable/request_header.rb +++ b/lib/hungrytable/request_header.rb @@ -1,27 +1,28 @@ +# frozen_string_literal: true + # Modified from simple_oauth (https://github.com/laserlemon/simple_oauth) module Hungrytable class RequestHeader - - ATTRIBUTE_KEYS = %w(consumer_key nonce signature_method timestamp token version).map(&:to_sym) + ATTRIBUTE_KEYS = %w[consumer_key nonce signature_method timestamp token version].map(&:to_sym) def self.default_options { - :nonce => OpenSSL::Random.random_bytes(16).unpack('H*')[0], - :signature_method => 'HMAC-SHA1', - :timestamp => Time.now.to_i.to_s, - :version => '1.0', - :consumer_key => Hungrytable::Config.oauth_key, - :consumer_secret => Hungrytable::Config.oauth_secret, - :token => '' + nonce: OpenSSL::Random.random_bytes(16).unpack1('H*'), + signature_method: 'HMAC-SHA1', + timestamp: Time.now.to_i.to_s, + version: '1.0', + consumer_key: Hungrytable::Config.oauth_key, + consumer_secret: Hungrytable::Config.oauth_secret, + token: '' } end def self.encode(value) - URI.encode(value.to_s, /[^a-z0-9\-\.\_\~]/i) + CGI.escape(value.to_s).gsub('+', '%20').gsub('%7E', '~') end def self.decode(value) - URI.decode(value.to_s) + CGI.unescape(value.to_s) end attr_reader :method, :params, :options @@ -33,7 +34,7 @@ def initialize(method, url, params, oauth = {}) @uri.normalize! @uri.fragment = nil @params = params - @options = self.class.default_options.merge(oauth) + @options = self.class.default_options.merge(oauth) end def url @@ -43,7 +44,7 @@ def url end def to_s - %Q(OAuth realm="http://www.opentable.com/", #{normalized_attributes}) + %(OAuth realm="http://www.opentable.com/", #{normalized_attributes}) end def valid?(secrets = {}) @@ -55,38 +56,38 @@ def valid?(secrets = {}) end def signed_attributes - attributes.merge(:oauth_signature => signature) + attributes.merge(oauth_signature: signature) end private def normalized_attributes - signed_attributes.sort_by{|k,v| k.to_s }.map{|k,v| %(#{k}="#{self.class.encode(v)}") }.join(', ') + signed_attributes.sort_by { |k, _v| k.to_s }.map { |k, v| %(#{k}="#{self.class.encode(v)}") }.join(', ') end def attributes - ATTRIBUTE_KEYS.inject({}){|a,k| options.key?(k) ? a.merge(:"oauth_#{k}" => options[k]) : a } + ATTRIBUTE_KEYS.inject({}) { |a, k| options.key?(k) ? a.merge("oauth_#{k}": options[k]) : a } end def signature - send(options[:signature_method].downcase.tr('-', '_') + '_signature') + send("#{options[:signature_method].downcase.tr('-', '_')}_signature") end def hmac_sha1_signature - Base64.encode64(OpenSSL::HMAC.digest(OpenSSL::Digest::SHA1.new, secret, signature_base)).chomp.gsub(/\n/, '') + Base64.encode64(OpenSSL::HMAC.digest(OpenSSL::Digest.new('SHA1'), secret, signature_base)).chomp.gsub("\n", '') end def secret - options.values_at(:consumer_secret, :token_secret).map{|v| self.class.encode(v) }.join('&') + options.values_at(:consumer_secret, :token_secret).map { |v| self.class.encode(v) }.join('&') end - alias_method :plaintext_signature, :secret + alias plaintext_signature secret def signature_base - [method, url, normalized_params].map{|v| self.class.encode(v) }.join('&') + [method, url, normalized_params].map { |v| self.class.encode(v) }.join('&') end def normalized_params - signature_params.map{|p| p.map{|v| self.class.encode(v) } }.sort.map{|p| p.join('=') }.join('&') + signature_params.map { |p| p.map { |v| self.class.encode(v) } }.sort.map { |p| p.join('=') }.join('&') end def signature_params @@ -94,16 +95,15 @@ def signature_params end def url_params - CGI.parse(@uri.query || '').inject([]){|p,(k,vs)| p + vs.sort.map{|v| [k, v] } } + CGI.parse(@uri.query || '').inject([]) { |p, (k, vs)| p + vs.sort.map { |v| [k, v] } } end def rsa_sha1_signature - Base64.encode64(private_key.sign(OpenSSL::Digest::SHA1.new, signature_base)).chomp.gsub(/\n/, '') + Base64.encode64(private_key.sign(OpenSSL::Digest.new('SHA1'), signature_base)).chomp.gsub("\n", '') end def private_key OpenSSL::PKey::RSA.new(options[:consumer_secret]) end - end end diff --git a/lib/hungrytable/reservation_cancel.rb b/lib/hungrytable/reservation_cancel.rb index fa2d86a..221c236 100644 --- a/lib/hungrytable/reservation_cancel.rb +++ b/lib/hungrytable/reservation_cancel.rb @@ -1,30 +1,44 @@ +# frozen_string_literal: true + module Hungrytable + # Cancels an existing restaurant reservation class ReservationCancel include RequestExtensions attr_reader :opts - def initialize opts={} + def initialize(opts = {}) @opts = opts ensure_required_opts @requester = opts[:requester] || GetRequest end + # Check if the cancellation was successful + # @return [Boolean] true if no errors def successful? - details["ns:ErrorID"] == "0" + details['ns:ErrorID'].to_s == '0' + end + + # Get error messages if cancellation failed + # @return [String, nil] error message or nil + def error_message + details['ns:ErrorMessage'] end private + def request_uri - "/reservation/?pid=#{Config.partner_id}&rid=#{opts[:restaurant_id]}&conf=#{opts[:confirmation_number]}&email=#{CGI.escape(opts[:email_address])}" + "/reservation/?pid=#{Config.partner_id}&rid=#{opts[:restaurant_id]}&" \ + "conf=#{CGI.escape(opts[:confirmation_number].to_s)}&email=#{CGI.escape(opts[:email_address])}" end + # @return [Hash] cancellation results from API response def details - request.parsed_response["Results"] + @details ||= request.parsed_response['Results'] || {} end def required_opts - %w(email_address confirmation_number restaurant_id).map(&:to_sym) + %i[email_address confirmation_number restaurant_id] end end end diff --git a/lib/hungrytable/reservation_make.rb b/lib/hungrytable/reservation_make.rb index 2960df9..2f64419 100644 --- a/lib/hungrytable/reservation_make.rb +++ b/lib/hungrytable/reservation_make.rb @@ -1,28 +1,43 @@ +# frozen_string_literal: true + module Hungrytable + # Makes a new restaurant reservation class ReservationMake include RequestExtensions attr_reader :restaurant_slotlock, :opts - def initialize restaurant_slotlock, opts={} + def initialize(restaurant_slotlock, opts = {}) @opts = opts ensure_required_opts - @requester = opts[:requester] || PostRequest + @requester = opts[:requester] || PostRequest @restaurant_slotlock = restaurant_slotlock end + # Check if the reservation was successful + # @return [Boolean] true if no errors def successful? - details["ns:ErrorID"] == "0" + details['ns:ErrorID'].to_s == '0' end + # Get the confirmation number for the reservation + # @return [String, nil] confirmation number or nil if unsuccessful def confirmation_number return nil unless successful? - details["ns:ConfirmationNumber"] + + details['ns:ConfirmationNumber'] + end + + # Get error messages if reservation failed + # @return [String, nil] error message or nil + def error_message + details['ns:ErrorMessage'] end private + def required_opts - %w(email_address firstname lastname phone).map(&:to_sym) + %i[email_address firstname lastname phone] end def default_options @@ -30,22 +45,28 @@ def default_options 'OTannouncementOption' => '0', 'RestaurantEmailOption' => '0', 'firsttimediner' => '0', - 'specialinstructions' => 'Have a great time', + 'specialinstructions' => opts[:specialinstructions] || '', 'slotlockid' => restaurant_slotlock.slotlock_id }.merge(restaurant_slotlock.params) end def params - default_options.merge(opts) + # Filter out internal options (like :requester) before converting to API parameters + internal_opts = %i[requester] + api_opts = opts.except(*internal_opts) + + # Convert symbol keys to strings for API + user_opts = api_opts.transform_keys(&:to_s) + default_options.merge(user_opts) end def request_uri "/reservation/?pid=#{Config.partner_id}&st=0" end + # @return [Hash] reservation results from API response def details - request.parsed_response["MakeResults"] + @details ||= request.parsed_response['MakeResults'] || {} end - end end diff --git a/lib/hungrytable/reservation_status.rb b/lib/hungrytable/reservation_status.rb index e69de29..82650e1 100644 --- a/lib/hungrytable/reservation_status.rb +++ b/lib/hungrytable/reservation_status.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Hungrytable + # Checks the status of an existing reservation + class ReservationStatus + include RequestExtensions + + attr_reader :opts + + def initialize(opts = {}) + @opts = opts + ensure_required_opts + @requester = opts[:requester] || GetRequest + end + + # @return [Boolean] true if the status request was successful + def successful? + details['ns:ErrorID'].to_s == '0' + end + + # @return [String] the current status of the reservation + def status + return nil unless successful? + + details['ns:ReservationStatus'] + end + + # @return [Hash] all reservation details + def reservation_details + return nil unless successful? + + { + status: details['ns:ReservationStatus'], + restaurant_name: details['ns:RestaurantName'], + date_time: details['ns:DateTime'], + party_size: details['ns:PartySize'], + confirmation_number: opts[:confirmation_number] + } + end + + private + + def request_uri + "/reservationstatus/?pid=#{Config.partner_id}&rid=#{opts[:restaurant_id]}&" \ + "conf=#{CGI.escape(opts[:confirmation_number].to_s)}" + end + + def details + @details ||= request.parsed_response['StatusResults'] || {} + end + + def required_opts + %i[confirmation_number restaurant_id] + end + end +end diff --git a/lib/hungrytable/restaurant.rb b/lib/hungrytable/restaurant.rb index b122eb6..f4f4353 100644 --- a/lib/hungrytable/restaurant.rb +++ b/lib/hungrytable/restaurant.rb @@ -1,46 +1,59 @@ +# frozen_string_literal: true + module Hungrytable + # Represents a restaurant and its details from the OpenTable API class Restaurant include RequestExtensions - def initialize restaurant_id, opts={} - @requester = opts[:requester] || GetRequest + # Attributes that map directly to API response fields + ATTRIBUTES = %i[ + address + city + error_ID + error_message + image_link + latitude + longitude + metro_name + neighborhood_name + parking + parking_details + phone + postal_code + price_range + primary_food_type + restaurant_description + restaurant_ID + restaurant_name + state + url + ].freeze + + attr_reader :restaurant_id + + def initialize(restaurant_id, opts = {}) + @requester = opts[:requester] || GetRequest @restaurant_id = restaurant_id end + # Alias for consistency def id @restaurant_id end + # Check if the restaurant query was valid + # @return [Boolean] true if no errors def valid? - error_ID == "0" + error_ID.to_s == '0' end - def method_missing meth, *args, &blk - if %w( - address - city - error_ID - error_message - image_link - latitude - longitude - metro_name - neighborhood_name - parking - parking_details - phone - postal_code - price_range - primary_food_type - restaurant_description - restaurant_ID - restaurant_name - state - url - ).map(&:to_sym).include?(meth) - return details["ns:#{meth.to_s.camelize.gsub("Id","ID")}"] + # Dynamically define getter methods for all attributes + ATTRIBUTES.each do |attr| + define_method(attr) do + # Convert Ruby snake_case to API camelCase, handling special case of ID + api_key = "ns:#{attr.to_s.camelize.gsub('Id', 'ID')}" + details[api_key] end - super end private @@ -49,10 +62,9 @@ def request_uri "/restaurant/?pid=#{Config.partner_id}&rid=#{id}" end - # @return [Hash] + # @return [Hash] restaurant details from API response def details - request.parsed_response["RestaurantDetailsResults"] + @details ||= request.parsed_response['RestaurantDetailsResults'] || {} end - end end diff --git a/lib/hungrytable/restaurant_search.rb b/lib/hungrytable/restaurant_search.rb index 2e00cad..b0cbf4f 100644 --- a/lib/hungrytable/restaurant_search.rb +++ b/lib/hungrytable/restaurant_search.rb @@ -1,77 +1,106 @@ +# frozen_string_literal: true + module Hungrytable + # Searches for available reservation times at a restaurant class RestaurantSearch include RequestExtensions + # Attributes that map directly to API response fields + ATTRIBUTES = %i[ + cuisine_type + early_security_ID + early_time + error_ID + error_message + exact_security_ID + exact_time + later_security_ID + later_time + latitude + longitude + neighborhood_name + restaurant_name + results_key + no_times_message + ].freeze + attr_reader :restaurant, :opts - def initialize restaurant, opts={} + def initialize(restaurant, opts = {}) @opts = opts ensure_required_opts - @requester = opts[:requester] || GetRequest + validate_date_time_type + validate_party_size + @requester = opts[:requester] || GetRequest @restaurant = restaurant end + # Check if the search was valid + # @return [Boolean] true if no errors def valid? - error_ID == "0" + error_ID.to_s == '0' end - def method_missing meth, *args, &blk - if %w( - cuisine_type - early_security_ID - early_time - error_ID - error_message - exact_security_ID - exact_time - later_security_ID - later_time - latitude - longitude - neighborhood_name - restaurant_name - results_key - no_times_message - ).map(&:to_sym).include?(meth) - return details["ns:#{meth.to_s.camelize.gsub("Id","ID")}"] + # Dynamically define getter methods for all attributes + ATTRIBUTES.each do |attr| + define_method(attr) do + # Convert Ruby snake_case to API camelCase, handling special case of ID + api_key = "ns:#{attr.to_s.camelize.gsub('Id', 'ID')}" + details[api_key] end - super end + # Get the best available security ID + # @return [String, nil] the ideal security ID for slotlock def ideal_security_id - return exact_security_ID unless exact_security_ID.nil? - return early_security_ID unless early_security_ID.nil? - return later_security_ID unless later_security_ID.nil? - nil + exact_security_ID || early_security_ID || later_security_ID end + # Get the best available time + # @return [String, nil] the ideal time for reservation def ideal_time - return exact_time unless exact_time.nil? - return early_time unless early_time.nil? - return later_time unless later_time.nil? - nil + exact_time || early_time || later_time end + # Get the party size for this search + # @return [Integer] number of people def party_size opts[:party_size] end private + def required_opts - %w(date_time party_size).map(&:to_sym) + %i[date_time party_size] end def encoded_date_time - URI.encode(opts[:date_time].strftime("%m/%d/%Y %I:%M %p"), /[^a-z0-9\-\.\_\~]/i) + # Use CGI.escape instead of deprecated URI.encode + CGI.escape(opts[:date_time].strftime('%m/%d/%Y %I:%M %p')) end def request_uri "/table/?pid=#{Config.partner_id}&rid=#{restaurant.id}&dt=#{encoded_date_time}&ps=#{party_size}" end + # @return [Hash] search results from API response def details - request.parsed_response["SearchResults"] + @details ||= request.parsed_response['SearchResults'] || {} + end + + def validate_date_time_type + return if opts[:date_time].respond_to?(:strftime) + + raise ValidationError, + "date_time must be a Time or DateTime object, got #{opts[:date_time].class}" end + def validate_party_size + ps = opts[:party_size] + return if ps.is_a?(Integer) && ps.positive? && ps <= 20 + + raise ValidationError, + "party_size must be a positive integer between 1 and 20, got #{ps.inspect}" + end end end diff --git a/lib/hungrytable/restaurant_slotlock.rb b/lib/hungrytable/restaurant_slotlock.rb index f6fb630..87e1bf8 100644 --- a/lib/hungrytable/restaurant_slotlock.rb +++ b/lib/hungrytable/restaurant_slotlock.rb @@ -1,38 +1,52 @@ +# frozen_string_literal: true + module Hungrytable + # Locks a reservation time slot before making a reservation class RestaurantSlotlock include RequestExtensions attr_reader :restaurant_search - def initialize restaurant_search, requester=PostRequest + def initialize(restaurant_search, requester = PostRequest) @restaurant_search = restaurant_search - @requester = requester + @requester = requester + validate_search_has_results end + # Check if the slotlock was successful + # @return [Boolean] true if no errors def successful? - details["ns:ErrorID"] == "0" + details['ns:ErrorID'].to_s == '0' end + # Get error messages if slotlock failed + # @return [String, nil] error message or nil def errors - details["ns:ErrorMessage"] + details['ns:ErrorMessage'] end + # Get the slotlock ID needed for making a reservation + # @return [String, nil] slotlock ID or nil if unsuccessful def slotlock_id return nil unless successful? - details["ns:SlotLockID"] + + details['ns:SlotLockID'] end + # Parameters to send with the slotlock request + # @return [Hash] request parameters def params { - 'RID' => restaurant.id, - 'datetime' => restaurant_search.ideal_time, - 'partysize' => restaurant_search.party_size, + 'RID' => restaurant.id, + 'datetime' => restaurant_search.ideal_time, + 'partysize' => restaurant_search.party_size, 'timesecurityID' => restaurant_search.ideal_security_id, - 'resultskey' => restaurant_search.results_key + 'resultskey' => restaurant_search.results_key } end private + def request_uri "/slotlock/?pid=#{Config.partner_id}&st=0" end @@ -41,9 +55,17 @@ def restaurant restaurant_search.restaurant end + # @return [Hash] slotlock results from API response def details - request.parsed_response["SlotLockResults"] + @details ||= request.parsed_response['SlotLockResults'] || {} end + def validate_search_has_results + if restaurant_search.ideal_security_id.nil? || + restaurant_search.ideal_time.nil? || + restaurant_search.results_key.nil? + raise ValidationError, 'Cannot create slotlock: no available times found in restaurant search' + end + end end end diff --git a/lib/hungrytable/version.rb b/lib/hungrytable/version.rb index be4aa5a..2d94778 100644 --- a/lib/hungrytable/version.rb +++ b/lib/hungrytable/version.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Hungrytable - VERSION = "0.0.8" + VERSION = '1.0.0' end diff --git a/test/test_helper.rb b/test/test_helper.rb index 4642b86..7cf88a6 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,18 +1,55 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift File.expand_path('../lib', __dir__) + +# SimpleCov must be loaded BEFORE any application code +require 'simplecov' +require 'simplecov-lcov' + +# Configure LCOV formatter for CI +SimpleCov::Formatter::LcovFormatter.config do |c| + c.report_with_single_file = true + c.single_report_path = 'coverage/lcov.info' +end + +# Use multiple formatters: HTML for local, LCOV for CI +SimpleCov.formatters = SimpleCov::Formatter::MultiFormatter.new([ + SimpleCov::Formatter::HTMLFormatter, + SimpleCov::Formatter::LcovFormatter + ]) + +SimpleCov.start do + add_filter '/test/' + command_name 'Unit Tests' +end + require 'minitest/autorun' require 'minitest/reporters' +require 'mocha/minitest' +require 'webmock/minitest' +require 'hungrytable' -require 'ostruct' -require 'pry' -require 'webmock' -include WebMock::API +# Use spec reporter for better output +Minitest::Reporters.use! Minitest::Reporters::SpecReporter.new +# Disable real HTTP requests in tests +WebMock.disable_net_connect!(allow_localhost: true) -# Load support files -Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f } +# Test helper methods +module TestHelpers + def setup_config + Hungrytable::Config.reset! + Hungrytable::Config.partner_id = 'test_partner_id' + Hungrytable::Config.oauth_key = 'test_oauth_key' + Hungrytable::Config.oauth_secret = 'test_oauth_secret' + end -# Set up minitest -MiniTest::Unit.runner = MiniTest::SuiteRunner.new -MiniTest::Unit.runner.reporters << MiniTest::Reporters::SpecReporter.new + def teardown_config + Hungrytable::Config.reset! + end +end -require 'mocha' -require File.expand_path("../../lib/hungrytable.rb", __FILE__) +# Include helpers in all tests +class Minitest::Test + include TestHelpers +end diff --git a/test/unit/config_test.rb b/test/unit/config_test.rb index 817b511..88d5b5c 100644 --- a/test/unit/config_test.rb +++ b/test/unit/config_test.rb @@ -1,43 +1,114 @@ +# frozen_string_literal: true + require 'test_helper' -describe Hungrytable::Config do - describe 'when the correct ENV vars are not set' do - before do - ENV['OT_PARTNER_ID'] = nil - ENV['OT_OAUTH_KEY'] = nil - ENV['OT_OAUTH_SECRET'] = nil - end +class ConfigTest < Minitest::Test + def setup + Hungrytable::Config.reset! + end - it 'raises a HungrytableError' do - [:partner_id, :oauth_key, :oauth_secret].each do |meth| - -> {Hungrytable::Config.send meth}.must_raise Hungrytable::HungrytableError - end - end + def teardown + Hungrytable::Config.reset! + ENV.delete('OT_PARTNER_ID') + ENV.delete('OT_OAUTH_KEY') + ENV.delete('OT_OAUTH_SECRET') end - - describe 'when the ENV vars are set' do - before do - ENV['OT_PARTNER_ID'] = 'foo' - ENV['OT_OAUTH_KEY'] = 'bar' - ENV['OT_OAUTH_SECRET'] = 'baz' + + def test_partner_id_from_env + ENV['OT_PARTNER_ID'] = 'env_partner_id' + + assert_equal 'env_partner_id', Hungrytable::Config.partner_id + end + + def test_oauth_key_from_env + ENV['OT_OAUTH_KEY'] = 'env_oauth_key' + + assert_equal 'env_oauth_key', Hungrytable::Config.oauth_key + end + + def test_oauth_secret_from_env + ENV['OT_OAUTH_SECRET'] = 'env_oauth_secret' + + assert_equal 'env_oauth_secret', Hungrytable::Config.oauth_secret + end + + def test_partner_id_direct_assignment + Hungrytable::Config.partner_id = 'direct_partner_id' + + assert_equal 'direct_partner_id', Hungrytable::Config.partner_id + end + + def test_oauth_key_direct_assignment + Hungrytable::Config.oauth_key = 'direct_oauth_key' + + assert_equal 'direct_oauth_key', Hungrytable::Config.oauth_key + end + + def test_oauth_secret_direct_assignment + Hungrytable::Config.oauth_secret = 'direct_oauth_secret' + + assert_equal 'direct_oauth_secret', Hungrytable::Config.oauth_secret + end + + def test_base_url_default + assert_equal 'https://secure.opentable.com/api/otapi_v3.ashx', Hungrytable::Config.base_url + end + + def test_base_url_can_be_overridden + Hungrytable::Config.base_url = 'https://custom.url' + + assert_equal 'https://custom.url', Hungrytable::Config.base_url + end + + def test_missing_partner_id_raises_error + assert_raises(Hungrytable::ConfigurationError) do + Hungrytable::Config.partner_id end + end - describe '.partner_id' do - it 'returns the value for OT_PARTNER_ID' do - Hungrytable::Config.partner_id.must_equal 'foo' - end + def test_missing_oauth_key_raises_error + assert_raises(Hungrytable::ConfigurationError) do + Hungrytable::Config.oauth_key end + end - describe '.oauth_key' do - it 'returns the value for OT_OAUTH_KEY' do - Hungrytable::Config.oauth_key.must_equal 'bar' - end + def test_missing_oauth_secret_raises_error + assert_raises(Hungrytable::ConfigurationError) do + Hungrytable::Config.oauth_secret end + end - describe '.oauth_secret' do - it 'returns the value for OT_OAUTH_SECRET' do - Hungrytable::Config.oauth_secret.must_equal 'baz' - end + def test_valid_returns_true_when_all_config_present + ENV['OT_PARTNER_ID'] = 'test' + ENV['OT_OAUTH_KEY'] = 'test' + ENV['OT_OAUTH_SECRET'] = 'test' + + assert_predicate Hungrytable::Config, :valid? + end + + def test_valid_returns_false_when_config_missing + refute_predicate Hungrytable::Config, :valid? + end + + def test_reset_clears_all_config + Hungrytable::Config.partner_id = 'test' + Hungrytable::Config.oauth_key = 'test' + Hungrytable::Config.oauth_secret = 'test' + + Hungrytable::Config.reset! + + refute_predicate Hungrytable::Config, :valid? + end + + def test_configure_block + Hungrytable.configure do |config| + config.partner_id = 'block_partner_id' + config.oauth_key = 'block_oauth_key' + config.oauth_secret = 'block_oauth_secret' end + + assert_equal 'block_partner_id', Hungrytable::Config.partner_id + assert_equal 'block_oauth_key', Hungrytable::Config.oauth_key + assert_equal 'block_oauth_secret', Hungrytable::Config.oauth_secret end end diff --git a/test/unit/get_request_test.rb b/test/unit/get_request_test.rb deleted file mode 100644 index e69de29..0000000 diff --git a/test/unit/hungrytable/user_test.rb b/test/unit/hungrytable/user_test.rb deleted file mode 100644 index 9f8625a..0000000 --- a/test/unit/hungrytable/user_test.rb +++ /dev/null @@ -1,28 +0,0 @@ -=begin -require 'test_helper' - -describe Hungrytable::User do - subject { Hungrytable::User } - - it 'should be a class' do - subject.class.must_be_kind_of Class - end - - %w{ login }.each do |meth| - it "should respond to #{meth}" do - assert subject.new.respond_to?(meth.to_sym), true - end - end - - describe 'users' do - before do - stub_request(:get, /.*secure.opentable.com\/api\/otapi_v2.ashx\/user.*/).to_return(File.new('./test/user_login_result.json'), :status => 200) - @user = subject.new - end - it 'should be able to login' do - response = @user.login({ email: 'bob@webservices.com', password: 'password' }) - response.must_be_kind_of Hash - end - end -end -=end diff --git a/test/unit/post_request_test.rb b/test/unit/post_request_test.rb deleted file mode 100644 index e69de29..0000000 diff --git a/test/unit/request_test.rb b/test/unit/request_test.rb deleted file mode 100644 index e69de29..0000000 diff --git a/test/unit/reservation_cancel_test.rb b/test/unit/reservation_cancel_test.rb index e69de29..a9341bb 100644 --- a/test/unit/reservation_cancel_test.rb +++ b/test/unit/reservation_cancel_test.rb @@ -0,0 +1,327 @@ +# frozen_string_literal: true + +require 'test_helper' + +class ReservationCancelTest < Minitest::Test + def setup + setup_config + @valid_opts = { + email_address: 'test@example.com', + confirmation_number: 'CONF123456', + restaurant_id: 12_345 + } + end + + def teardown + teardown_config + WebMock.reset! + end + + # Initialization tests + def test_initialization_with_valid_options + cancellation = Hungrytable::ReservationCancel.new(@valid_opts) + + assert_equal @valid_opts, cancellation.opts + end + + def test_initialization_raises_error_when_email_address_missing + opts = @valid_opts.dup + opts.delete(:email_address) + + error = assert_raises(Hungrytable::MissingRequiredFieldError) do + Hungrytable::ReservationCancel.new(opts) + end + + assert_includes error.message, 'email_address' + end + + def test_initialization_raises_error_when_confirmation_number_missing + opts = @valid_opts.dup + opts.delete(:confirmation_number) + + error = assert_raises(Hungrytable::MissingRequiredFieldError) do + Hungrytable::ReservationCancel.new(opts) + end + + assert_includes error.message, 'confirmation_number' + end + + def test_initialization_raises_error_when_restaurant_id_missing + opts = @valid_opts.dup + opts.delete(:restaurant_id) + + error = assert_raises(Hungrytable::MissingRequiredFieldError) do + Hungrytable::ReservationCancel.new(opts) + end + + assert_includes error.message, 'restaurant_id' + end + + def test_initialization_raises_error_when_all_required_fields_missing + error = assert_raises(Hungrytable::MissingRequiredFieldError) do + Hungrytable::ReservationCancel.new({}) + end + + assert_includes error.message, 'email_address' + assert_includes error.message, 'confirmation_number' + assert_includes error.message, 'restaurant_id' + end + + # successful? method tests + def test_successful_returns_true_when_error_id_is_zero + stub_successful_cancel_response + cancellation = Hungrytable::ReservationCancel.new(@valid_opts) + + assert_predicate cancellation, :successful? + end + + def test_successful_returns_false_when_error_id_is_not_zero + stub_failed_cancel_response + cancellation = Hungrytable::ReservationCancel.new(@valid_opts) + + refute_predicate cancellation, :successful? + end + + # error_message method tests + def test_error_message_returns_nil_when_successful + stub_successful_cancel_response + cancellation = Hungrytable::ReservationCancel.new(@valid_opts) + + assert_nil cancellation.error_message + end + + def test_error_message_returns_message_when_unsuccessful + stub_failed_cancel_response + cancellation = Hungrytable::ReservationCancel.new(@valid_opts) + + assert_equal 'Reservation not found', cancellation.error_message + end + + def test_error_message_returns_different_error_messages + stub_invalid_email_cancel_response + cancellation = Hungrytable::ReservationCancel.new(@valid_opts) + + assert_equal 'Email does not match reservation', cancellation.error_message + end + + def test_error_message_when_cancellation_window_passed + stub_too_late_cancel_response + cancellation = Hungrytable::ReservationCancel.new(@valid_opts) + + assert_equal 'Cannot cancel within 2 hours of reservation', cancellation.error_message + end + + # Request URI tests + def test_request_uri_includes_partner_id + cancellation = Hungrytable::ReservationCancel.new(@valid_opts) + uri = cancellation.send(:request_uri) + + assert_includes uri, 'pid=test_partner_id' + end + + def test_request_uri_includes_restaurant_id + cancellation = Hungrytable::ReservationCancel.new(@valid_opts) + uri = cancellation.send(:request_uri) + + assert_includes uri, 'rid=12345' + end + + def test_request_uri_includes_confirmation_number + cancellation = Hungrytable::ReservationCancel.new(@valid_opts) + uri = cancellation.send(:request_uri) + + assert_includes uri, 'conf=CONF123456' + end + + def test_request_uri_includes_encoded_email_address + cancellation = Hungrytable::ReservationCancel.new(@valid_opts) + uri = cancellation.send(:request_uri) + + # Email should be CGI escaped + assert_includes uri, 'email=' + assert_includes uri, 'test%40example.com' + end + + def test_request_uri_properly_encodes_email_with_plus_sign + opts = @valid_opts.merge(email_address: 'test+tag@example.com') + cancellation = Hungrytable::ReservationCancel.new(opts) + uri = cancellation.send(:request_uri) + + # Plus sign should be encoded + assert_includes uri, 'test%2Btag%40example.com' + end + + def test_request_uri_format + cancellation = Hungrytable::ReservationCancel.new(@valid_opts) + uri = cancellation.send(:request_uri) + + expected_uri = '/reservation/?pid=test_partner_id&rid=12345&conf=CONF123456&email=test%40example.com' + + assert_equal expected_uri, uri + end + + # Integration tests with WebMock + def test_handles_successful_api_response_correctly + stub_successful_cancel_response + cancellation = Hungrytable::ReservationCancel.new(@valid_opts) + + assert_predicate cancellation, :successful? + assert_nil cancellation.error_message + end + + def test_handles_failed_api_response_correctly + stub_failed_cancel_response + cancellation = Hungrytable::ReservationCancel.new(@valid_opts) + + refute_predicate cancellation, :successful? + assert_equal 'Reservation not found', cancellation.error_message + end + + def test_handles_invalid_email_response + stub_invalid_email_cancel_response + cancellation = Hungrytable::ReservationCancel.new(@valid_opts) + + refute_predicate cancellation, :successful? + assert_equal 'Email does not match reservation', cancellation.error_message + end + + def test_handles_too_late_to_cancel_response + stub_too_late_cancel_response + cancellation = Hungrytable::ReservationCancel.new(@valid_opts) + + refute_predicate cancellation, :successful? + assert_equal 'Cannot cancel within 2 hours of reservation', cancellation.error_message + end + + # Edge case tests + def test_handles_empty_response + stub_request(:get, %r{#{Regexp.escape(Hungrytable::Config.base_url)}/reservation/}) + .to_return( + status: 200, + body: { Results: {} }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + cancellation = Hungrytable::ReservationCancel.new(@valid_opts) + + refute_predicate cancellation, :successful? + end + + def test_handles_missing_results_key + stub_request(:get, %r{#{Regexp.escape(Hungrytable::Config.base_url)}/reservation/}) + .to_return( + status: 200, + body: {}.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + cancellation = Hungrytable::ReservationCancel.new(@valid_opts) + + # Should not raise an error, just return nil values + refute_predicate cancellation, :successful? + assert_nil cancellation.error_message + end + + def test_works_with_different_restaurant_ids + opts = @valid_opts.merge(restaurant_id: 99_999) + stub_request(:get, %r{#{Regexp.escape(Hungrytable::Config.base_url)}/reservation/}) + .with(query: hash_including('rid' => '99999')) + .to_return( + status: 200, + body: successful_cancel_json, + headers: { 'Content-Type' => 'application/json' } + ) + + cancellation = Hungrytable::ReservationCancel.new(opts) + + assert_predicate cancellation, :successful? + end + + def test_works_with_different_confirmation_numbers + opts = @valid_opts.merge(confirmation_number: 'CONF999999') + stub_request(:get, %r{#{Regexp.escape(Hungrytable::Config.base_url)}/reservation/}) + .with(query: hash_including('conf' => 'CONF999999')) + .to_return( + status: 200, + body: successful_cancel_json, + headers: { 'Content-Type' => 'application/json' } + ) + + cancellation = Hungrytable::ReservationCancel.new(opts) + + assert_predicate cancellation, :successful? + end + + private + + def stub_successful_cancel_response + stub_request(:get, %r{#{Regexp.escape(Hungrytable::Config.base_url)}/reservation/}) + .to_return( + status: 200, + body: successful_cancel_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + def stub_failed_cancel_response + stub_request(:get, %r{#{Regexp.escape(Hungrytable::Config.base_url)}/reservation/}) + .to_return( + status: 200, + body: failed_cancel_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + def stub_invalid_email_cancel_response + stub_request(:get, %r{#{Regexp.escape(Hungrytable::Config.base_url)}/reservation/}) + .to_return( + status: 200, + body: invalid_email_cancel_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + def stub_too_late_cancel_response + stub_request(:get, %r{#{Regexp.escape(Hungrytable::Config.base_url)}/reservation/}) + .to_return( + status: 200, + body: too_late_cancel_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + def successful_cancel_json + { + Results: { + 'ns:ErrorID' => '0' + } + }.to_json + end + + def failed_cancel_json + { + Results: { + 'ns:ErrorID' => '1', + 'ns:ErrorMessage' => 'Reservation not found' + } + }.to_json + end + + def invalid_email_cancel_json + { + Results: { + 'ns:ErrorID' => '2', + 'ns:ErrorMessage' => 'Email does not match reservation' + } + }.to_json + end + + def too_late_cancel_json + { + Results: { + 'ns:ErrorID' => '3', + 'ns:ErrorMessage' => 'Cannot cancel within 2 hours of reservation' + } + }.to_json + end +end diff --git a/test/unit/reservation_make_test.rb b/test/unit/reservation_make_test.rb index e69de29..55e9b93 100644 --- a/test/unit/reservation_make_test.rb +++ b/test/unit/reservation_make_test.rb @@ -0,0 +1,375 @@ +# frozen_string_literal: true + +require 'test_helper' + +class ReservationMakeTest < Minitest::Test + def setup + setup_config + @restaurant = Hungrytable::Restaurant.new(12_345) + + # Create a mock restaurant search + @restaurant_search = Minitest::Mock.new + @restaurant_search.expect :restaurant, @restaurant + @restaurant_search.expect :ideal_time, '2025-02-01 19:00:00' + @restaurant_search.expect :party_size, 4 + @restaurant_search.expect :ideal_security_id, 'exact_sec_456' + @restaurant_search.expect :results_key, 'results_key_abc123' + + # Create a mock slotlock + @slotlock = Minitest::Mock.new + @slotlock.expect :slotlock_id, 'slotlock_xyz789' + @slotlock.expect :params, { + 'RID' => 12_345, + 'datetime' => '2025-02-01 19:00:00', + 'partysize' => 4, + 'timesecurityID' => 'exact_sec_456', + 'resultskey' => 'results_key_abc123' + } + + @valid_opts = { + email_address: 'test@example.com', + firstname: 'John', + lastname: 'Doe', + phone: '555-1234' + } + end + + def teardown + teardown_config + WebMock.reset! + end + + # Initialization tests + def test_initialization_with_slotlock_and_options + # Use a simple stub instead of mock to avoid strict method checking in initialization + stub_slotlock = Object.new + reservation = Hungrytable::ReservationMake.new(stub_slotlock, @valid_opts) + + assert_equal stub_slotlock, reservation.restaurant_slotlock + assert_equal @valid_opts, reservation.opts + end + + def test_initialization_raises_error_when_email_address_missing + opts = @valid_opts.dup + opts.delete(:email_address) + + error = assert_raises(Hungrytable::MissingRequiredFieldError) do + Hungrytable::ReservationMake.new(@slotlock, opts) + end + + assert_includes error.message, 'email_address' + end + + def test_initialization_raises_error_when_firstname_missing + opts = @valid_opts.dup + opts.delete(:firstname) + + error = assert_raises(Hungrytable::MissingRequiredFieldError) do + Hungrytable::ReservationMake.new(@slotlock, opts) + end + + assert_includes error.message, 'firstname' + end + + def test_initialization_raises_error_when_lastname_missing + opts = @valid_opts.dup + opts.delete(:lastname) + + error = assert_raises(Hungrytable::MissingRequiredFieldError) do + Hungrytable::ReservationMake.new(@slotlock, opts) + end + + assert_includes error.message, 'lastname' + end + + def test_initialization_raises_error_when_phone_missing + opts = @valid_opts.dup + opts.delete(:phone) + + error = assert_raises(Hungrytable::MissingRequiredFieldError) do + Hungrytable::ReservationMake.new(@slotlock, opts) + end + + assert_includes error.message, 'phone' + end + + def test_initialization_raises_error_when_all_required_fields_missing + error = assert_raises(Hungrytable::MissingRequiredFieldError) do + Hungrytable::ReservationMake.new(@slotlock, {}) + end + + assert_includes error.message, 'email_address' + assert_includes error.message, 'firstname' + assert_includes error.message, 'lastname' + assert_includes error.message, 'phone' + end + + # successful? method tests + def test_successful_returns_true_when_error_id_is_zero + stub_successful_reservation_response + reservation = Hungrytable::ReservationMake.new(@slotlock, @valid_opts) + + assert_predicate reservation, :successful? + end + + def test_successful_returns_false_when_error_id_is_not_zero + stub_failed_reservation_response + reservation = Hungrytable::ReservationMake.new(@slotlock, @valid_opts) + + refute_predicate reservation, :successful? + end + + # confirmation_number method tests + def test_confirmation_number_returns_number_when_successful + stub_successful_reservation_response + reservation = Hungrytable::ReservationMake.new(@slotlock, @valid_opts) + + assert_equal 'CONF123456', reservation.confirmation_number + end + + def test_confirmation_number_returns_nil_when_unsuccessful + stub_failed_reservation_response + reservation = Hungrytable::ReservationMake.new(@slotlock, @valid_opts) + + assert_nil reservation.confirmation_number + end + + # error_message method tests + def test_error_message_returns_nil_when_successful + stub_successful_reservation_response + reservation = Hungrytable::ReservationMake.new(@slotlock, @valid_opts) + + assert_nil reservation.error_message + end + + def test_error_message_returns_message_when_unsuccessful + stub_failed_reservation_response + reservation = Hungrytable::ReservationMake.new(@slotlock, @valid_opts) + + assert_equal 'Reservation could not be completed', reservation.error_message + end + + def test_error_message_returns_different_messages + stub_invalid_email_reservation_response + reservation = Hungrytable::ReservationMake.new(@slotlock, @valid_opts) + + assert_equal 'Invalid email address', reservation.error_message + end + + # params method tests + def test_params_merges_default_options_with_user_options + reservation = Hungrytable::ReservationMake.new(@slotlock, @valid_opts) + params = reservation.send(:params) + + # Check default options are present + assert_equal '0', params['OTannouncementOption'] + assert_equal '0', params['RestaurantEmailOption'] + assert_equal '0', params['firsttimediner'] + assert_equal '', params['specialinstructions'] + + # Check slotlock data is present + assert_equal 'slotlock_xyz789', params['slotlockid'] + assert_equal 12_345, params['RID'] + assert_equal '2025-02-01 19:00:00', params['datetime'] + assert_equal 4, params['partysize'] + + # Check user options are present (as strings) + assert_equal 'test@example.com', params['email_address'] + assert_equal 'John', params['firstname'] + assert_equal 'Doe', params['lastname'] + assert_equal '555-1234', params['phone'] + end + + def test_params_includes_special_instructions_when_provided + opts = @valid_opts.merge(specialinstructions: 'Window seat please') + reservation = Hungrytable::ReservationMake.new(@slotlock, opts) + params = reservation.send(:params) + + assert_equal 'Window seat please', params['specialinstructions'] + end + + def test_params_uses_empty_string_for_special_instructions_when_not_provided + reservation = Hungrytable::ReservationMake.new(@slotlock, @valid_opts) + params = reservation.send(:params) + + assert_equal '', params['specialinstructions'] + end + + def test_params_converts_symbol_keys_to_strings + # Verify that symbol keys from user options are converted to strings + reservation = Hungrytable::ReservationMake.new(@slotlock, @valid_opts) + params = reservation.send(:params) + + # All keys should be strings + params.each_key do |key| + assert_instance_of String, key + end + end + + def test_params_filters_out_internal_options + # Verify that internal options like :requester are not sent to the API + opts_with_requester = @valid_opts.merge(requester: Minitest::Mock.new) + reservation = Hungrytable::ReservationMake.new(@slotlock, opts_with_requester) + params = reservation.send(:params) + + # :requester should not be in the params sent to API + refute_includes params.keys, 'requester' + refute_includes params.keys, :requester + + # Valid options should still be present + assert_equal 'test@example.com', params['email_address'] + assert_equal 'John', params['firstname'] + end + + # Request URI tests + def test_request_uri_includes_partner_id + reservation = Hungrytable::ReservationMake.new(@slotlock, @valid_opts) + uri = reservation.send(:request_uri) + + assert_includes uri, 'pid=test_partner_id' + end + + def test_request_uri_includes_st_parameter + reservation = Hungrytable::ReservationMake.new(@slotlock, @valid_opts) + uri = reservation.send(:request_uri) + + assert_includes uri, 'st=0' + end + + def test_request_uri_format + reservation = Hungrytable::ReservationMake.new(@slotlock, @valid_opts) + uri = reservation.send(:request_uri) + + assert_equal '/reservation/?pid=test_partner_id&st=0', uri + end + + # Integration tests with WebMock + def test_handles_successful_api_response_correctly + stub_successful_reservation_response + reservation = Hungrytable::ReservationMake.new(@slotlock, @valid_opts) + + assert_predicate reservation, :successful? + assert_equal 'CONF123456', reservation.confirmation_number + assert_nil reservation.error_message + end + + def test_handles_failed_api_response_correctly + stub_failed_reservation_response + reservation = Hungrytable::ReservationMake.new(@slotlock, @valid_opts) + + refute_predicate reservation, :successful? + assert_nil reservation.confirmation_number + assert_equal 'Reservation could not be completed', reservation.error_message + end + + def test_handles_invalid_email_response + stub_invalid_email_reservation_response + reservation = Hungrytable::ReservationMake.new(@slotlock, @valid_opts) + + refute_predicate reservation, :successful? + assert_nil reservation.confirmation_number + assert_equal 'Invalid email address', reservation.error_message + end + + # Edge case tests + def test_handles_empty_response + stub_request(:post, %r{#{Regexp.escape(Hungrytable::Config.base_url)}/reservation/}) + .to_return( + status: 200, + body: { MakeResults: {} }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + reservation = Hungrytable::ReservationMake.new(@slotlock, @valid_opts) + + refute_predicate reservation, :successful? + assert_nil reservation.confirmation_number + end + + def test_handles_missing_make_results_key + stub_request(:post, %r{#{Regexp.escape(Hungrytable::Config.base_url)}/reservation/}) + .to_return( + status: 200, + body: {}.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + reservation = Hungrytable::ReservationMake.new(@slotlock, @valid_opts) + + # Should not raise an error, just return nil values + refute_predicate reservation, :successful? + assert_nil reservation.confirmation_number + end + + def test_accepts_additional_optional_fields + opts = @valid_opts.merge( + specialinstructions: 'Allergy to peanuts', + RestaurantEmailOption: '1', + OTannouncementOption: '1' + ) + + reservation = Hungrytable::ReservationMake.new(@slotlock, opts) + params = reservation.send(:params) + + assert_equal 'Allergy to peanuts', params['specialinstructions'] + # User-provided values should override defaults + assert_equal '1', params['RestaurantEmailOption'] + assert_equal '1', params['OTannouncementOption'] + end + + private + + def stub_successful_reservation_response + stub_request(:post, %r{#{Regexp.escape(Hungrytable::Config.base_url)}/reservation/}) + .to_return( + status: 200, + body: successful_reservation_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + def stub_failed_reservation_response + stub_request(:post, %r{#{Regexp.escape(Hungrytable::Config.base_url)}/reservation/}) + .to_return( + status: 200, + body: failed_reservation_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + def stub_invalid_email_reservation_response + stub_request(:post, %r{#{Regexp.escape(Hungrytable::Config.base_url)}/reservation/}) + .to_return( + status: 200, + body: invalid_email_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + def successful_reservation_json + { + MakeResults: { + 'ns:ErrorID' => '0', + 'ns:ConfirmationNumber' => 'CONF123456' + } + }.to_json + end + + def failed_reservation_json + { + MakeResults: { + 'ns:ErrorID' => '1', + 'ns:ErrorMessage' => 'Reservation could not be completed' + } + }.to_json + end + + def invalid_email_json + { + MakeResults: { + 'ns:ErrorID' => '2', + 'ns:ErrorMessage' => 'Invalid email address' + } + }.to_json + end +end diff --git a/test/unit/reservation_status_test.rb b/test/unit/reservation_status_test.rb new file mode 100644 index 0000000..551b4df --- /dev/null +++ b/test/unit/reservation_status_test.rb @@ -0,0 +1,405 @@ +# frozen_string_literal: true + +require 'test_helper' + +class ReservationStatusTest < Minitest::Test + def setup + setup_config + @valid_opts = { + confirmation_number: 'CONF123456', + restaurant_id: 12_345 + } + end + + def teardown + teardown_config + WebMock.reset! + end + + # Initialization tests + def test_initialization_with_valid_options + status_check = Hungrytable::ReservationStatus.new(@valid_opts) + + assert_equal @valid_opts, status_check.opts + end + + def test_initialization_raises_error_when_confirmation_number_missing + opts = @valid_opts.dup + opts.delete(:confirmation_number) + + error = assert_raises(Hungrytable::MissingRequiredFieldError) do + Hungrytable::ReservationStatus.new(opts) + end + + assert_includes error.message, 'confirmation_number' + end + + def test_initialization_raises_error_when_restaurant_id_missing + opts = @valid_opts.dup + opts.delete(:restaurant_id) + + error = assert_raises(Hungrytable::MissingRequiredFieldError) do + Hungrytable::ReservationStatus.new(opts) + end + + assert_includes error.message, 'restaurant_id' + end + + def test_initialization_raises_error_when_all_required_fields_missing + error = assert_raises(Hungrytable::MissingRequiredFieldError) do + Hungrytable::ReservationStatus.new({}) + end + + assert_includes error.message, 'confirmation_number' + assert_includes error.message, 'restaurant_id' + end + + # successful? method tests + def test_successful_returns_true_when_error_id_is_zero + stub_successful_status_response + status_check = Hungrytable::ReservationStatus.new(@valid_opts) + + assert_predicate status_check, :successful? + end + + def test_successful_returns_false_when_error_id_is_not_zero + stub_failed_status_response + status_check = Hungrytable::ReservationStatus.new(@valid_opts) + + refute_predicate status_check, :successful? + end + + # status method tests + def test_status_returns_confirmed_when_successful + stub_successful_status_response + status_check = Hungrytable::ReservationStatus.new(@valid_opts) + + assert_equal 'Confirmed', status_check.status + end + + def test_status_returns_nil_when_unsuccessful + stub_failed_status_response + status_check = Hungrytable::ReservationStatus.new(@valid_opts) + + assert_nil status_check.status + end + + def test_status_returns_different_statuses + stub_cancelled_status_response + status_check = Hungrytable::ReservationStatus.new(@valid_opts) + + assert_equal 'Cancelled', status_check.status + end + + def test_status_returns_seated_status + stub_seated_status_response + status_check = Hungrytable::ReservationStatus.new(@valid_opts) + + assert_equal 'Seated', status_check.status + end + + # reservation_details method tests + def test_reservation_details_returns_complete_hash_when_successful + stub_successful_status_response + status_check = Hungrytable::ReservationStatus.new(@valid_opts) + details = status_check.reservation_details + + assert_instance_of Hash, details + assert_equal 'Confirmed', details[:status] + assert_equal 'Test Restaurant', details[:restaurant_name] + assert_equal '2025-02-01 19:00:00', details[:date_time] + assert_equal '4', details[:party_size] + assert_equal 'CONF123456', details[:confirmation_number] + end + + def test_reservation_details_returns_nil_when_unsuccessful + stub_failed_status_response + status_check = Hungrytable::ReservationStatus.new(@valid_opts) + + assert_nil status_check.reservation_details + end + + def test_reservation_details_includes_confirmation_number_from_opts + stub_successful_status_response + opts = @valid_opts.merge(confirmation_number: 'CONF999999') + status_check = Hungrytable::ReservationStatus.new(opts) + details = status_check.reservation_details + + assert_equal 'CONF999999', details[:confirmation_number] + end + + def test_reservation_details_contains_all_expected_keys + stub_successful_status_response + status_check = Hungrytable::ReservationStatus.new(@valid_opts) + details = status_check.reservation_details + + assert_includes details.keys, :status + assert_includes details.keys, :restaurant_name + assert_includes details.keys, :date_time + assert_includes details.keys, :party_size + assert_includes details.keys, :confirmation_number + end + + def test_reservation_details_with_different_party_sizes + stub_status_response_with_large_party + status_check = Hungrytable::ReservationStatus.new(@valid_opts) + details = status_check.reservation_details + + assert_equal '12', details[:party_size] + end + + # Request URI tests + def test_request_uri_includes_partner_id + status_check = Hungrytable::ReservationStatus.new(@valid_opts) + uri = status_check.send(:request_uri) + + assert_includes uri, 'pid=test_partner_id' + end + + def test_request_uri_includes_restaurant_id + status_check = Hungrytable::ReservationStatus.new(@valid_opts) + uri = status_check.send(:request_uri) + + assert_includes uri, 'rid=12345' + end + + def test_request_uri_includes_confirmation_number + status_check = Hungrytable::ReservationStatus.new(@valid_opts) + uri = status_check.send(:request_uri) + + assert_includes uri, 'conf=CONF123456' + end + + def test_request_uri_format + status_check = Hungrytable::ReservationStatus.new(@valid_opts) + uri = status_check.send(:request_uri) + + expected_uri = '/reservationstatus/?pid=test_partner_id&rid=12345&conf=CONF123456' + + assert_equal expected_uri, uri + end + + # Integration tests with WebMock + def test_handles_successful_api_response_correctly + stub_successful_status_response + status_check = Hungrytable::ReservationStatus.new(@valid_opts) + + assert_predicate status_check, :successful? + assert_equal 'Confirmed', status_check.status + refute_nil status_check.reservation_details + end + + def test_handles_failed_api_response_correctly + stub_failed_status_response + status_check = Hungrytable::ReservationStatus.new(@valid_opts) + + refute_predicate status_check, :successful? + assert_nil status_check.status + assert_nil status_check.reservation_details + end + + def test_handles_cancelled_reservation_response + stub_cancelled_status_response + status_check = Hungrytable::ReservationStatus.new(@valid_opts) + + assert_predicate status_check, :successful? + assert_equal 'Cancelled', status_check.status + details = status_check.reservation_details + + assert_equal 'Cancelled', details[:status] + end + + # Edge case tests + def test_handles_empty_response + stub_request(:get, %r{#{Regexp.escape(Hungrytable::Config.base_url)}/reservationstatus/}) + .to_return( + status: 200, + body: { StatusResults: {} }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + status_check = Hungrytable::ReservationStatus.new(@valid_opts) + + refute_predicate status_check, :successful? + assert_nil status_check.status + end + + def test_handles_missing_status_results_key + stub_request(:get, %r{#{Regexp.escape(Hungrytable::Config.base_url)}/reservationstatus/}) + .to_return( + status: 200, + body: {}.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + status_check = Hungrytable::ReservationStatus.new(@valid_opts) + + # Should not raise an error, just return nil values + refute_predicate status_check, :successful? + assert_nil status_check.status + assert_nil status_check.reservation_details + end + + def test_works_with_different_restaurant_ids + opts = @valid_opts.merge(restaurant_id: 99_999) + stub_request(:get, %r{#{Regexp.escape(Hungrytable::Config.base_url)}/reservationstatus/}) + .with(query: hash_including('rid' => '99999')) + .to_return( + status: 200, + body: successful_status_json, + headers: { 'Content-Type' => 'application/json' } + ) + + status_check = Hungrytable::ReservationStatus.new(opts) + + assert_predicate status_check, :successful? + end + + def test_works_with_different_confirmation_numbers + opts = @valid_opts.merge(confirmation_number: 'CONF999999') + stub_request(:get, %r{#{Regexp.escape(Hungrytable::Config.base_url)}/reservationstatus/}) + .with(query: hash_including('conf' => 'CONF999999')) + .to_return( + status: 200, + body: successful_status_json, + headers: { 'Content-Type' => 'application/json' } + ) + + status_check = Hungrytable::ReservationStatus.new(opts) + details = status_check.reservation_details + + # Confirmation number in details should match what was passed in + assert_equal 'CONF999999', details[:confirmation_number] + end + + def test_handles_partial_response_data + stub_request(:get, %r{#{Regexp.escape(Hungrytable::Config.base_url)}/reservationstatus/}) + .to_return( + status: 200, + body: { + StatusResults: { + 'ns:ErrorID' => '0', + 'ns:ReservationStatus' => 'Confirmed' + # Missing other fields + } + }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + status_check = Hungrytable::ReservationStatus.new(@valid_opts) + + assert_predicate status_check, :successful? + assert_equal 'Confirmed', status_check.status + + details = status_check.reservation_details + + assert_equal 'Confirmed', details[:status] + assert_nil details[:restaurant_name] + assert_nil details[:date_time] + assert_nil details[:party_size] + end + + private + + def stub_successful_status_response + stub_request(:get, %r{#{Regexp.escape(Hungrytable::Config.base_url)}/reservationstatus/}) + .to_return( + status: 200, + body: successful_status_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + def stub_failed_status_response + stub_request(:get, %r{#{Regexp.escape(Hungrytable::Config.base_url)}/reservationstatus/}) + .to_return( + status: 200, + body: failed_status_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + def stub_cancelled_status_response + stub_request(:get, %r{#{Regexp.escape(Hungrytable::Config.base_url)}/reservationstatus/}) + .to_return( + status: 200, + body: cancelled_status_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + def stub_seated_status_response + stub_request(:get, %r{#{Regexp.escape(Hungrytable::Config.base_url)}/reservationstatus/}) + .to_return( + status: 200, + body: seated_status_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + def stub_status_response_with_large_party + stub_request(:get, %r{#{Regexp.escape(Hungrytable::Config.base_url)}/reservationstatus/}) + .to_return( + status: 200, + body: large_party_status_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + def successful_status_json + { + StatusResults: { + 'ns:ErrorID' => '0', + 'ns:ReservationStatus' => 'Confirmed', + 'ns:RestaurantName' => 'Test Restaurant', + 'ns:DateTime' => '2025-02-01 19:00:00', + 'ns:PartySize' => '4' + } + }.to_json + end + + def failed_status_json + { + StatusResults: { + 'ns:ErrorID' => '1', + 'ns:ErrorMessage' => 'Reservation not found' + } + }.to_json + end + + def cancelled_status_json + { + StatusResults: { + 'ns:ErrorID' => '0', + 'ns:ReservationStatus' => 'Cancelled', + 'ns:RestaurantName' => 'Test Restaurant', + 'ns:DateTime' => '2025-02-01 19:00:00', + 'ns:PartySize' => '4' + } + }.to_json + end + + def seated_status_json + { + StatusResults: { + 'ns:ErrorID' => '0', + 'ns:ReservationStatus' => 'Seated', + 'ns:RestaurantName' => 'Test Restaurant', + 'ns:DateTime' => '2025-02-01 19:00:00', + 'ns:PartySize' => '4' + } + }.to_json + end + + def large_party_status_json + { + StatusResults: { + 'ns:ErrorID' => '0', + 'ns:ReservationStatus' => 'Confirmed', + 'ns:RestaurantName' => 'Large Party Restaurant', + 'ns:DateTime' => '2025-02-15 18:00:00', + 'ns:PartySize' => '12' + } + }.to_json + end +end diff --git a/test/unit/restaurant_search_test.rb b/test/unit/restaurant_search_test.rb index e69de29..64ae972 100644 --- a/test/unit/restaurant_search_test.rb +++ b/test/unit/restaurant_search_test.rb @@ -0,0 +1,382 @@ +# frozen_string_literal: true + +require 'test_helper' + +class RestaurantSearchTest < Minitest::Test + def setup + setup_config + @restaurant = Hungrytable::Restaurant.new(12_345) + @search_time = Time.parse('2025-02-01 19:00:00') + @opts = { + date_time: @search_time, + party_size: 4 + } + end + + def teardown + teardown_config + WebMock.reset! + end + + # Initialization tests + def test_initialization_with_restaurant_and_options + search = Hungrytable::RestaurantSearch.new(@restaurant, @opts) + + assert_equal @restaurant, search.restaurant + assert_equal @opts, search.opts + end + + def test_initialization_raises_error_when_date_time_missing + opts = { party_size: 4 } + + error = assert_raises(Hungrytable::MissingRequiredFieldError) do + Hungrytable::RestaurantSearch.new(@restaurant, opts) + end + + assert_includes error.message, 'date_time' + end + + def test_initialization_raises_error_when_party_size_missing + opts = { date_time: @search_time } + + error = assert_raises(Hungrytable::MissingRequiredFieldError) do + Hungrytable::RestaurantSearch.new(@restaurant, opts) + end + + assert_includes error.message, 'party_size' + end + + def test_initialization_raises_error_when_all_required_fields_missing + error = assert_raises(Hungrytable::MissingRequiredFieldError) do + Hungrytable::RestaurantSearch.new(@restaurant, {}) + end + + assert_includes error.message, 'date_time' + assert_includes error.message, 'party_size' + end + + # Valid? method tests + def test_valid_returns_true_when_error_id_is_zero + stub_successful_search_response + search = Hungrytable::RestaurantSearch.new(@restaurant, @opts) + + assert_predicate search, :valid? + end + + def test_valid_returns_false_when_error_id_is_not_zero + stub_failed_search_response + search = Hungrytable::RestaurantSearch.new(@restaurant, @opts) + + refute_predicate search, :valid? + end + + # Attribute accessor tests + def test_early_time_accessor + stub_successful_search_response + search = Hungrytable::RestaurantSearch.new(@restaurant, @opts) + + assert_equal '2025-02-01 18:30:00', search.early_time + end + + def test_exact_time_accessor + stub_successful_search_response + search = Hungrytable::RestaurantSearch.new(@restaurant, @opts) + + assert_equal '2025-02-01 19:00:00', search.exact_time + end + + def test_later_time_accessor + stub_successful_search_response + search = Hungrytable::RestaurantSearch.new(@restaurant, @opts) + + assert_equal '2025-02-01 19:30:00', search.later_time + end + + def test_early_security_id_accessor + stub_successful_search_response + search = Hungrytable::RestaurantSearch.new(@restaurant, @opts) + + assert_equal 'early_sec_123', search.early_security_ID + end + + def test_exact_security_id_accessor + stub_successful_search_response + search = Hungrytable::RestaurantSearch.new(@restaurant, @opts) + + assert_equal 'exact_sec_456', search.exact_security_ID + end + + def test_later_security_id_accessor + stub_successful_search_response + search = Hungrytable::RestaurantSearch.new(@restaurant, @opts) + + assert_equal 'later_sec_789', search.later_security_ID + end + + def test_restaurant_name_accessor + stub_successful_search_response + search = Hungrytable::RestaurantSearch.new(@restaurant, @opts) + + assert_equal 'Test Restaurant', search.restaurant_name + end + + def test_cuisine_type_accessor + stub_successful_search_response + search = Hungrytable::RestaurantSearch.new(@restaurant, @opts) + + assert_equal 'Italian', search.cuisine_type + end + + def test_error_message_accessor + stub_failed_search_response + search = Hungrytable::RestaurantSearch.new(@restaurant, @opts) + + assert_equal 'No availability found', search.error_message + end + + def test_no_times_message_accessor + stub_no_times_response + search = Hungrytable::RestaurantSearch.new(@restaurant, @opts) + + assert_equal 'Sorry, no times available for your party', search.no_times_message + end + + def test_results_key_accessor + stub_successful_search_response + search = Hungrytable::RestaurantSearch.new(@restaurant, @opts) + + assert_equal 'results_key_abc123', search.results_key + end + + # ideal_security_id method tests + def test_ideal_security_id_returns_exact_when_available + stub_successful_search_response + search = Hungrytable::RestaurantSearch.new(@restaurant, @opts) + + assert_equal 'exact_sec_456', search.ideal_security_id + end + + def test_ideal_security_id_returns_early_when_exact_not_available + stub_search_response_with_only_early + search = Hungrytable::RestaurantSearch.new(@restaurant, @opts) + + assert_equal 'early_sec_123', search.ideal_security_id + end + + def test_ideal_security_id_returns_later_when_exact_and_early_not_available + stub_search_response_with_only_later + search = Hungrytable::RestaurantSearch.new(@restaurant, @opts) + + assert_equal 'later_sec_789', search.ideal_security_id + end + + def test_ideal_security_id_returns_nil_when_no_times_available + stub_no_times_response + search = Hungrytable::RestaurantSearch.new(@restaurant, @opts) + + assert_nil search.ideal_security_id + end + + # ideal_time method tests + def test_ideal_time_returns_exact_when_available + stub_successful_search_response + search = Hungrytable::RestaurantSearch.new(@restaurant, @opts) + + assert_equal '2025-02-01 19:00:00', search.ideal_time + end + + def test_ideal_time_returns_early_when_exact_not_available + stub_search_response_with_only_early + search = Hungrytable::RestaurantSearch.new(@restaurant, @opts) + + assert_equal '2025-02-01 18:30:00', search.ideal_time + end + + def test_ideal_time_returns_later_when_exact_and_early_not_available + stub_search_response_with_only_later + search = Hungrytable::RestaurantSearch.new(@restaurant, @opts) + + assert_equal '2025-02-01 19:30:00', search.ideal_time + end + + def test_ideal_time_returns_nil_when_no_times_available + stub_no_times_response + search = Hungrytable::RestaurantSearch.new(@restaurant, @opts) + + assert_nil search.ideal_time + end + + # party_size method tests + def test_party_size_returns_correct_value + search = Hungrytable::RestaurantSearch.new(@restaurant, @opts) + + assert_equal 4, search.party_size + end + + def test_party_size_returns_different_sizes + opts = @opts.merge(party_size: 8) + search = Hungrytable::RestaurantSearch.new(@restaurant, opts) + + assert_equal 8, search.party_size + end + + # Request URI tests + def test_request_uri_includes_partner_id + search = Hungrytable::RestaurantSearch.new(@restaurant, @opts) + uri = search.send(:request_uri) + + assert_includes uri, 'pid=test_partner_id' + end + + def test_request_uri_includes_restaurant_id + search = Hungrytable::RestaurantSearch.new(@restaurant, @opts) + uri = search.send(:request_uri) + + assert_includes uri, 'rid=12345' + end + + def test_request_uri_includes_encoded_date_time + search = Hungrytable::RestaurantSearch.new(@restaurant, @opts) + uri = search.send(:request_uri) + + # Check that the date is properly encoded + assert_includes uri, 'dt=' + assert_includes uri, '02%2F01%2F2025' + end + + def test_request_uri_includes_party_size + search = Hungrytable::RestaurantSearch.new(@restaurant, @opts) + uri = search.send(:request_uri) + + assert_includes uri, 'ps=4' + end + + # Integration tests with WebMock + def test_handles_api_response_correctly + stub_successful_search_response + search = Hungrytable::RestaurantSearch.new(@restaurant, @opts) + + assert_predicate search, :valid? + assert_equal 'Test Restaurant', search.restaurant_name + assert_equal 'exact_sec_456', search.exact_security_ID + end + + def test_handles_no_availability_response + stub_no_times_response + search = Hungrytable::RestaurantSearch.new(@restaurant, @opts) + + refute_predicate search, :valid? + assert_nil search.ideal_security_id + assert_nil search.ideal_time + end + + private + + def stub_successful_search_response + stub_request(:get, %r{#{Regexp.escape(Hungrytable::Config.base_url)}/table/}) + .to_return( + status: 200, + body: successful_search_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + def stub_failed_search_response + stub_request(:get, %r{#{Regexp.escape(Hungrytable::Config.base_url)}/table/}) + .to_return( + status: 200, + body: failed_search_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + def stub_no_times_response + stub_request(:get, %r{#{Regexp.escape(Hungrytable::Config.base_url)}/table/}) + .to_return( + status: 200, + body: no_times_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + def stub_search_response_with_only_early + stub_request(:get, %r{#{Regexp.escape(Hungrytable::Config.base_url)}/table/}) + .to_return( + status: 200, + body: only_early_time_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + def stub_search_response_with_only_later + stub_request(:get, %r{#{Regexp.escape(Hungrytable::Config.base_url)}/table/}) + .to_return( + status: 200, + body: only_later_time_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + def successful_search_json + { + SearchResults: { + 'ns:ErrorID' => '0', + 'ns:RestaurantName' => 'Test Restaurant', + 'ns:CuisineType' => 'Italian', + 'ns:EarlyTime' => '2025-02-01 18:30:00', + 'ns:EarlySecurityID' => 'early_sec_123', + 'ns:ExactTime' => '2025-02-01 19:00:00', + 'ns:ExactSecurityID' => 'exact_sec_456', + 'ns:LaterTime' => '2025-02-01 19:30:00', + 'ns:LaterSecurityID' => 'later_sec_789', + 'ns:ResultsKey' => 'results_key_abc123', + 'ns:Latitude' => '40.7589', + 'ns:Longitude' => '-73.9851', + 'ns:NeighborhoodName' => 'Midtown' + } + }.to_json + end + + def failed_search_json + { + SearchResults: { + 'ns:ErrorID' => '1', + 'ns:ErrorMessage' => 'No availability found' + } + }.to_json + end + + def no_times_json + { + SearchResults: { + 'ns:ErrorID' => '2', + 'ns:NoTimesMessage' => 'Sorry, no times available for your party', + 'ns:RestaurantName' => 'Test Restaurant' + } + }.to_json + end + + def only_early_time_json + { + SearchResults: { + 'ns:ErrorID' => '0', + 'ns:RestaurantName' => 'Test Restaurant', + 'ns:EarlyTime' => '2025-02-01 18:30:00', + 'ns:EarlySecurityID' => 'early_sec_123', + 'ns:ResultsKey' => 'results_key_abc123' + } + }.to_json + end + + def only_later_time_json + { + SearchResults: { + 'ns:ErrorID' => '0', + 'ns:RestaurantName' => 'Test Restaurant', + 'ns:LaterTime' => '2025-02-01 19:30:00', + 'ns:LaterSecurityID' => 'later_sec_789', + 'ns:ResultsKey' => 'results_key_abc123' + } + }.to_json + end +end diff --git a/test/unit/restaurant_slotlock_test.rb b/test/unit/restaurant_slotlock_test.rb index e69de29..11edba8 100644 --- a/test/unit/restaurant_slotlock_test.rb +++ b/test/unit/restaurant_slotlock_test.rb @@ -0,0 +1,329 @@ +# frozen_string_literal: true + +require 'test_helper' + +class RestaurantSlotlockTest < Minitest::Test + def setup + setup_config + @restaurant = Hungrytable::Restaurant.new(12_345) + @search_time = Time.parse('2025-02-01 19:00:00') + end + + # Helper to create a restaurant search mock with expectations for validation + def create_restaurant_search_mock + mock = Minitest::Mock.new + # Expectations for validation during initialization + mock.expect :ideal_security_id, 'exact_sec_456' + mock.expect :ideal_time, '2025-02-01 19:00:00' + mock.expect :results_key, 'results_key_abc123' + # Additional expectations for params method + mock.expect :restaurant, @restaurant + mock.expect :ideal_time, '2025-02-01 19:00:00' + mock.expect :party_size, 4 + mock.expect :ideal_security_id, 'exact_sec_456' + mock.expect :results_key, 'results_key_abc123' + mock + end + + def teardown + teardown_config + WebMock.reset! + end + + # Initialization tests + def test_initialization_with_restaurant_search + # Create a minimal stub that responds to validation methods + stub_search = Minitest::Mock.new + stub_search.expect :ideal_security_id, 'some_id' + stub_search.expect :ideal_time, 'some_time' + stub_search.expect :results_key, 'some_key' + + slotlock = Hungrytable::RestaurantSlotlock.new(stub_search) + + # Verify validation methods were called (this confirms initialization worked) + stub_search.verify + + assert_instance_of Hungrytable::RestaurantSlotlock, slotlock + end + + def test_initialization_accepts_custom_requester + # Create a stub that responds to validation methods + stub_search = Minitest::Mock.new + stub_search.expect :ideal_security_id, 'some_id' + stub_search.expect :ideal_time, 'some_time' + stub_search.expect :results_key, 'some_key' + + stub_requester = Object.new + slotlock = Hungrytable::RestaurantSlotlock.new(stub_search, stub_requester) + + # Verify validation methods were called (this confirms initialization worked) + stub_search.verify + + assert_instance_of Hungrytable::RestaurantSlotlock, slotlock + end + + def test_initialization_raises_error_when_no_available_times + search = Minitest::Mock.new + search.expect :ideal_security_id, nil + search.expect :ideal_time, nil + search.expect :results_key, nil + + error = assert_raises(Hungrytable::ValidationError) do + Hungrytable::RestaurantSlotlock.new(search) + end + + assert_match(/no available times found/, error.message) + end + + # successful? method tests + def test_successful_returns_true_when_error_id_is_zero + stub_successful_slotlock_response + slotlock = Hungrytable::RestaurantSlotlock.new(create_restaurant_search_mock) + + assert_predicate slotlock, :successful? + end + + def test_successful_returns_false_when_error_id_is_not_zero + stub_failed_slotlock_response + slotlock = Hungrytable::RestaurantSlotlock.new(create_restaurant_search_mock) + + refute_predicate slotlock, :successful? + end + + # slotlock_id method tests + def test_slotlock_id_returns_id_when_successful + stub_successful_slotlock_response + slotlock = Hungrytable::RestaurantSlotlock.new(create_restaurant_search_mock) + + assert_equal 'slotlock_xyz789', slotlock.slotlock_id + end + + def test_slotlock_id_returns_nil_when_unsuccessful + stub_failed_slotlock_response + slotlock = Hungrytable::RestaurantSlotlock.new(create_restaurant_search_mock) + + assert_nil slotlock.slotlock_id + end + + def test_slotlock_id_returns_nil_when_api_error + stub_api_error_slotlock_response + slotlock = Hungrytable::RestaurantSlotlock.new(create_restaurant_search_mock) + + assert_nil slotlock.slotlock_id + end + + # errors method tests + def test_errors_returns_nil_when_successful + stub_successful_slotlock_response + slotlock = Hungrytable::RestaurantSlotlock.new(create_restaurant_search_mock) + + assert_nil slotlock.errors + end + + def test_errors_returns_message_when_unsuccessful + stub_failed_slotlock_response + slotlock = Hungrytable::RestaurantSlotlock.new(create_restaurant_search_mock) + + assert_equal 'Time slot no longer available', slotlock.errors + end + + def test_errors_returns_message_when_invalid_request + stub_invalid_request_slotlock_response + slotlock = Hungrytable::RestaurantSlotlock.new(create_restaurant_search_mock) + + assert_equal 'Invalid party size', slotlock.errors + end + + # params method tests + def test_params_builds_correct_hash + slotlock = Hungrytable::RestaurantSlotlock.new(create_restaurant_search_mock) + params = slotlock.params + + assert_equal 12_345, params['RID'] + assert_equal '2025-02-01 19:00:00', params['datetime'] + assert_equal 4, params['partysize'] + assert_equal 'exact_sec_456', params['timesecurityID'] + assert_equal 'results_key_abc123', params['resultskey'] + end + + def test_params_uses_restaurant_search_data + # Create a different mock with different values + different_search = Minitest::Mock.new + different_restaurant = Hungrytable::Restaurant.new(99_999) + + # Expectations for validation during initialization + different_search.expect :ideal_security_id, 'different_sec_999' + different_search.expect :ideal_time, '2025-03-15 20:00:00' + different_search.expect :results_key, 'different_key_xyz' + # Additional expectations for params method + different_search.expect :restaurant, different_restaurant + different_search.expect :ideal_time, '2025-03-15 20:00:00' + different_search.expect :party_size, 6 + different_search.expect :ideal_security_id, 'different_sec_999' + different_search.expect :results_key, 'different_key_xyz' + + slotlock = Hungrytable::RestaurantSlotlock.new(different_search) + params = slotlock.params + + assert_equal 99_999, params['RID'] + assert_equal '2025-03-15 20:00:00', params['datetime'] + assert_equal 6, params['partysize'] + assert_equal 'different_sec_999', params['timesecurityID'] + assert_equal 'different_key_xyz', params['resultskey'] + + different_search.verify + end + + # Request URI tests + def test_request_uri_includes_partner_id + slotlock = Hungrytable::RestaurantSlotlock.new(create_restaurant_search_mock) + uri = slotlock.send(:request_uri) + + assert_includes uri, 'pid=test_partner_id' + end + + def test_request_uri_includes_st_parameter + slotlock = Hungrytable::RestaurantSlotlock.new(create_restaurant_search_mock) + uri = slotlock.send(:request_uri) + + assert_includes uri, 'st=0' + end + + def test_request_uri_format + slotlock = Hungrytable::RestaurantSlotlock.new(create_restaurant_search_mock) + uri = slotlock.send(:request_uri) + + assert_equal '/slotlock/?pid=test_partner_id&st=0', uri + end + + # Integration tests with WebMock + def test_handles_successful_api_response_correctly + stub_successful_slotlock_response + slotlock = Hungrytable::RestaurantSlotlock.new(create_restaurant_search_mock) + + assert_predicate slotlock, :successful? + assert_equal 'slotlock_xyz789', slotlock.slotlock_id + assert_nil slotlock.errors + end + + def test_handles_failed_api_response_correctly + stub_failed_slotlock_response + slotlock = Hungrytable::RestaurantSlotlock.new(create_restaurant_search_mock) + + refute_predicate slotlock, :successful? + assert_nil slotlock.slotlock_id + assert_equal 'Time slot no longer available', slotlock.errors + end + + def test_handles_api_error_response + stub_api_error_slotlock_response + slotlock = Hungrytable::RestaurantSlotlock.new(create_restaurant_search_mock) + + refute_predicate slotlock, :successful? + assert_nil slotlock.slotlock_id + end + + # Edge case tests + def test_handles_empty_response + stub_request(:post, %r{#{Regexp.escape(Hungrytable::Config.base_url)}/slotlock/}) + .to_return( + status: 200, + body: { SlotLockResults: {} }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + slotlock = Hungrytable::RestaurantSlotlock.new(create_restaurant_search_mock) + + refute_predicate slotlock, :successful? + assert_nil slotlock.slotlock_id + end + + def test_handles_missing_slotlock_results_key + stub_request(:post, %r{#{Regexp.escape(Hungrytable::Config.base_url)}/slotlock/}) + .to_return( + status: 200, + body: {}.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + slotlock = Hungrytable::RestaurantSlotlock.new(create_restaurant_search_mock) + + # Should not raise an error, just return nil values + refute_predicate slotlock, :successful? + assert_nil slotlock.slotlock_id + end + + private + + def stub_successful_slotlock_response + stub_request(:post, %r{#{Regexp.escape(Hungrytable::Config.base_url)}/slotlock/}) + .to_return( + status: 200, + body: successful_slotlock_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + def stub_failed_slotlock_response + stub_request(:post, %r{#{Regexp.escape(Hungrytable::Config.base_url)}/slotlock/}) + .to_return( + status: 200, + body: failed_slotlock_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + def stub_api_error_slotlock_response + stub_request(:post, %r{#{Regexp.escape(Hungrytable::Config.base_url)}/slotlock/}) + .to_return( + status: 200, + body: api_error_slotlock_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + def stub_invalid_request_slotlock_response + stub_request(:post, %r{#{Regexp.escape(Hungrytable::Config.base_url)}/slotlock/}) + .to_return( + status: 200, + body: invalid_request_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + def successful_slotlock_json + { + SlotLockResults: { + 'ns:ErrorID' => '0', + 'ns:SlotLockID' => 'slotlock_xyz789' + } + }.to_json + end + + def failed_slotlock_json + { + SlotLockResults: { + 'ns:ErrorID' => '1', + 'ns:ErrorMessage' => 'Time slot no longer available' + } + }.to_json + end + + def api_error_slotlock_json + { + SlotLockResults: { + 'ns:ErrorID' => '500', + 'ns:ErrorMessage' => 'Internal server error' + } + }.to_json + end + + def invalid_request_json + { + SlotLockResults: { + 'ns:ErrorID' => '2', + 'ns:ErrorMessage' => 'Invalid party size' + } + }.to_json + end +end diff --git a/test/unit/restaurant_test.rb b/test/unit/restaurant_test.rb index de8e595..579fe30 100644 --- a/test/unit/restaurant_test.rb +++ b/test/unit/restaurant_test.rb @@ -1,39 +1,88 @@ +# frozen_string_literal: true + require 'test_helper' -require 'pry' - -describe Hungrytable::Restaurant do - before do - @mock_requester = mock('GetRequest') - @mock_request = mock('get') - @mock_requester.expects(:new).returns(@mock_request) - @mock_request.stubs(:parsed_response).returns({"RestaurantDetailsResults" => {}}) - ENV['OT_PARTNER_ID'] = 'foo' - @restaurant = Hungrytable::Restaurant.new(1, requester: @mock_requester) - end - %w( - address - city - error_ID - error_message - image_link - latitude - longitude - metro_name - neighborhood_name - parking - parking_details - phone - postal_code - price_range - primary_food_type - restaurant_description - restaurant_ID - restaurant_name - state - url - ).map(&:to_sym).each do |meth| - it "responds to #{meth.to_s} via method_missing" do - @restaurant.send(meth).must_equal nil - end + +class RestaurantTest < Minitest::Test + def setup + setup_config + @restaurant = Hungrytable::Restaurant.new(12_345) + end + + def teardown + teardown_config + end + + def test_initialization_with_id + assert_equal 12_345, @restaurant.restaurant_id + assert_equal 12_345, @restaurant.id + end + + def test_responds_to_all_attributes + Hungrytable::Restaurant::ATTRIBUTES.each do |attr| + assert_respond_to @restaurant, attr end + end + + def test_valid_returns_true_when_error_id_is_zero + requester_mock = Minitest::Mock.new + response_mock = Minitest::Mock.new + + requester_mock.expect :new, response_mock, [String, Hash] + response_mock.expect :parsed_response, { 'RestaurantDetailsResults' => { 'ns:ErrorID' => '0' } } + + restaurant = Hungrytable::Restaurant.new(12_345, requester: requester_mock) + + assert_predicate restaurant, :valid? + + requester_mock.verify + response_mock.verify + end + + def test_valid_returns_false_when_error_id_is_not_zero + requester_mock = Minitest::Mock.new + response_mock = Minitest::Mock.new + + requester_mock.expect :new, response_mock, [String, Hash] + response_mock.expect :parsed_response, { 'RestaurantDetailsResults' => { 'ns:ErrorID' => '1' } } + + restaurant = Hungrytable::Restaurant.new(12_345, requester: requester_mock) + + refute_predicate restaurant, :valid? + + requester_mock.verify + response_mock.verify + end + + def test_attributes_map_to_api_response + requester_mock = Minitest::Mock.new + response_mock = Minitest::Mock.new + + api_response = { + 'RestaurantDetailsResults' => { + 'ns:RestaurantName' => 'Test Restaurant', + 'ns:Address' => '123 Main St', + 'ns:Phone' => '555-1234', + 'ns:ErrorID' => '0' + } + } + + requester_mock.expect :new, response_mock, [String, Hash] + response_mock.expect :parsed_response, api_response + + restaurant = Hungrytable::Restaurant.new(12_345, requester: requester_mock) + + assert_equal 'Test Restaurant', restaurant.restaurant_name + assert_equal '123 Main St', restaurant.address + assert_equal '555-1234', restaurant.phone + + requester_mock.verify + response_mock.verify + end + + def test_request_uri_includes_partner_id_and_restaurant_id + uri = @restaurant.send(:request_uri) + + assert_includes uri, 'pid=test_partner_id' + assert_includes uri, 'rid=12345' + end end