diff --git a/backend/app/controllers/api/v1/orders_controller.rb b/backend/app/controllers/api/v1/orders_controller.rb new file mode 100644 index 0000000..f8afaf2 --- /dev/null +++ b/backend/app/controllers/api/v1/orders_controller.rb @@ -0,0 +1,36 @@ +class Api::V1::OrdersController < ApplicationController + def index + @orders = Order.all + render json: @orders + end + + def create + @order = Order.new(order_params) + + if @order.save + render json: { + message: 'Order Successfully Placed!', + order: @order + } + else + render json: { + message: 'Failed to place order.', + errors: [] + }, status: :bad_request + end + end + + def prices + @order = Order.new(order_params) + prices = OrderPriceService.new.calculate_prices(@order) + render json: prices + end + + private + + def order_params + params.require(:order).permit( + order_items_attributes: %i[product_id quantity] + ) + end +end diff --git a/backend/app/controllers/api/v1/products_controller.rb b/backend/app/controllers/api/v1/products_controller.rb index 71e6ff4..d94b083 100644 --- a/backend/app/controllers/api/v1/products_controller.rb +++ b/backend/app/controllers/api/v1/products_controller.rb @@ -1,6 +1,6 @@ class Api::V1::ProductsController < ApplicationController def index - products = ProductService.new.available_products + products = Product.all render json: products end end diff --git a/backend/app/models/order.rb b/backend/app/models/order.rb new file mode 100644 index 0000000..acd1d69 --- /dev/null +++ b/backend/app/models/order.rb @@ -0,0 +1,38 @@ +class Order < ApplicationRecord + validates :shipping_price, numericality: { greater_than_or_equal_to: 0 } + validates :total_price, numericality: { greater_than: 0 } + + has_many :order_items, dependent: :destroy, autosave: true + accepts_nested_attributes_for :order_items, reject_if: :all_blank + + before_validation :update_prices + before_validation :ensure_items_present + + def as_json(_options) + super( + include: { + order_items: { + only: %i[product_id quantity], + methods: %i[product_name unit_price] + } + } + ) + end + + private + + def update_prices + prices = OrderPriceService.new.calculate_prices(self) + self.shipping_price = prices[:shipping_price] + self.total_price = prices[:total_price] + true + end + + def ensure_items_present + errors.add(:order_items, 'must contain at least 1 item') unless total_qty.positive? + end + + def total_qty + order_items.map(&:quantity).sum + end +end diff --git a/backend/app/models/order_item.rb b/backend/app/models/order_item.rb new file mode 100644 index 0000000..d96939d --- /dev/null +++ b/backend/app/models/order_item.rb @@ -0,0 +1,12 @@ +class OrderItem < ApplicationRecord + belongs_to :order + belongs_to :product + + default_scope { includes(:product) } + + validates :order, :product, presence: true + validates :quantity, numericality: { greater_than: 0, only_integer: true } + + delegate :name, to: :product, prefix: :product + delegate :unit_price, to: :product +end diff --git a/backend/app/models/product.rb b/backend/app/models/product.rb new file mode 100644 index 0000000..583d216 --- /dev/null +++ b/backend/app/models/product.rb @@ -0,0 +1,4 @@ +class Product < ApplicationRecord + validates :name, presence: true + validates :unit_price, numericality: { greater_than: 0 } +end diff --git a/backend/app/services/order_price_service.rb b/backend/app/services/order_price_service.rb new file mode 100644 index 0000000..c0fb638 --- /dev/null +++ b/backend/app/services/order_price_service.rb @@ -0,0 +1,20 @@ +class OrderPriceService + def calculate_prices(order) + total_qty = 0 + shipping_price = 30 + total_price = 0 + + order.order_items.each do |item| + total_qty += item.quantity + total_price += item.quantity * item.unit_price + end + + shipping_price = 0 if total_qty >= 10 + total_price *= 0.9 if total_qty > 20 + + { + total_price: total_price + shipping_price, + shipping_price: shipping_price + } + end +end diff --git a/backend/app/services/product_service.rb b/backend/app/services/product_service.rb deleted file mode 100644 index 63e67cb..0000000 --- a/backend/app/services/product_service.rb +++ /dev/null @@ -1,20 +0,0 @@ -# Service for retrieving available products -class ProductService - # DTO Class for each product - # type: String - Product type to be displayed - # cost: Integer - Cost of product - Product = Struct.new(:type, :cost) - - # Dummy products. - AVAILABLE_PRODUCTS = [ - Product.new('High Quality', 20), - Product.new('Premium', 30) - ].freeze - - # Retrieve all available products - # In reality this would be likely be retrieved from external - # service/database instead of having hardcoded products - def available_products - AVAILABLE_PRODUCTS - end -end diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 1ca712b..26eaecc 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -3,6 +3,9 @@ namespace :api do namespace :v1 do get 'products' => 'products#index' + get 'orders' => 'orders#index' + post 'orders' => 'orders#create' + post 'orders/calculate-price' => 'orders#prices' end end end diff --git a/backend/db/migrate/20200709103721_create_orders.rb b/backend/db/migrate/20200709103721_create_orders.rb new file mode 100644 index 0000000..d0e35c6 --- /dev/null +++ b/backend/db/migrate/20200709103721_create_orders.rb @@ -0,0 +1,10 @@ +class CreateOrders < ActiveRecord::Migration[6.0] + def change + create_table :orders do |t| + t.integer :shipping_price, null: false + t.float :total_price, null: false + + t.timestamps + end + end +end diff --git a/backend/db/migrate/20200710070619_create_products.rb b/backend/db/migrate/20200710070619_create_products.rb new file mode 100644 index 0000000..9cd17fe --- /dev/null +++ b/backend/db/migrate/20200710070619_create_products.rb @@ -0,0 +1,10 @@ +class CreateProducts < ActiveRecord::Migration[6.0] + def change + create_table :products do |t| + t.string :name, null: false + t.float :unit_price, null: false + + t.timestamps + end + end +end diff --git a/backend/db/migrate/20200710113332_create_order_items.rb b/backend/db/migrate/20200710113332_create_order_items.rb new file mode 100644 index 0000000..b636c34 --- /dev/null +++ b/backend/db/migrate/20200710113332_create_order_items.rb @@ -0,0 +1,11 @@ +class CreateOrderItems < ActiveRecord::Migration[6.0] + def change + create_table :order_items do |t| + t.references :order, null: false, foreign_key: true + t.references :product, null: false, foreign_key: true + t.integer :quantity, null: false + + t.timestamps + end + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index b10373b..c2194a5 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,9 +10,35 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 0) do +ActiveRecord::Schema.define(version: 2020_07_10_113332) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" + create_table "order_items", force: :cascade do |t| + t.bigint "order_id", null: false + t.bigint "product_id", null: false + t.integer "quantity", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["order_id"], name: "index_order_items_on_order_id" + t.index ["product_id"], name: "index_order_items_on_product_id" + end + + create_table "orders", force: :cascade do |t| + t.integer "shipping_price", null: false + t.float "total_price", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + end + + create_table "products", force: :cascade do |t| + t.string "name", null: false + t.float "unit_price", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + end + + add_foreign_key "order_items", "orders" + add_foreign_key "order_items", "products" end diff --git a/backend/db/seeds.rb b/backend/db/seeds.rb index 1beea2a..2b79f19 100644 --- a/backend/db/seeds.rb +++ b/backend/db/seeds.rb @@ -1,7 +1,7 @@ -# This file should contain all the record creation needed to seed the database with its default values. -# The data can then be loaded with the rails db:seed command (or created alongside the database with db:setup). -# -# Examples: -# -# movies = Movie.create([{ name: 'Star Wars' }, { name: 'Lord of the Rings' }]) -# Character.create(name: 'Luke', movie: movies.first) +# In lieu of a CMS for managing products, we will just seed the db +Product.create( + [ + { name: 'High Quality', unit_price: 20 }, + { name: 'Premium', unit_price: 30 } + ] +) diff --git a/backend/test/controllers/api/v1/orders_controller_test.rb b/backend/test/controllers/api/v1/orders_controller_test.rb new file mode 100644 index 0000000..0321840 --- /dev/null +++ b/backend/test/controllers/api/v1/orders_controller_test.rb @@ -0,0 +1,51 @@ +class Api::V1::OrdersControllerTest < ActionDispatch::IntegrationTest + test 'should return expected orders in json format' do + expected = Order.all + get api_v1_orders_path + assert_response :ok + assert_equal expected.to_json, @response.body + end + + test 'should create order' do + assert_difference 'Order.count', 1 do + post api_v1_orders_path, params: { + order_items_attributes: [ + { product_id: products(:one).id, quantity: 1 } + ] + }, as: :json + end + assert_includes @response.body, products(:one).name + assert_response :ok + end + + test 'should not create order without items' do + assert_no_difference 'Order.count', 1 do + post api_v1_orders_path, params: { order_items_attributes: [] }, as: :json + end + assert_response :bad_request + end + + test 'should calculate price' do + expected = { 'shipping_price' => 30, 'total_price' => 40 } + post api_v1_orders_calculate_price_path, params: { + order_items_attributes: [ + { product_id: products(:one).id, quantity: 1 } + ] + }, as: :json + json = JSON.parse(@response.body) + assert_equal expected['shipping_price'], json['shipping_price'] + assert_equal expected['total_price'], json['total_price'] + assert_response :ok + end + + test 'should calculate price with no items' do + expected = { 'shipping_price' => 30, 'total_price' => 30 } + post api_v1_orders_calculate_price_path, params: { + order_items_attributes: [] + }, as: :json + json = JSON.parse(@response.body) + assert_equal expected['shipping_price'], json['shipping_price'] + assert_equal expected['total_price'], json['total_price'] + assert_response :ok + end +end diff --git a/backend/test/controllers/api/v1/products_controller_test.rb b/backend/test/controllers/api/v1/products_controller_test.rb index 04fd980..32180be 100644 --- a/backend/test/controllers/api/v1/products_controller_test.rb +++ b/backend/test/controllers/api/v1/products_controller_test.rb @@ -2,14 +2,9 @@ class Api::V1::ProductsControllerTest < ActionDispatch::IntegrationTest test 'should return expected products in json format' do - expected = ProductService::Product.new('DummyProduct', 42) - ProductService - .any_instance - .stubs(:available_products) - .returns([expected]) - + expected = Product.all get api_v1_products_path assert_response :ok - assert_equal [expected].to_json, @response.body + assert_equal expected.to_json, @response.body end end diff --git a/backend/test/fixtures/order_items.yml b/backend/test/fixtures/order_items.yml new file mode 100644 index 0000000..e82c5a8 --- /dev/null +++ b/backend/test/fixtures/order_items.yml @@ -0,0 +1,5 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html +item_one: + product: one + order: order_one + quantity: 1 \ No newline at end of file diff --git a/backend/test/fixtures/orders.yml b/backend/test/fixtures/orders.yml new file mode 100644 index 0000000..654699f --- /dev/null +++ b/backend/test/fixtures/orders.yml @@ -0,0 +1,4 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html +order_one: + shipping_price: 30 + total_price: 50 \ No newline at end of file diff --git a/backend/test/fixtures/products.yml b/backend/test/fixtures/products.yml new file mode 100644 index 0000000..d0e2b27 --- /dev/null +++ b/backend/test/fixtures/products.yml @@ -0,0 +1,8 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html +one: + name: "Test Product" + unit_price: 10 + +two: + name: "Test Product 2" + unit_price: 5 \ No newline at end of file diff --git a/backend/test/models/order_item_test.rb b/backend/test/models/order_item_test.rb new file mode 100644 index 0000000..a089b65 --- /dev/null +++ b/backend/test/models/order_item_test.rb @@ -0,0 +1,38 @@ +require 'test_helper' + +class OrderItemTest < ActiveSupport::TestCase + def setup + @order = Order.new + @product = products(:one) + @order_item = @order.order_items.build( + product: @product, + quantity: 1 + ) + end + + test 'should be valid' do + assert @order_item.valid? + end + + test 'should have order' do + @order_item.order = nil + assert_not @order_item.valid? + end + + test 'should have product' do + @order_item.product = nil + assert_not @order_item.valid? + end + + test 'should have quantity' do + @order_item.quantity = nil + assert_not @order_item.valid? + end + + test 'should have positive quantity' do + @order_item.quantity = 0 + assert_not @order_item.valid? + @order_item.quantity = -10 + assert_not @order_item.valid? + end +end diff --git a/backend/test/models/order_test.rb b/backend/test/models/order_test.rb new file mode 100644 index 0000000..ab311c1 --- /dev/null +++ b/backend/test/models/order_test.rb @@ -0,0 +1,48 @@ +require 'test_helper' + +class OrderTest < ActiveSupport::TestCase + def setup + @product = products(:one) + @order = Order.new( + shipping_price: 10, + total_price: 40, + order_items_attributes: [ + { + product_id: @product.id, + quantity: 2 + } + ] + ) + # Skip price updates to verify validation logic + @order.stubs(:update_prices) + end + + test 'should be valid' do + assert @order.valid? + end + + test 'should have shipping price' do + @order.shipping_price = nil + assert_not @order.valid? + end + + test 'should not have negative shipping price' do + @order.shipping_price = -1 + assert_not @order.valid? + end + + test 'should have total price' do + @order.total_price = nil + assert_not @order.valid? + end + + test 'should have 0 total price' do + @order.total_price = 0 + assert_not @order.valid? + end + + test 'should not have negative total price' do + @order.total_price = -1.0 + assert_not @order.valid? + end +end diff --git a/backend/test/models/product_test.rb b/backend/test/models/product_test.rb new file mode 100644 index 0000000..3d9808c --- /dev/null +++ b/backend/test/models/product_test.rb @@ -0,0 +1,36 @@ +require 'test_helper' + +class ProductTest < ActiveSupport::TestCase + def setup + @product = Product.new( + name: 'High Quality', + unit_price: 20 + ) + end + + test 'should be valid' do + assert @product.valid? + end + + test 'should have name' do + @product.name = nil + assert_not @product.valid? + @product.name = '' + assert_not @product.valid? + @product.name = ' ' + assert_not @product.valid? + end + + test 'should have price' do + @product.unit_price = nil + assert_not @product.valid? + end + + test 'should have positive unit price' do + @product.unit_price = 0 + assert_not @product.valid? + + @product.unit_price = -10 + assert_not @product.valid? + end +end diff --git a/backend/test/services/order_price_service_test.rb b/backend/test/services/order_price_service_test.rb new file mode 100644 index 0000000..3bde4ff --- /dev/null +++ b/backend/test/services/order_price_service_test.rb @@ -0,0 +1,113 @@ +require 'test_helper' + +class OrderPriceServiceTest < ActiveSupport::TestCase + def setup + @product1 = products(:one) # Unit price 10 + @product2 = products(:two) # Unit price 5 + @order = Order.new + end + + test 'should calculate price for empty order' do + expected = { + shipping_price: 30, + total_price: 30 + } + assert_equal expected, OrderPriceService.new.calculate_prices(@order) + end + + test 'should correctly calculate price for order < 10' do + expected = { + shipping_price: 30, + total_price: (10 * 9) + 30 + } + + @order.order_items.build( + product: @product1, + quantity: 9 + ) + + actual = OrderPriceService.new.calculate_prices(@order) + assert_equal expected, actual + end + + test 'should correctly calculate price for order > 10 and <= 20' do + expected = { + shipping_price: 0, + total_price: 10 * 20 + } + + @order.order_items.build( + product: @product1, + quantity: 20 + ) + + actual = OrderPriceService.new.calculate_prices(@order) + assert_equal expected, actual + end + + test 'should correctly calculate price for order > 20' do + expected = { + shipping_price: 0, + total_price: 210 * 0.9 + } + + @order.order_items.build( + quantity: 21, + product: @product1 + ) + + actual = OrderPriceService.new.calculate_prices(@order) + assert_equal expected, actual + end + + test 'should correctly calculate price for multiple line items with qty < 10 total' do + expected = { + shipping_price: 30, + total_price: 50 + } + + @order.order_items.build( + [ + { quantity: 1, product: @product1 }, + { quantity: 2, product: @product2 } + ] + ) + + actual = OrderPriceService.new.calculate_prices(@order) + assert_equal expected, actual + end + + test 'should correctly calculate price for multiple line items with qty = 20 total' do + expected = { + shipping_price: 0, + total_price: 190 + } + + @order.order_items.build( + [ + { quantity: 18, product: @product1 }, + { quantity: 2, product: @product2 } + ] + ) + + actual = OrderPriceService.new.calculate_prices(@order) + assert_equal expected, actual + end + + test 'should correctly calculate price for multiple line items with qty > 20 total' do + expected = { + shipping_price: 0, + total_price: 210 * 0.9 + } + + @order.order_items.build( + [ + { quantity: 20, product: @product1 }, + { quantity: 2, product: @product2 } + ] + ) + + actual = OrderPriceService.new.calculate_prices(@order) + assert_equal expected, actual + end +end