Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Better error when submitting order with out of stock product #387

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
.ruby-version
.tool-versions
5 changes: 2 additions & 3 deletions ecommerce/crm/lib/crm/order.rb
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
module Crm
class Order
include AggregateRoot
CustomerAlreadyAssigned = Class.new(StandardError)

def initialize(id)
@id = id
end

def set_customer(customer_id)
raise CustomerAlreadyAssigned if @customer_id
return if @customer_id == customer_id
apply CustomerAssignedToOrder.new(data: { order_id: @id, customer_id: customer_id })
end

Expand All @@ -18,4 +17,4 @@ def set_customer(customer_id)
@customer_id = event.data[:customer_id]
end
end
end
end
27 changes: 24 additions & 3 deletions ecommerce/crm/test/assign_customer_to_order_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,31 @@ def test_customer_should_get_assigned
def test_customer_should_not_get_assigned_twice
customer_id = SecureRandom.uuid
order_id = SecureRandom.uuid

register_customer(customer_id, fake_name)

expected_event = CustomerAssignedToOrder.new(data: {customer_id: customer_id, order_id: order_id})

assert_events("Crm::Order$#{order_id}", expected_event) do
assign_customer_to_order(order_id, customer_id)
assign_customer_to_order(order_id, customer_id)
end
end

def test_customer_can_be_reassigned
customer_id = SecureRandom.uuid
another_customer_id = SecureRandom.uuid
order_id = SecureRandom.uuid

register_customer(customer_id, fake_name)
assign_customer_to_order(order_id, customer_id)
assert_raises(Order::CustomerAlreadyAssigned) do
register_customer(another_customer_id, fake_name)

expected_event_1 = CustomerAssignedToOrder.new(data: {customer_id: customer_id, order_id: order_id})
expected_event_2 = CustomerAssignedToOrder.new(data: {customer_id: another_customer_id, order_id: order_id})

assert_events("Crm::Order$#{order_id}", expected_event_1, expected_event_2) do
assign_customer_to_order(order_id, customer_id)
assign_customer_to_order(order_id, another_customer_id)
end
end

Expand All @@ -40,4 +61,4 @@ def assign_customer_to_order(order_id, customer_id)
run_command(AssignCustomerToOrder.new(order_id: order_id, customer_id: customer_id))
end
end
end
end
1 change: 1 addition & 0 deletions ecommerce/processes/lib/processes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
require_relative "../../invoicing/lib/invoicing"
require_relative "../../fulfillment/lib/fulfillment"
require_relative 'processes/confirm_order_on_payment_captured'
require_relative 'processes/events'
require_relative 'processes/release_payment_process'
require_relative 'processes/shipment_process'
require_relative 'processes/determine_vat_rates_on_order_placed'
Expand Down
10 changes: 10 additions & 0 deletions ecommerce/processes/lib/processes/events.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
module Processes
class ReservationProcessFailed < Infra::Event
attribute :order_id, Infra::Types::UUID
attribute :unavailable_products, Infra::Types::Array
end

class ReservationProcessSuceeded < Infra::Event
attribute :order_id, Infra::Types::UUID
end
end
40 changes: 30 additions & 10 deletions ecommerce/processes/lib/processes/reservation_process.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,7 @@ def call(event)
state = build_state(event)
case event.event_type
when 'Ordering::OrderSubmitted'
begin
reserve_stock(state)
rescue Inventory::InventoryEntry::InventoryNotAvailable
release_stock(state)
reject_order(state)
else
accept_order(state)
end
order_side_effects(state) { reserve_stock(state) }
when 'Fulfillment::OrderCancelled'
release_stock(state)
when 'Fulfillment::OrderConfirmed'
Expand All @@ -28,10 +21,33 @@ def call(event)
private

def reserve_stock(state)
unavailable_products = []
state.order_lines.each do |product_id, quantity|
command_bus.(Inventory::Reserve.new(product_id: product_id, quantity: quantity))
state.product_reserved(product_id)
rescue Inventory::InventoryEntry::InventoryNotAvailable
unavailable_products << product_id
end

if unavailable_products.empty?
event = ReservationProcessSuceeded.new(data: { order_id: state.order_id })
else
event = ReservationProcessFailed.new(data: { order_id: state.order_id, unavailable_products: unavailable_products })
end
event_store.publish(event, stream_name: stream_name(state.order_id))
end

def order_side_effects(state)
event_store
.within { yield }
.subscribe(to: ReservationProcessFailed) do
release_stock(state)
reject_order(state)
end
.subscribe(to: ReservationProcessSuceeded) do
accept_order(state)
end
.call
end

