Skip to content

Commit

Permalink
Better error when submitting order with out of stock product
Browse files Browse the repository at this point in the history
  • Loading branch information
marlena-b authored and andrzejkrzywda committed Sep 3, 2024
1 parent 53100a8 commit 690d65d
Show file tree
Hide file tree
Showing 17 changed files with 321 additions and 51 deletions.
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.of(Infra::Types::UUID)
end

class ReservationProcessSuceeded < Infra::Event
attribute :order_id, Infra::Types::UUID
end
end
36 changes: 26 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,29 @@ 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
release_stock(state)
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) { reject_order(state) }
.subscribe(to: ReservationProcessSuceeded) { accept_order(state) }
.call
end

def release_stock(state)
Expand All @@ -54,8 +66,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 +109,4 @@ def product_reserved(product_id)
end
end
end
end
end
43 changes: 26 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,21 @@ 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(&block)
assert_events_contain(
"ReservationProcess$#{order_id}",
ReservationProcessFailed.new(data: { order_id: order_id, unavailable_products: [product_id] }),
&block
)
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
17 changes: 17 additions & 0 deletions rails_application/app/services/application_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
class ApplicationService
def self.call(...)
new(...).call
end

def call
raise NotImplementedError
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 = nil

event_store
.within { submit_order }
.subscribe(to: Processes::ReservationProcessFailed) do |event|
success = false
unavailable_products = Products::Product.where(id: event.data.fetch(:unavailable_products)).pluck(: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

0 comments on commit 690d65d

Please sign in to comment.