diff --git a/.gitignore b/.gitignore index d1a1edf06f..f7ee7a7a6c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ # Local cache of Rubocop remote config .rubocop-* +twilio.env diff --git a/Gemfile b/Gemfile index 35de4a7f0e..6eb28c6ec9 100644 --- a/Gemfile +++ b/Gemfile @@ -4,10 +4,12 @@ ruby '3.0.2' group :test do gem 'rspec' - gem 'simplecov', require: false, group: :test - gem 'simplecov-console', require: false, group: :test + gem 'simplecov', require: false + gem 'simplecov-console', require: false + gem 'simplecov-shields-badge', require: false end group :development, :test do gem 'rubocop', '1.20' + gem 'twilio-ruby' end diff --git a/Gemfile.lock b/Gemfile.lock index 66064703c7..c71877ace7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -5,9 +5,39 @@ GEM ast (2.4.2) diff-lcs (1.4.4) docile (1.4.0) + faraday (1.10.0) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.0.3) + multipart-post (>= 1.2, < 3) + faraday-net_http (1.0.1) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.3) + jwt (2.3.0) + mini_portile2 (2.8.0) + multipart-post (2.1.1) + 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) rainbow (3.0.0) regexp_parser (2.1.1) rexml (3.2.5) @@ -36,6 +66,7 @@ GEM rubocop-ast (1.11.0) parser (>= 3.0.1.1) ruby-progressbar (1.11.0) + ruby2_keywords (0.0.5) simplecov (0.21.2) docile (~> 1.1) simplecov-html (~> 0.11) @@ -45,9 +76,15 @@ GEM simplecov terminal-table simplecov-html (0.12.3) + simplecov-shields-badge (0.1.0) + simplecov (~> 0.15) simplecov_json_formatter (0.1.3) terminal-table (3.0.1) unicode-display_width (>= 1.1.1, < 3) + twilio-ruby (5.66.2) + faraday (>= 0.9, < 2.0) + jwt (>= 1.5, <= 2.5) + nokogiri (>= 1.6, < 2.0) unicode-display_width (2.0.0) PLATFORMS @@ -58,6 +95,8 @@ DEPENDENCIES rubocop (= 1.20) simplecov simplecov-console + simplecov-shields-badge + twilio-ruby RUBY VERSION ruby 3.0.2p107 diff --git a/README.md b/README.md index dbcb154e43..da0c31c8df 100644 --- a/README.md +++ b/README.md @@ -1,83 +1,83 @@ -Takeaway Challenge -================== +# Takeaway + +[![Ruby Style Guide](https://img.shields.io/badge/code_style-rubocop-brightgreen.svg)](https://github.com/rubocop/rubocop) +[![Coverage](./badge.svg)](https://github.com/lukestorey95/takeaway-challenge) + +This program allows the user to order a takeaway, and receive a confirmation text informing them when to expect delivery. It uses a takeaway controller, order system, menu that loads dishes from a .csv file and TwilioAPI to send the text messages. + +
+ +## Installation + ``` - _________ - r== | | - _ // | M.A. | )))) - |_)//(''''': | | - // \_____:_____.-------D ))))) - // | === | / \ - .:'//. \ \=| \ / .:'':./ ))))) - :' // ': \ \ ''..'--:'-.. ': - '. '' .' \:.....:--'.-'' .' - ':..:' ':..:' - - ``` - -Instructions -------- - -* 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 - -Task ------ - -* 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: +$ git clone https://github.com/lukestorey95/takeaway-challenge.git + +$ cd takeaway-challenge +$ bundle + +$ source ./twilio.env ``` -As a customer -So that I can check if I want to order something -I would like to see a list of dishes with prices -As a customer -So that I can order the meal I want -I would like to be able to select some number of several available dishes +
-As a customer -So that I can verify that my order is correct -I would like to check that the total I have been given matches the sum of the various dishes in my order +## Quickstart -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 ``` +$ irb -* 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. +> Dir['./lib/*.rb'].each {|file| require file } -* Advanced! (have a go if you're feeling adventurous): - * Implement the ability to place orders via text message. +> takeaway = Takeaway.new -* 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. +> takeaway.display_menu -> :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. +> takeaway.add_to_order({ pizza: 9.50 }) -* 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 +> takeaway.check_order +> takeaway.place_order +``` -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. +## Running Tests -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. +``` +$ rspec -Notes on Test Coverage ------------------- +# The integration test is pending as running it will public send a text (costs 6p per text) +``` -You can see your [test coverage](https://github.com/makersacademy/course/blob/main/pills/test_coverage.md) when you run your tests. +
+ +## My Process + +1. Break down user stories into objects, attributes and behaviour +2. Feature test and note down errors/expected errors +3. Write failing test that replicates errors +4. Write the minimum code to make test pass +5. Refactor and ensure tests still pass +6. Repeat step 2 and ensure behaviour works as intended + +
+ +## User Stories + +``` +As a customer +So that I can check if I want to order something +I would like to see a list of dishes with prices + +As a customer +So that I can order the meal I want +I would like to be able to select some number of several available dishes + +As a customer +So that I can verify that my order is correct +I would like to check that the total I have been given matches the sum of the various dishes in my order + +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 +``` \ No newline at end of file diff --git a/assets/dishes.csv b/assets/dishes.csv new file mode 100644 index 0000000000..465bb54d56 --- /dev/null +++ b/assets/dishes.csv @@ -0,0 +1,6 @@ +name, price, available +pizza, 9.50,true +pasta, 8.20,true +tiramisu, 4.50,false +calamari, 7.80,true +risotto, 6.90,true \ No newline at end of file diff --git a/badge.svg b/badge.svg new file mode 100644 index 0000000000..b846227899 --- /dev/null +++ b/badge.svg @@ -0,0 +1 @@ +coverage: 100.0%coverage100.0% \ No newline at end of file diff --git a/diagram.md b/diagram.md new file mode 100644 index 0000000000..e9b4948434 --- /dev/null +++ b/diagram.md @@ -0,0 +1,71 @@ +# Diagram + +``` +Takeaway + @menu Menu + @current_order Order + + display_menu + should instruct Menu to display available dishes + + add_to_order(dishes) + when at least one item is chosen + when all the dishes match dishes on the Menu + should instruct + + when not all the dishes match dishes on the Menu + should raise error + + when no dishes are not provided + should raise error + + place_order + when current_order and basket are not empty + should change current_order order_complete from false to true + should instruct Text to send_text + + when current_order basket are empty + should raise error + +Order + @basket [] + @order_total_price 0.00 + @order_complete false + @order_placed_time Time + + add_to_basket + should change basket by number of dishes + + check_order_total_matches_item_total + when basket are not empty + should return list of basket, prices and order_total_price + + when basket are empty + should raise error + +Menu + @dishes_menu [{ name: “”, price: 0.00, available: true}] + + display_available_dishes + when dishes_menu contains available dishes + should return list of available dishes names and prices + + when dishes_menu is empty + should raise error + + import_dishes + when dishes are not empty + should change dishes_menu by at least 1 + + when dishes are empty + should raise error + +Text + ORDER_CONFIRMATION_MESSAGE + + @client Twilio::REST::Client + @message_body “” + + send_text + should instruct client with message_body and order_placed_time +``` \ No newline at end of file diff --git a/lib/menu.rb b/lib/menu.rb new file mode 100644 index 0000000000..37235363c1 --- /dev/null +++ b/lib/menu.rb @@ -0,0 +1,50 @@ +require 'csv' + +class Menu + attr_reader :dishes, :available_dishes + + def initialize(dishes_file = "./assets/dishes.csv") + @dishes = [] + load_dishes(dishes_file) + + @available_dishes = filter_dishes_by_available + end + + def display_available_dishes + available_dishes + end + + private + + attr_writer :dishes, :available_dishes + + def load_dishes(dishes_file) + CSV.foreach(dishes_file, headers: true, header_converters: :symbol) do |row| + name, price, available = row[:name], row[:price], row[:available] + + @dishes << { name: name.to_sym, price: price.to_f, available: true?(available) } + end + end + + def true?(available) + available == "true" + end + + def filter_dishes_by_available + self.available_dishes = dishes.select do |dish| + dish[:available] == true + end + + format_available_dishes + end + + def format_available_dishes + formatted_available_dishes = {} + + self.available_dishes = available_dishes.each do |dish| + formatted_available_dishes.merge!(dish[:name] => dish[:price]) + end + + formatted_available_dishes + end +end diff --git a/lib/order.rb b/lib/order.rb new file mode 100644 index 0000000000..a3415522e1 --- /dev/null +++ b/lib/order.rb @@ -0,0 +1,44 @@ +class Order + attr_reader :basket, :complete_status, :basket_total + + def initialize + @basket = [] + @complete_status = false + end + + def add_to_basket(dishes) + dishes.each { |dish| basket << dish } + calculate_basket_total + end + + def display_basket_and_total + { basket => basket_total } + end + + def complete_order(completion_time) + order_complete? + basket_empty? + self.complete_status = true + self.completion_time = completion_time + end + + private + + attr_writer :complete_status, :completion_time, :basket_total + + def basket_empty? + fail "No dishes in basket!" if basket.empty? + end + + def order_complete? + fail "Order already completed!" if complete_status == true + end + + def calculate_basket_total + prices = [] + basket.each do |item| + prices << item.values[0] + end + self.basket_total = prices.sum + end +end diff --git a/lib/takeaway.rb b/lib/takeaway.rb new file mode 100644 index 0000000000..82e180408f --- /dev/null +++ b/lib/takeaway.rb @@ -0,0 +1,46 @@ +class Takeaway + STANDARD_DELIVERY_TIMEFRAME = 3600 + + def initialize( + menu = Menu.new, + current_order = Order.new, + text = Text.new + ) + @menu = menu + @current_order = current_order + @text = text + end + + def display_menu + menu.display_available_dishes + end + + def add_to_order(*dishes) + current_order.add_to_basket(dishes) + end + + def check_order + current_order.display_basket_and_total + end + + def place_order + current_order.complete_order( + completion_time = current_time) + + text.send_completion_message( + expected_delivery_time = standard_delivery_time) + end + + private + + attr_reader :menu, :current_order, :text + + def standard_delivery_time + time = current_time + STANDARD_DELIVERY_TIMEFRAME + time.strftime("%H:%M") + end + + def current_time + Time.now + end +end diff --git a/lib/text.rb b/lib/text.rb new file mode 100644 index 0000000000..bad69f5027 --- /dev/null +++ b/lib/text.rb @@ -0,0 +1,36 @@ +require 'twilio-ruby' + +class Text + ACCOUNT_SID = ENV['TWILIO_ACCOUNT_SID'] + AUTH_TOKEN = ENV['TWILIO_AUTH_TOKEN'] + FROM_NUMBER = ENV['FROM_NUMBER'] + TO_NUMBER = ENV['TO_NUMBER'] + + def initialize( + client = Twilio::REST::Client.new( + ACCOUNT_SID, AUTH_TOKEN) + ) + @client = client + end + + def send_completion_message(completion_time) + completion_message = "Thanks for your order! " + + "Our chefs are already busy preparing your food, " + + "and then it will be on it's way to you in no time. " + + "Listen out for the doorbell around #{completion_time}" + + public_send(completion_message) + end + + private + + attr_reader :client + + def public_send(message) + new_text = client.messages.create( + body: message, + from: FROM_NUMBER, + to: TO_NUMBER + ) + end +end diff --git a/spec/dishes_spec.csv b/spec/dishes_spec.csv new file mode 100644 index 0000000000..2dabfc5d5f --- /dev/null +++ b/spec/dishes_spec.csv @@ -0,0 +1,4 @@ +name, price, available +pizza, 9.50,true +pasta, 8.20,true +tiramisu, 4.50,false \ No newline at end of file diff --git a/spec/integration_spec.rb b/spec/integration_spec.rb new file mode 100644 index 0000000000..ef1cd26d3e --- /dev/null +++ b/spec/integration_spec.rb @@ -0,0 +1,11 @@ +describe 'integration test' do + xit 'should not raise error' do + expect { + takeaway = Takeaway.new + takeaway.display_menu + takeaway.add_to_order({ pizza: 9.50 }) + takeaway.check_order + takeaway.place_order + }.not_to raise_error + end +end diff --git a/spec/menu_spec.rb b/spec/menu_spec.rb new file mode 100644 index 0000000000..fe04c2ef53 --- /dev/null +++ b/spec/menu_spec.rb @@ -0,0 +1,18 @@ +require 'csv' + +describe Menu do + subject(:menu) { Menu.new('./spec/dishes_spec.csv') } + + describe '#display_available_dishes' do + + context 'when some dishes are unavailable' do + it 'should only return available dish names and prices' do + expect(menu.display_available_dishes).to include(:pizza => 9.5, :pasta => 8.2) + end + + it 'should not return unavailable dishes' do + expect(menu.display_available_dishes).not_to include(:tiramisu => 4.5) + end + end + end +end diff --git a/spec/order_spec.rb b/spec/order_spec.rb new file mode 100644 index 0000000000..93e8c6ec2d --- /dev/null +++ b/spec/order_spec.rb @@ -0,0 +1,41 @@ +describe Order do + subject(:order) { Order.new } + dishes = [{ pizza: 9.50 }, { pasta: 8.20 }] + + describe '#add_to_basket' do + it 'should change basket to include dishes' do + order.add_to_basket(dishes) + expect(order.basket).to eq(dishes) + end + end + + describe '#display_basket_total' do + it 'should return basket dishes and prices and total' do + order.add_to_basket(dishes) + expect(order.display_basket_and_total).to eq(dishes => 17.70) + end + end + + describe '#complete_order' do + context 'when basket contains dishes' do + before(:each) { order.add_to_basket(dishes) } + + it 'should change order status to completed' do + expect { order.complete_order('time') }.to change { + order.complete_status + }.from(false).to(true) + end + + it 'should not allow a completed order to be completed again' do + allow(order).to receive(:complete_status).and_return(true) + expect { order.complete_order('time') }.to raise_error "Order already completed!" + end + end + + context 'when basket is empty' do + it 'should return error when basket is empty' do + expect { order.complete_order('time') }.to raise_error "No dishes in basket!" + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 252747d899..282e6374bb 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -6,12 +6,23 @@ # Want a nice code coverage website? Uncomment this next line! # SimpleCov::Formatter::HTMLFormatter ]) -SimpleCov.start - -RSpec.configure do |config| - config.after(:suite) do - puts - puts "\e[33mHave you considered running rubocop? It will help you improve your code!\e[0m" - puts "\e[33mTry it now! Just run: rubocop\e[0m" - end +SimpleCov.start do + add_filter 'spec' + require 'shields_badge' + SimpleCov.formatter = SimpleCov::Formatter::ShieldsBadge end + +# module FormatterOverrides +# def example_pending(_) +# end + +# def dump_pending(_) +# end +# end + +# RSpec::Core::Formatters::DocumentationFormatter.prepend FormatterOverrides + +require 'text' +require 'takeaway' +require 'menu' +require 'order' diff --git a/spec/takeaway_spec.rb b/spec/takeaway_spec.rb new file mode 100644 index 0000000000..0921b48851 --- /dev/null +++ b/spec/takeaway_spec.rb @@ -0,0 +1,47 @@ +describe Takeaway do + let(:menu) { instance_double('Menu') } + let(:order) { instance_double('Order') } + let(:text) { instance_double('Text') } + + subject(:takeaway) { Takeaway.new(menu, order, text) } + + describe '#display_menu' do + it 'should instruct menu to return available dishes' do + expect(menu).to receive(:display_available_dishes) + takeaway.display_menu + end + end + + describe '#add_to_order' do + it 'should instruct current_order to add dishes to basket' do + expect(order).to receive(:add_to_basket) + dishes = ['pizza', 'pasta'] + takeaway.add_to_order(dishes) + end + end + + describe '#check_order' do + it 'should instruct current_order to return dishes, prices and total' do + expect(order).to receive(:display_basket_and_total) + takeaway.check_order + end + end + + describe '#place_order' do + it 'should instruct current_order to complete' do + allow(order).to receive(:complete_order) + allow(text).to receive(:send_completion_message) + + expect(order).to receive(:complete_order) + takeaway.place_order + end + + it 'should instruct text to send_completion_message' do + allow(order).to receive(:complete_order) + allow(text).to receive(:send_completion_message) + + expect(text).to receive(:send_completion_message) + takeaway.place_order + end + end +end diff --git a/spec/text_spec.rb b/spec/text_spec.rb new file mode 100644 index 0000000000..496aaad70c --- /dev/null +++ b/spec/text_spec.rb @@ -0,0 +1,12 @@ +describe Text do + let(:client) { instance_double('Client', messages: self) } + + subject(:text) { Text.new(client) } + + describe '#send_completion_message' do + it 'should instruct client to send text message' do + expect(client.messages).to receive(:create) + text.send_completion_message('time') + end + end +end