def release_stock(state)
Expand All @@ -54,8 +70,12 @@ def reject_order(state)
command_bus.(Ordering::RejectOrder.new(order_id: state.order_id))
end

def stream_name(order_id)
"ReservationProcess$#{order_id}"
end

def build_state(event)
stream_name = "ReservationProcess$#{event.data.fetch(:order_id)}"
stream_name = stream_name(event.data.fetch(:order_id))
begin
past_events = event_store.read.stream(stream_name).to_a
last_stored = past_events.size - 1
Expand Down Expand Up @@ -93,4 +113,4 @@ def product_reserved(product_id)
end
end
end
end
end
41 changes: 24 additions & 17 deletions ecommerce/processes/test/reservation_process_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ class ReservationProcessTest < Test

def test_happy_path
process = ReservationProcess.new
given([order_submitted]).each { |event| process.call(event) }
assert_success_event do
given([order_submitted]).each { |event| process.call(event) }
end
assert_all_commands(
Inventory::Reserve.new(product_id: product_id, quantity: 1),
Inventory::Reserve.new(product_id: another_product_id, quantity: 2),
Expand All @@ -26,28 +28,19 @@ def call(command)
end
end

def test_reject_order_command_is_dispatched_when_sth_is_unavailable
def test_rejects_order_and_compensates_stock_when_sth_is_unavailable
failing_command = Inventory::Reserve.new(product_id: product_id, quantity: 1)
enhanced_command_bus = EnhancedFakeCommandBus.new(command_bus, failing_command => Inventory::InventoryEntry::InventoryNotAvailable)
process = ReservationProcess.new
process.command_bus = enhanced_command_bus
given([order_submitted]).each { |event| process.call(event) }
assert_all_commands(
failing_command,
Ordering::RejectOrder.new(order_id: order_id)
)
end

def test_compensation_when_sth_is_unavailable
failing_command = Inventory::Reserve.new(product_id: another_product_id, quantity: 2)
enhanced_command_bus = EnhancedFakeCommandBus.new(command_bus, failing_command => Inventory::InventoryEntry::InventoryNotAvailable)
process = ReservationProcess.new
process.command_bus = enhanced_command_bus
given([order_submitted]).each { |event| process.call(event) }
assert_failure_event do
given([order_submitted]).each { |event| process.call(event) }
end
assert_all_commands(
Inventory::Reserve.new(product_id: product_id, quantity: 1),
failing_command,
Inventory::Release.new(product_id: product_id, quantity: 1),
Inventory::Reserve.new(product_id: another_product_id, quantity: 2),
Inventory::Release.new(product_id: another_product_id, quantity: 2),
Ordering::RejectOrder.new(order_id: order_id)
)
end
Expand Down Expand Up @@ -104,5 +97,19 @@ def order_cancelled
}
)
end

def assert_success_event(&block)
assert_events_contain(
"ReservationProcess$#{order_id}",
ReservationProcessSuceeded.new(data: { order_id: order_id }),
&block
)
end

def assert_failure_event
assert_events_contain("ReservationProcess$#{order_id}", ReservationProcessFailed.new(data: { order_id: order_id, unavailable_products: [product_id] })) do
yield
end
end
end
end
end
2 changes: 1 addition & 1 deletion rails_application/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,4 @@ yarn-debug.log*

# Event to handlers and handler to events mappings generated by big_picture.rb script
/lib/event_to_handlers.rb
/lib/handler_to_events.rb
/lib/handler_to_events.rb
1 change: 1 addition & 0 deletions rails_application/.mutant.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,4 @@ matcher:
- Orders::UpdateOrderTotalValue*
- Orders::SubmitOrder*
- Orders::AssignCustomerToOrder*
- Orders::SubmitService#submit_order
11 changes: 5 additions & 6 deletions rails_application/app/controllers/client/orders_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,12 @@ def new
end

def create
ActiveRecord::Base.transaction do
command_bus.(Ordering::SubmitOrder.new(order_id: params[:order_id]))
command_bus.(Crm::AssignCustomerToOrder.new(customer_id: cookies[:client_id], order_id: params[:order_id]))
Orders::SubmitService.new(order_id: params[:order_id], customer_id: cookies[:client_id]).call.then do |result|
result.path(:success) { redirect_to client_order_path(params[:order_id]), notice: "Your order is being submitted" }
result.path(:products_out_of_stock) { |unavailable_products| redirect_to edit_client_order_path(params[:order_id]),
alert: "Order can not be submitted! #{unavailable_products.join(", ")} not available in requested quantity!"}
result.path(:order_is_empty) { redirect_to edit_client_order_path(params[:order_id]), alert: "You can't submit an empty order" }
end
redirect_to client_order_path(params[:order_id]), notice: "Your order is being submitted"
rescue Ordering::Order::IsEmpty
redirect_to edit_client_order_path(params[:order_id]), alert: "You can't submit an empty order"
end

