diff --git a/.gitignore b/.gitignore index d1a1edf06f..eba59a0a64 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ /**/.DS_Store /coverage +.env + # Local cache of Rubocop remote config .rubocop-* diff --git a/Gemfile b/Gemfile index 35de4a7f0e..95c8a88f1b 100644 --- a/Gemfile +++ b/Gemfile @@ -10,4 +10,6 @@ end group :development, :test do gem 'rubocop', '1.20' + gem 'twilio-ruby' + gem 'dotenv-rails' end diff --git a/Gemfile.lock b/Gemfile.lock index 66064703c7..c4dcf8d32f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,14 +1,68 @@ GEM remote: https://rubygems.org/ specs: + actionpack (7.0.2.4) + actionview (= 7.0.2.4) + activesupport (= 7.0.2.4) + rack (~> 2.0, >= 2.2.0) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.0) + rails-html-sanitizer (~> 1.0, >= 1.2.0) + actionview (7.0.2.4) + activesupport (= 7.0.2.4) + builder (~> 3.1) + erubi (~> 1.4) + rails-dom-testing (~> 2.0) + rails-html-sanitizer (~> 1.1, >= 1.2.0) + activesupport (7.0.2.4) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 1.6, < 2) + minitest (>= 5.1) + tzinfo (~> 2.0) ansi (1.5.0) ast (2.4.2) + builder (3.2.4) + concurrent-ruby (1.1.10) + crass (1.0.6) diff-lcs (1.4.4) docile (1.4.0) + dotenv (2.7.6) + dotenv-rails (2.7.6) + dotenv (= 2.7.6) + railties (>= 3.2) + erubi (1.10.0) + i18n (1.10.0) + concurrent-ruby (~> 1.0) + loofah (2.17.0) + crass (~> 1.0.2) + nokogiri (>= 1.5.9) + method_source (1.0.0) + mini_portile2 (2.8.0) + minitest (5.15.0) + nokogiri (1.13.4) + mini_portile2 (~> 2.8.0) + racc (~> 1.4) parallel (1.20.1) parser (3.0.2.0) ast (~> 2.4.1) + racc (1.6.0) + rack (2.2.3) + rack-test (1.1.0) + rack (>= 1.0, < 3) + rails-dom-testing (2.0.3) + activesupport (>= 4.2.0) + nokogiri (>= 1.6) + rails-html-sanitizer (1.4.2) + loofah (~> 2.3) + railties (7.0.2.4) + actionpack (= 7.0.2.4) + activesupport (= 7.0.2.4) + method_source + rake (>= 12.2) + thor (~> 1.0) + zeitwerk (~> 2.5) rainbow (3.0.0) + rake (13.0.6) regexp_parser (2.1.1) rexml (3.2.5) rspec (3.10.0) @@ -48,16 +102,22 @@ GEM simplecov_json_formatter (0.1.3) terminal-table (3.0.1) unicode-display_width (>= 1.1.1, < 3) + thor (1.2.1) + tzinfo (2.0.4) + concurrent-ruby (~> 1.0) unicode-display_width (2.0.0) + zeitwerk (2.5.4) PLATFORMS ruby DEPENDENCIES + dotenv-rails rspec rubocop (= 1.20) simplecov simplecov-console + twilio-ruby RUBY VERSION ruby 3.0.2p107 diff --git a/README.md b/README.md index dbcb154e43..e24d7369e0 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,41 @@ Takeaway Challenge ================== -``` - _________ - r== | | - _ // | M.A. | )))) - |_)//(''''': | | - // \_____:_____.-------D ))))) - // | === | / \ - .:'//. \ \=| \ / .:'':./ ))))) - :' // ': \ \ ''..'--:'-.. ': - '. '' .' \:.....:--'.-'' .' - ':..:' ':..:' - - ``` -Instructions + +Installation ------- +``` +$ git clone https://github.com/LGretzk/takeaway-challenge.git -* Feel free to use google, your notes, books, etc. but work on your own -* If you refer to the solution of another coach or student, please put a link to that in your README -* If you have a partial solution, **still check in a partial solution** -* You must submit a pull request to this repo with your code by 9am Monday morning +$ cd takeaway-challenge -Task +$ bundle +``` + +Instructions ----- -* Fork this repo -* Run the command 'bundle' in the project directory to ensure you have all the gems -* Write a Takeaway program with the following user stories: +* Use IRB to require the order.rb file + +* Create a new instance of Order to order food. + +* Use show_dishes method to see the menu. + +* Use add_dish(id, quantity) to order dish. + +* Use remove_dish(id) to remove dish from your order. + +* Use update_dish_quantity(id, quantity) to update the quantity of your chosen dish. + +* Use show_current_order to preview your order. + +* Use total to get the total amount you would need to pay for your order. + +* Use place_order(Confirmation.new) to place your order. + + +User stories +----- ``` As a customer @@ -46,38 +54,3 @@ As a customer So that I am reassured that my order will be delivered on time I would like to receive a text such as "Thank you! Your order was placed and will be delivered before 18:52" after I have ordered ``` - -* Hints on functionality to implement: - * Ensure you have a list of dishes with prices - * The text should state that the order was placed successfully and that it will be delivered 1 hour from now, e.g. "Thank you! Your order was placed and will be delivered before 18:52". - * The text sending functionality should be implemented using Twilio API. You'll need to register for it. It’s free. - * Use the twilio-ruby gem to access the API - * Use the Gemfile to manage your gems - * Make sure that your Takeaway is thoroughly tested and that you use mocks and/or stubs, as necessary to not to send texts when your tests are run - * However, if your Takeaway is loaded into IRB and the order is placed, the text should actually be sent - * Note that you can only send texts in the same country as you have your account. I.e. if you have a UK account you can only send to UK numbers. - -* Advanced! (have a go if you're feeling adventurous): - * Implement the ability to place orders via text message. - -* A free account on Twilio will only allow you to send texts to "verified" numbers. Use your mobile phone number, don't worry about the customer's mobile phone. - -> :warning: **WARNING:** think twice before you push your **mobile number** or **Twilio API Key** to a public space like GitHub :eyes: -> -> :key: Now is a great time to think about security and how you can keep your private information secret. You might want to explore environment variables. - -* Finally submit a pull request before Monday at 9am with your solution or partial solution. However much or little amount of code you wrote please please please submit a pull request before Monday at 9am - - -In code review we'll be hoping to see: - -* All tests passing -* High [Test coverage](https://github.com/makersacademy/course/blob/main/pills/test_coverage.md) (>95% is good) -* The code is elegant: every class has a clear responsibility, methods are short etc. - -Reviewers will potentially be using this [code review rubric](docs/review.md). Referring to this rubric in advance will make the challenge somewhat easier. You should be the judge of how much challenge you want this at this moment. - -Notes on Test Coverage ------------------- - -You can see your [test coverage](https://github.com/makersacademy/course/blob/main/pills/test_coverage.md) when you run your tests. diff --git a/lib/confirmation.rb b/lib/confirmation.rb new file mode 100644 index 0000000000..92d099aa0f --- /dev/null +++ b/lib/confirmation.rb @@ -0,0 +1,31 @@ +require 'twilio-ruby' +require 'dotenv' +Dotenv.load + +class Confirmation + + def initialize + @number = ENV['TO_PHONE_NUMBER'] + end + + def send_text + account_sid = ENV['ACCOUNT_SID'] + auth_token = ENV['AUTH_TOKEN'] + client = Twilio::REST::Client.new(account_sid, auth_token) + + from = ENV['FROM_PHONE_NUMBER'] + + client.messages.create( + from: from, + to: @number, + body: "Thank you! Your order was placed and will be delivered before #{delivery_time}" + ) + end + + def delivery_time + time = Time.now + delivered_by = (time + (60 * 60)) + delivered_by.to_s.split()[1][0,5] + end + +end diff --git a/lib/menu.rb b/lib/menu.rb new file mode 100644 index 0000000000..009e91d8a7 --- /dev/null +++ b/lib/menu.rb @@ -0,0 +1,44 @@ + +class Menu + + DISHES = [ + { id: 1, name: 'Spinach & Cheddar Pancakes', price: 7, available?: true, quantity: 0 }, + { id: 2, name: 'Veggie Breakfast', price: 8, available?: true, quantity: 0 }, + { id: 3, name: 'Halloumi Sandwhich', price: 7, available?: true, quantity: 0 }, + { id: 4, name: 'Soup of the Day', price: 8, available?: true, quantity: 0 }, + { id: 5, name: 'Ceaser Salad', price: 8, available?: true, quantity: 0 }, + { id: 6, name: 'Pizza', price: 9, available?: true, quantity: 0 }, + { id: 7, name: 'Vegan Pizza', price: 10, available?: true, quantity: 0 }, + { id: 8, name: 'Yum Yum Salad', price: 11, available?: true, quantity: 0 }, + { id: 9, name: 'Singapore Laksa', price: 11, available?: true, quantity: 0 }, + { id: 10, name: 'Spiced Noodles', price: 11, available?: true, quantity: 0 }, + { id: 11, name: 'Phad Thai', price: 11, available?: true, quantity: 0 }, + { id: 12, name: 'Caponata', price: 11, available?: false, quantity: 0 } + ].freeze + + attr_reader :dishes + + def initialize + @dishes = DISHES + end + + def display + @dishes.map do |dish| + p "#{dish[:id]}. #{dish[:name]} - £#{dish[:price]} - #{dish[:available?] ? 'available' : 'unavailable'}" + end + end + + def select_dish(id) + @dishes.select { |dish| dish[:id] == id }.first + end + + def dish_available?(id) + dish = select_dish(id) + dish[:available?] + end + + def id_valid?(id) + @dishes.select { |dish| dish[:id] == id } != [] + end + +end diff --git a/lib/order.rb b/lib/order.rb new file mode 100644 index 0000000000..6311c6b1da --- /dev/null +++ b/lib/order.rb @@ -0,0 +1,93 @@ +require_relative 'menu' +require_relative 'confirmation' + +class Order + + attr_reader :current_order, :order_placed + + def initialize + @current_order = [] + @order_placed = false + end + + def show_dishes + @@menu.display + end + + def add_dish(id, quantity) + select_dish(id) + if dish_already_added?(id) + add_by_updating_quantity(id, quantity) + else + @dish[:quantity] = quantity + @current_order << @dish + end + end + + def remove_dish(id) + fail_if_dish_not_added(id) + index = @current_order.index { |dish| dish[:id] == id } + @current_order.delete_at(index) + end + + def update_dish_quantity(id, quantity) + fail_if_dish_not_added(id) + remove_dish(id) if quantity < 1 + @current_order.each { |dish| dish[:quantity] = quantity if dish[:id] == id } + end + + def show_current_order + fail_if_order_empty + @current_order.map do |dish| + p "#{dish[:quantity]} x ##{dish[:id]} #{dish[:name]} - £#{dish[:price] * dish[:quantity]}" + end + puts "Order total: £#{total}" + end + + def total + fail_if_order_empty + @current_order.map { |dish| dish[:price] * dish[:quantity] }.reduce(:+) + end + + def place_order(confirmation) + fail_if_order_empty || (fail 'Order already placed' if @order_placed == true) + @order_placed = true + puts "Your order has been placed. Order summary:" + show_current_order + confirmation.send_text ### this would have had the tel number passed on as argument + end + + private + + @@menu = Menu.new + + def select_dish(id) + fail_if_invalid_id(id) || fail_if_dish_unavailable(id) + @dish = @@menu.select_dish(id) + end + + def add_by_updating_quantity(id, quantity) + @current_order.each { |dish| dish[:quantity] = (dish[:quantity] + quantity) if dish[:id] == id } + end + + def fail_if_invalid_id(id) + fail 'Invalid id' unless @@menu.id_valid?(id) + end + + def fail_if_dish_unavailable(id) + fail 'Dish unavailable' unless @@menu.dish_available?(id) + end + + def fail_if_dish_not_added(id) + fail 'Dish has not been added' unless dish_already_added?(id) + end + + def fail_if_order_empty + fail 'Current order empty' if @current_order.empty? + end + + def dish_already_added?(id) + (@current_order.select { |dish| dish[:id] == id }) != [] + end + +end diff --git a/spec/confirmation_spec.rb b/spec/confirmation_spec.rb new file mode 100644 index 0000000000..e69de29bb2 diff --git a/spec/menu_spec.rb b/spec/menu_spec.rb new file mode 100644 index 0000000000..ff33103377 --- /dev/null +++ b/spec/menu_spec.rb @@ -0,0 +1,55 @@ +require 'menu' + +describe Menu do + + subject(:menu) { Menu.new } + + it 'creates an instance of the class' do + expect(menu).to be_instance_of(Menu) + end + + it 'initializes with a dishes array' do + expect(menu.dishes).not_to be_empty + end + + it 'initializes wiht a dishes array that contains dishes' do + expect(menu.dishes).to include({ id: 1, name: 'Spinach & Cheddar Pancakes', price: 7, available?: true, quantity: 0 }) + end + + it 'contains multiple dishes' do + expect(menu.dishes).to include({ id: 11, name: 'Phad Thai', price: 11, available?: true, quantity: 0 }) + end + + it 'displays all of the dishes' do + expect(menu.display[1]).to eq("2. Veggie Breakfast - £8 - available") + end + + it 'selects a dish' do + expect(menu.select_dish(1)).to eq({ id: 1, name: 'Spinach & Cheddar Pancakes', price: 7, available?: true, quantity: 0 }) + end + + context 'dish available' do + it 'informs whether the dish is available' do + expect(menu.dish_available?(1)).to eq true + end + end + + context 'dish unavailable' do + it 'informs whether the dish is available' do + expect(menu.dish_available?(12)).to eq false + end + end + + context 'id valid' do + it 'checks if id is valid' do + expect(menu.id_valid?(1)).to eq true + end + end + + context 'id invalid' do + it 'checks if if is valid' do + expect(menu.id_valid?(30)).to eq false + end + end + +end diff --git a/spec/order_spec.rb b/spec/order_spec.rb new file mode 100644 index 0000000000..cf8aed942d --- /dev/null +++ b/spec/order_spec.rb @@ -0,0 +1,140 @@ +require 'order' + +describe Order do + + subject(:order) { Order.new } + let(:dish0) { { id: 1, name: 'Spinach & Cheddar Pancakes', price: 7, available?: true, quantity: 0 } } + let(:dish1) { { id: 1, name: 'Spinach & Cheddar Pancakes', price: 7, available?: true, quantity: 1 } } + let(:dish2) { { id: 1, name: 'Spinach & Cheddar Pancakes', price: 7, available?: true, quantity: 2 } } + let(:menu) { instance_double('Menu') } + + + it 'creates an instance of the order class' do + expect(order).to be_instance_of(Order) + end + + it 'initializes with the current_order variable' do + expect(order.current_order).to eq([]) + end + + it 'initializes with the order_placed variable' do + expect(order.order_placed).to eq false + end + + it 'shows all of the dishes' do + allow(menu).to receive(:display).and_return("2. Veggie Breakfast - £8 - available") + expect(order.show_dishes[1]).to eq("2. Veggie Breakfast - £8 - available") + end + + describe '#select_dish' do ### tested when the method wasn't private + + it 'selects a dish from the menu' do + allow(menu).to receive_messages(:select_dish => dish0) + dish = menu.select_dish(1) + expect(dish).to eq dish0 + end + + xit 'fails if id is invalid' do ### this works in irb but not in rspec, what could be the reason? + # tested when the method wasn't private + allow(menu).to receive(:select_dish).and_raise('Invalid id') + dish = menu.select_dish(1) + expect { dish }.to raise_error 'Invalid id' + end + + xit 'fails if dish is unavailable' do ### same as above + allow(menu).to receive_messages(:select_dish => dish0, :id_valid? => true, :dish_available? => false) + dish = menu.select_dish(1) + expect { dish }.to raise_error 'Dish unavailable' + end + + end + + describe '#add_dish' do ### this still uses the Menu class; how coud it stub it? + + it 'adds a dish to the current_order' do + order.add_dish(1, 1) + expect(order.current_order).to eq([dish1]) + end + + it 'fails if id is invalid' do + expect { order.add_dish(33, 1) }.to raise_error 'Invalid id' + end + + it 'fails if dish is unavailable' do + expect { order.add_dish(12, 1) }.to raise_error 'Dish unavailable' + end + + end + + describe '#remove_dish' do + + it 'removes the dish from the current order' do + order.add_dish(1, 1) + order.remove_dish(1) + expect(order.current_order).to eq([]) + end + + it 'raises an erro when removing dish that has not been added' do + expect { order.remove_dish(1) }.to raise_error 'Dish has not been added' + end + + end + + describe '#update_dish_quantity' do + + it 'updates the quantity if the same dish is added twice' do + order.add_dish(1, 1) + order.add_dish(1, 1) + expect(order.current_order).to eq([dish2]) + end + + it 'removes the dish from the current order if the quantity is zero' do + order.add_dish(1, 1) + order.update_dish_quantity(1, 0) + expect(order.current_order).to eq([]) + end + + it 'raises an error when dish has not been added' do + expect { order.update_dish_quantity(1, 1) }.to raise_error 'Dish has not been added' + end + + end + + xit 'shows the current order' do ### test pending, method prints & doesn't return statements + order.add_dish(2, 1) + expect(order.show_current_order).to eq("1 x #2 Veggie Breakfast - £8" + "Order total: £14") + end + + it 'fails to show current order if empty' do + expect { order.show_current_order }.to raise_error 'Current order empty' + end + + it 'calculates total for the current order' do + order.add_dish(1, 1) + order.add_dish(1, 1) + expect(order.total).to eq 14 + end + + it 'fails to calculate total if current order is empty' do + expect { order.total }.to raise_error 'Current order empty' + end + + it 'places order' do + order.add_dish(1, 1) + order.add_dish(1, 1) + confirmation = double('Confirmation', :send_text => 'Message sent') + order.place_order(confirmation) + expect(order.order_placed).to be true + end + + it 'fails to place order if current order is empty' do + expect { order.place_order('confirmation') }.to raise_error 'Current order empty' + end + + it 'sends a confirmation text message' do + order.add_dish(1, 1) + confirmation = double('Confirmation', :send_text => 'Message sent') + expect(order.place_order(confirmation)).to eq 'Message sent' + end + +end