Files
openfoodnetwork/spec/controllers/checkout_controller_spec.rb
Gaetan Craig-Riou 66041061fb Redeem VINE voucher before redirecting to payment url
When using paypal, we need to redeem the voucher before redirecting to
the payment gateway url, otherwise the voucher will never get redeemed.
2025-08-11 16:28:04 +10:00

718 lines
23 KiB
Ruby

# frozen_string_literal: true
require 'spec_helper'
RSpec.describe CheckoutController do
let(:user) { order.user }
let(:address) { create(:address) }
let(:distributor) { create(:distributor_enterprise, with_payment_and_shipping: true) }
let(:order_cycle) { create(:order_cycle, distributors: [distributor]) }
let(:exchange) { order_cycle.exchanges.outgoing.first }
let(:order) { create(:order_with_line_items, line_items_count: 1, distributor:, order_cycle:) }
let(:payment_method) { distributor.payment_methods.first }
let(:shipping_method) { distributor.shipping_methods.first }
before do
exchange.variants << order.line_items.first.variant
allow(controller).to receive(:current_order) { order }
allow(controller).to receive(:spree_current_user) { user }
end
describe "#edit" do
it "renders the checkout" do
get :edit, params: { step: "details" }
expect(response).to have_http_status :ok
end
it "redirects to current step if no step is given" do
get :edit
expect(response).to redirect_to checkout_step_path(:details)
end
context "when line items in the cart are not valid" do
before { allow(controller).to receive(:valid_order_line_items?) { false } }
it "redirects to cart" do
get :edit
expect(response).to redirect_to cart_path
end
end
context "when the given `step` params is inconsistent with the current order state" do
context "when order state is `cart`" do
before do
order.update!(state: "cart")
end
it "redirects to the valid step if params is `payment`" do
get :edit, params: { step: "payment" }
expect(response).to redirect_to checkout_step_path(:details)
end
it "redirects to the valid step if params is `summary`" do
get :edit, params: { step: "summary" }
expect(response).to redirect_to checkout_step_path(:details)
end
end
context "when order state is `payment`" do
before do
order.update!(state: "payment")
end
it "redirects to the valid step if params is `summary`" do
get :edit, params: { step: "summary" }
expect(response).to redirect_to checkout_step_path(:payment)
end
end
context "when order state is 'confirmation'" do
before do
order.update!(state: "confirmation")
end
context "when loading payment step" do
it "updates the order state to payment" do
get :edit, params: { step: "payment" }
expect(response).to have_http_status :ok
expect(order.reload.state).to eq("payment")
end
end
context "when loading address step" do
it "updates the order state to address" do
get :edit, params: { step: "details" }
expect(response).to have_http_status :ok
expect(order.reload.state).to eq("address")
end
end
end
context "when order state is 'payment'" do
context "when loading address step" do
before do
order.update!(state: "payment")
end
it "updates the order state to address" do
get :edit, params: { step: "details" }
expect(response).to have_http_status :ok
expect(order.reload.state).to eq("address")
end
end
end
end
end
describe "#update" do
let(:checkout_params) { {} }
let(:params) { { step: }.merge(checkout_params) }
context "details step" do
let(:step) { "details" }
context "with incomplete data" do
let(:checkout_params) { { order: { email: user.email } } }
it "returns 422 and some feedback" do
put(:update, params:)
expect(response).to have_http_status :unprocessable_entity
expect(flash[:error]).to match "Saving failed, please update the highlighted fields."
expect(order.reload.state).to eq "cart"
end
end
context "with complete data" do
let(:checkout_params) do
{
order: {
email: user.email,
bill_address_attributes: address.to_param,
ship_address_attributes: address.to_param
},
shipping_method_id: shipping_method.id
}
end
it "updates and redirects to payment step" do
put(:update, params:)
expect(response).to redirect_to checkout_step_path(:payment)
expect(order.reload.state).to eq "payment"
end
context "with insufficient stock" do
it "redirects to details page" do
allow(order).to receive_message_chain(:insufficient_stock_lines,
:empty?).and_return false
put(:update, params:)
expect_cable_ready_redirect(response)
end
end
describe "saving default addresses" do
it "doesn't update default bill address on user" do
expect {
put :update, params: params.merge(order: { save_bill_address: "0" })
}.not_to change {
order.user.reload.bill_address
}
end
it "updates default bill address on user and customer" do
put :update, params: params.merge(order: { save_bill_address: "1" })
expect(order.customer.bill_address).to eq(order.bill_address)
expect(order.user.bill_address).to eq(order.bill_address)
end
it "doesn't update default ship address on user" do
expect {
put :update, params: params.merge(order: { save_ship_address: "0" })
}.not_to change {
order.user.reload.ship_address
}
end
it "updates default ship address on user and customer" do
put :update, params: params.merge(order: { save_ship_address: "1" })
expect(order.customer.ship_address).to eq(order.ship_address)
expect(order.user.ship_address).to eq(order.ship_address)
end
end
describe "with a voucher" do
let(:checkout_params) do
{
order: {
email: user.email,
bill_address_attributes: address.to_param,
ship_address_attributes: address.to_param
},
shipping_method_id: order.shipment.shipping_method.id.to_s
}
end
let(:voucher) { create(:voucher_flat_rate, enterprise: distributor) }
let(:service) { mock_voucher_adjustment_service }
before do
voucher.create_adjustment(voucher.code, order)
end
it "doesn't recalculate the voucher adjustment" do
expect(service).not_to receive(:update)
put(:update, params:)
expect(response).to redirect_to checkout_step_path(:payment)
end
context "when updating shipping method" do
let(:checkout_params) do
{
order: {
email: user.email,
bill_address_attributes: address.to_param,
ship_address_attributes: address.to_param
},
shipping_method_id: new_shipping_method.id.to_s
}
end
let(:new_shipping_method) { create(:shipping_method, distributors: [distributor]) }
before do
# Add a shipping rates for the new shipping method to prevent
# order.select_shipping_method from failing
order.shipment.shipping_rates <<
Spree::ShippingRate.create(shipping_method: new_shipping_method, selected: true)
end
it "recalculates the voucher adjustment" do
expect(service).to receive(:update)
put(:update, params:)
expect(response).to redirect_to checkout_step_path(:payment)
end
context "when no shipments available" do
before do
order.shipments.destroy_all
end
it "recalculates the voucher adjustment" do
expect(service).to receive(:update)
put(:update, params:)
expect(response).to redirect_to checkout_step_path(:payment)
end
end
end
end
end
end
context "payment step" do
let(:step) { "payment" }
before do
order.bill_address = address
order.ship_address = address
order.select_shipping_method shipping_method.id
Orders::WorkflowService.new(order).advance_to_payment
end
context "with incomplete data" do
let(:checkout_params) { { order: { email: user.email } } }
it "returns 422 and some feedback" do
put(:update, params:)
expect(response).to have_http_status :unprocessable_entity
expect(flash[:error]).to match "Saving failed, please update the highlighted fields."
expect(order.reload.state).to eq "payment"
end
end
context "with complete data" do
let(:checkout_params) do
{
order: {
payments_attributes: [
{ payment_method_id: payment_method.id }
]
}
}
end
it "updates and redirects to summary step" do
put(:update, params:)
expect(response).to redirect_to checkout_step_path(:summary)
expect(order.reload.state).to eq "confirmation"
end
describe "with a voucher" do
let(:voucher) { create(:voucher_flat_rate, enterprise: distributor) }
before do
voucher.create_adjustment(voucher.code, order)
end
# so we need to recalculate voucher to account for payment fees
it "recalculates the voucher adjustment" do
service = mock_voucher_adjustment_service
expect(service).to receive(:update)
put(:update, params:)
expect(response).to redirect_to checkout_step_path(:summary)
end
end
context "with insufficient stock" do
it "redirects to details page" do
allow(order).to receive_message_chain(:insufficient_stock_lines,
:empty?).and_return false
put(:update, params:)
expect_cable_ready_redirect(response)
end
end
context "with existing invalid payments" do
let(:invalid_payments) {
[
create(:payment, state: :failed),
create(:payment, state: :void),
]
}
before do
order.payments = invalid_payments
end
it "deletes invalid payments" do
expect{
put(:update, params:)
}.to change { order.payments.to_a }.from(invalid_payments)
end
end
context "with different payment method previously chosen" do
let(:other_payment_method) { build(:payment_method, distributors: [distributor]) }
let(:other_payment) {
build(:payment, amount: order.total, payment_method: other_payment_method)
}
before do
order.payments = [other_payment]
end
context "and order is in an earlier state" do
# This revealed obscure bug #12693. If you progress to order summary, go back to payment
# method, then open delivery details in a new tab (or hover over the link with Turbo
# enabled), then submit new payment details, this happens.
before do
order.back_to_address
end
it "deletes invalid (old) payments" do
put(:update, params:)
order.payments.reload
expect(order.payments).not_to include other_payment
end
end
end
end
context "with no payment source" do
let(:checkout_params) do
{
order: {
payments_attributes: [
{
payment_method_id:,
source_attributes: {
first_name: "Jane",
last_name: "Doe",
month: "",
year: "",
cc_type: "",
last_digits: "",
gateway_payment_profile_id: ""
}
}
]
},
commit: "Next - Order Summary"
}
end
context "with a cash/check payment method" do
let!(:payment_method_id) { payment_method.id }
it "updates and redirects to summary step" do
put(:update, params:)
expect(response).to have_http_status :found
expect(response).to redirect_to checkout_step_path(:summary)
expect(order.reload.state).to eq "confirmation"
end
end
context "with a StripeSCA payment method" do
let(:stripe_payment_method) {
create(:stripe_sca_payment_method, distributor_ids: [distributor.id],
environment: Rails.env)
}
let!(:payment_method_id) { stripe_payment_method.id }
it "updates and redirects to summary step" do
put(:update, params:)
expect(response).to have_http_status :unprocessable_entity
expect(flash[:error]).to match "Saving failed, please update the highlighted fields."
expect(order.reload.state).to eq "payment"
end
end
end
context "with payment fees" do
let(:payment_method_with_fee) do
create(:payment_method, :flat_rate, amount: "1.23", distributors: [distributor])
end
let(:checkout_params) do
{
order: {
payments_attributes: [
{ payment_method_id: payment_method_with_fee.id }
]
}
}
end
it "applies the fee and updates the order total" do
put(:update, params:)
expect(response).to redirect_to checkout_step_path(:summary)
order.reload
expect(order.state).to eq "confirmation"
expect(order.payments.first.adjustment.amount).to eq 1.23
expect(order.payments.first.amount).to eq order.item_total + order.adjustment_total
expect(order.adjustment_total).to eq 1.23
expect(order.total).to eq order.item_total + order.adjustment_total
end
end
context "with a zero-priced order" do
let(:params) do
{ step: "payment", order: { payments_attributes: [{ amount: 0 }] } }
end
before do
order.line_items.first.update(price: 0)
order.update_totals_and_states
end
it "allows proceeding to confirmation" do
put(:update, params:)
expect(response).to redirect_to checkout_step_path(:summary)
expect(order.reload.state).to eq "confirmation"
expect(order.payments.count).to eq 1
expect(order.payments.first.amount).to eq 0
end
end
context "with a saved credit card" do
let!(:saved_card) { create(:stored_credit_card, user:) }
let(:checkout_params) do
{
order: {
payments_attributes: [
{ payment_method_id: payment_method.id }
]
},
existing_card_id: saved_card.id
}
end
it "updates and redirects to payment step" do
put(:update, params:)
expect(response).to redirect_to checkout_step_path(:summary)
expect(order.reload.state).to eq "confirmation"
expect(order.payments.first.source.id).to eq saved_card.id
end
end
end
context "summary step" do
let(:step) { "summary" }
let(:checkout_params) { { confirm_order: "Complete order" } }
before do
order.bill_address = address
order.ship_address = address
order.select_shipping_method shipping_method.id
Orders::WorkflowService.new(order).advance_to_payment
order.payments << build(:payment, amount: order.total, payment_method:)
order.next
end
describe "confirming the order" do
it "completes the order and redirects to order confirmation" do
put(:update, params:)
expect(response).to redirect_to order_path(order, order_token: order.token)
expect(order.reload.state).to eq "complete"
end
it "syncs stock before locking the order" do
actions = []
expect(StockSyncJob).to receive(:sync_linked_catalogs_now) do
actions << "sync stock"
end
expect(CurrentOrderLocker).to receive(:around) do
actions << "lock order"
end
put(:update, params:)
expect(actions).to eq ["sync stock", "lock order"]
end
end
context "with insufficient stock" do
it "redirects to details page" do
allow(order).to receive_message_chain(:insufficient_stock_lines, :empty?).and_return false
put(:update, params:)
expect_cable_ready_redirect(response)
end
end
context "when accepting T&Cs is required" do
before do
allow(TermsOfService).to receive(:platform_terms_required?) { true }
end
describe "submitting without accepting the T&Cs" do
let(:checkout_params) { {} }
it "returns 422 and some feedback" do
put(:update, params:)
expect(response).to have_http_status :unprocessable_entity
expect(flash[:error]).to match "Saving failed, please update the highlighted fields."
expect(order.reload.state).to eq "confirmation"
end
end
describe "submitting and accepting the T&Cs" do
let(:checkout_params) { { accept_terms: true } }
it "completes the order and redirects to order confirmation" do
put(:update, params:)
expect(response).to redirect_to order_path(order, order_token: order.token)
expect(order.reload.state).to eq "complete"
end
end
end
context "with a VINE voucher", feature: :connected_apps do
let(:vine_voucher) {
create(:vine_voucher, code: 'some_code', enterprise: distributor, amount: 6)
}
let(:vine_voucher_redeemer) { instance_double(Vine::VoucherRedeemerService) }
before do
# Adding voucher to the order
vine_voucher.create_adjustment(vine_voucher.code, order)
OrderManagement::Order::Updater.new(order).update_voucher
allow(Vine::VoucherRedeemerService).to receive(:new).and_return(vine_voucher_redeemer)
end
it "completes the order and redirects to order confirmation" do
expect(vine_voucher_redeemer).to receive(:redeem).and_return(true)
put(:update, params:)
expect(response).to redirect_to order_path(order, order_token: order.token)
expect(order.reload.state).to eq "complete"
end
context "when redeeming the voucher fails" do
it "returns 422 and some error" do
allow(vine_voucher_redeemer).to receive(:redeem).and_return(false)
allow(vine_voucher_redeemer).to receive(:errors).and_return(
{ redeeming_failed: "Redeeming the voucher failed" }
)
put(:update, params:)
expect(response).to have_http_status :unprocessable_entity
expect(flash[:error]).to match "Redeeming the voucher failed"
end
end
context "when an other error happens" do
it "returns 422 and some error" do
allow(vine_voucher_redeemer).to receive(:redeem).and_return(false)
allow(vine_voucher_redeemer).to receive(:errors).and_return(
{ vine_api: "There was an error communicating with the API" }
)
put(:update, params:)
expect(response).to have_http_status :unprocessable_entity
expect(flash[:error]).to match "There was an error while trying to redeem your voucher"
end
end
context "when an external payment gateway is used" do
before do
expect(payment_method).to receive(:external_gateway?) { true }
expect(payment_method).to receive(:external_payment_url) { "https://example.com/pay" }
mock_payment_method_fetcher(payment_method)
end
it "redeems the voucher and redirect to the payment gateway's URL" do
expect(vine_voucher_redeemer).to receive(:redeem).and_return(true)
put(:update, params:)
expect(response.body).to match("https://example.com/pay").and match("redirect")
expect(order.reload.state).to eq "confirmation"
end
end
end
context "when an external payment gateway is used" do
before do
expect(payment_method).to receive(:external_gateway?) { true }
expect(payment_method).to receive(:external_payment_url) { "https://example.com/pay" }
mock_payment_method_fetcher(payment_method)
end
describe "confirming the order" do
it "redirects to the payment gateway's URL" do
put(:update, params:)
expect(response.body).to match("https://example.com/pay").and match("redirect")
expect(order.reload.state).to eq "confirmation"
end
end
end
end
end
describe "running out of stock" do
let(:order_cycle_distributed_variants) { double(:order_cycle_distributed_variants) }
before do
allow(controller).to receive(:current_order).and_return(order)
allow(order).to receive(:distributor).and_return(distributor)
order.update(order_cycle:)
allow(OrderCycles::DistributedVariantsService).to receive(:new).and_return(
order_cycle_distributed_variants
)
end
shared_examples "handling not available items" do |step|
context "#{step} step" do
let(:step) { step.to_s }
it "redirects when some items are not available" do
allow(order).to receive_message_chain(:insufficient_stock_lines, :empty?).and_return true
expect(order_cycle_distributed_variants).to receive(
:distributes_order_variants?
).with(order).and_return(false)
get :edit
expect(response).to redirect_to cart_path
end
end
end
it_behaves_like "handling not available items", "details"
it_behaves_like "handling not available items", "payment"
it_behaves_like "handling not available items", "summary"
end
def mock_voucher_adjustment_service
voucher_adjustment_service = instance_double(VoucherAdjustmentsService)
allow(VoucherAdjustmentsService).to receive(:new).and_return(voucher_adjustment_service)
voucher_adjustment_service
end
def expect_cable_ready_redirect(response)
expect(response.parsed_body).to eq(
[{ "url" => "/checkout/details", "operation" => "redirectTo" }].to_json
)
end
def mock_payment_method_fetcher(payment_method)
payment_method_fetcher = instance_double(Checkout::PaymentMethodFetcher, call: payment_method)
expect(Checkout::PaymentMethodFetcher).to receive(:new).and_return(payment_method_fetcher)
end
end