def show
Expand Down
18 changes: 7 additions & 11 deletions rails_application/app/controllers/orders_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,13 @@ def remove_item
end

def create
ApplicationRecord.transaction { submit_order(params[:order_id], params[:customer_id]) }
redirect_to order_path(params[:order_id]), notice: "Your order is being submitted"
rescue Ordering::Order::IsEmpty
redirect_to edit_order_path(params[:order_id]), alert: "You can't submit an empty order"
rescue Crm::Customer::NotExists
redirect_to order_path(params[:order_id]), alert: "Order can not be submitted! Customer does not exist."
Orders::SubmitService.new(order_id: params[:order_id], customer_id: params[:customer_id]).call.then do |result|
result.path(:success) { redirect_to order_path(params[:order_id]), notice: "Your order is being submitted" }
result.path(:products_out_of_stock) { |unavailable_products| redirect_to edit_order_path(params[:order_id]),
alert: "Order can not be submitted! #{unavailable_products.join(", ")} not available in requested quantity!"}
result.path(:order_is_empty) { redirect_to edit_order_path(params[:order_id]), alert: "You can't submit an empty order" }
result.path(:customer_not_exists) { redirect_to order_path(params[:order_id]), alert: "Order can not be submitted! Customer does not exist." }
end
end

def expire
Expand Down Expand Up @@ -113,11 +114,6 @@ def cancel

private

def submit_order(order_id, customer_id)
command_bus.(Ordering::SubmitOrder.new(order_id: order_id))
command_bus.(Crm::AssignCustomerToOrder.new(order_id: order_id, customer_id: customer_id))
end

def authorize_payment(order_id)
command_bus.call(authorize_payment_cmd(order_id))
end
Expand Down
13 changes: 13 additions & 0 deletions rails_application/app/services/application_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
class ApplicationService
def self.call(...)
new(...).call
end

def event_store
Rails.configuration.event_store
end

def command_bus
Rails.configuration.command_bus
end
end
42 changes: 42 additions & 0 deletions rails_application/app/services/orders/submit_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
module Orders
class SubmitService < ApplicationService
def initialize(order_id:, customer_id:)
@order_id = order_id
@customer_id = customer_id
end

def call
success = true
unavailable_products = []

event_store
.within { submit_order }
.subscribe(to: Processes::ReservationProcessFailed) do |event|
success = false
unavailable_products << Products::Product.where(id: event.data.fetch(:unavailable_products)).pick(:name)
end
.call

if success
Result.new(:success)
else
Result.new(:products_out_of_stock, unavailable_products)
end
rescue Ordering::Order::IsEmpty
Result.new(:order_is_empty)
rescue Crm::Customer::NotExists
Result.new(:customer_not_exists)
end

private

attr_reader :order_id, :customer_id

def submit_order
ActiveRecord::Base.transaction do
command_bus.(Ordering::SubmitOrder.new(order_id: order_id))
command_bus.(Crm::AssignCustomerToOrder.new(order_id: order_id, customer_id: customer_id))
end
end
end
end
14 changes: 14 additions & 0 deletions rails_application/app/services/result.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
class Result
attr_reader :status, :args

def initialize(status, *args)
@status = status
@args = args
end

def path(name, &block)
return unless @status == name.to_sym

block.call(*@args)
end
end
16 changes: 16 additions & 0 deletions rails_application/test/integration/orders_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,22 @@ def test_empty_order_cannot_be_submitted
assert_select "#alert", "You can't submit an empty order"
end

def test_order_cannot_be_submitted_with_out_of_stock_product
product_id = register_product("Fearless Refactoring", 4, 10)
shopify_id = register_customer("Shopify")

supply_product(product_id, 1)
order_1_id = SecureRandom.uuid
order_2_id = SecureRandom.uuid
post "/orders/#{order_1_id}/add_item?product_id=#{product_id}"
post "/orders/#{order_2_id}/add_item?product_id=#{product_id}"

post "/orders", params: { order_id: order_1_id, customer_id: shopify_id }
post "/orders", params: { order_id: order_2_id, customer_id: shopify_id }

assert_equal "Order can not be submitted! Fearless Refactoring not available in requested quantity!", flash[:alert]
end

private

def assert_remove_buttons_visible(async_remote_id, fearless_id, order_id)
Expand Down
Loading
Loading