diff --git a/ecommerce/processes/.mutant.yml b/ecommerce/processes/.mutant.yml index c082e82b..fd81a444 100644 --- a/ecommerce/processes/.mutant.yml +++ b/ecommerce/processes/.mutant.yml @@ -16,5 +16,4 @@ matcher: - Processes::SyncShipmentFromPricing* - Processes::SyncInventoryFromOrdering* - Processes::NotifyPaymentsAboutOrderValue* - - Processes::ThreePlusOneFree* - Processes::DetermineVatRatesOnOrderPlaced* diff --git a/ecommerce/processes/lib/processes/three_plus_one_free.rb b/ecommerce/processes/lib/processes/three_plus_one_free.rb index aad027a8..97f764c2 100644 --- a/ecommerce/processes/lib/processes/three_plus_one_free.rb +++ b/ecommerce/processes/lib/processes/three_plus_one_free.rb @@ -1,96 +1,70 @@ module Processes class ThreePlusOneFree - include Infra::Retry - def initialize(event_store, command_bus) - @event_store = event_store - @command_bus = command_bus - @event_store.subscribe( - self, - to: [ - Pricing::PriceItemAdded, - Pricing::PriceItemRemoved, - Pricing::ProductMadeFreeForOrder, - Pricing::FreeProductRemovedFromOrder - ] - ) - end - - def call(event) - state = build_state(event) - return if event_only_for_state_building?(event) - - make_or_remove_free_product(state) - end + ProcessState = Data.define(:lines, :free_product) do + def initialize(lines: [], free_product: nil) + super(lines: lines.freeze, free_product:) + end - private + MIN_ORDER_LINES_QUANTITY = 4 - def build_state(event) - with_retry do - stream_name = "ThreePlusOneFreeProcess$#{event.data.fetch(:order_id)}" - past_events = @event_store.read.stream(stream_name).to_a - last_stored = past_events.size - 1 - @event_store.link(event.event_id, stream_name: stream_name, expected_version: last_stored) - ProcessState.new(event.data.fetch(:order_id)).tap do |state| - past_events.each { |ev| state.call(ev) } - state.call(event) + def eligible_free_product + if lines.size >= MIN_ORDER_LINES_QUANTITY + lines.sort_by { _1.fetch(:price) }.first.fetch(:product_id) end end end - def make_or_remove_free_product(state) - free_product_id = state.cheapest_product + include Infra::ProcessManager.with_state(ProcessState) - return if current_free_product_not_changed?(free_product_id, state) + subscribes_to( + Pricing::PriceItemAdded, + Pricing::PriceItemRemoved, + Pricing::ProductMadeFreeForOrder, + Pricing::FreeProductRemovedFromOrder + ) - remove_old_free_product(state) - make_new_product_for_free(state, free_product_id) - end + private - def event_only_for_state_building?(event) - event.instance_of?(Pricing::FreeProductRemovedFromOrder) || event.instance_of?(Pricing::ProductMadeFreeForOrder) + def apply(event) + product_id = event.data.fetch(:product_id) + case event + when Pricing::PriceItemAdded + lines = (state.lines + [{ product_id:, price: event.data.fetch(:price) }]) + state.with(lines:) + when Pricing::PriceItemRemoved + lines = state.lines.dup + index_of_line_to_remove = lines.index { |line| line.fetch(:product_id) == product_id } + lines.delete_at(index_of_line_to_remove) + state.with(lines:) + when Pricing::ProductMadeFreeForOrder + state.with(free_product: product_id) + when Pricing::FreeProductRemovedFromOrder + state.with(free_product: nil) + end end - def current_free_product_not_changed?(free_product_id, state) - free_product_id == state.current_free_product_id + def act + case [state.free_product, state.eligible_free_product] + in [the_same_product, ^the_same_product] + in [nil, new_free_product] + make_new_product_for_free(new_free_product) + in [old_free_product, *] + remove_old_free_product(old_free_product) + else + end end - def remove_old_free_product(state) - @command_bus.call(Pricing::RemoveFreeProductFromOrder.new(order_id: state.order_id, product_id: state.current_free_product_id)) if state.current_free_product_id + def remove_old_free_product(product_id) + command_bus.call(Pricing::RemoveFreeProductFromOrder.new(order_id: id, product_id:)) end - def make_new_product_for_free(state, free_product_id) - @command_bus.call(Pricing::MakeProductFreeForOrder.new(order_id: state.order_id, product_id: free_product_id)) if free_product_id + def make_new_product_for_free(product_id) + command_bus.call(Pricing::MakeProductFreeForOrder.new(order_id: id, product_id:)) end - class ProcessState - attr_reader :order_id, :order_lines, :current_free_product_id - - def initialize(order_id) - @order_id = order_id - @order_lines = [] - end - - def call(event) - product_id = event.data.fetch(:product_id) - case event - when Pricing::PriceItemAdded - order_lines << { product_id: event.data.fetch(:product_id), price: event.data.fetch(:price) } - when Pricing::PriceItemRemoved - index_of_line_to_remove = order_lines.index { |line| line.fetch(:product_id) == product_id && line.fetch(:price) == event.data.fetch(:price) } - @order_lines.delete_at(index_of_line_to_remove) - @current_free_product_id = nil if event.data.fetch(:price) == 0 - when Pricing::ProductMadeFreeForOrder - @current_free_product_id = product_id - when Pricing::FreeProductRemovedFromOrder - @current_free_product_id = nil - end - end - - MIN_ORDER_LINES_QUANTITY = 4 - def cheapest_product - order_lines.sort_by { |line| line.fetch(:price) }.first.fetch(:product_id) if order_lines.size >= MIN_ORDER_LINES_QUANTITY - end + def fetch_id(event) + event.data.fetch(:order_id) end end end diff --git a/infra/lib/infra/process_manager.rb b/infra/lib/infra/process_manager.rb index b0ab5eb6..a5d13a51 100644 --- a/infra/lib/infra/process_manager.rb +++ b/infra/lib/infra/process_manager.rb @@ -7,6 +7,7 @@ def initialize(event_store, command_bus) end def call(event) + @state = initial_state @id = fetch_id(event) build_state(event) act @@ -45,8 +46,12 @@ def subscribes_to(*events) def self.with_state(state_class) Module.new do - define_method :state do - @state ||= state_class.new + define_method :initial_state do + state_class.new + end + + def state + @state ||= initial_state end def self.included(host_class)