Skip to content
Open
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
18 changes: 17 additions & 1 deletion core/app/models/workarea/checkout/steps/payment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,31 @@ def update(params = {})
# Requires:
# * order to be purchasable
# * payment purchasable for order total_price
# * credit card, if present, must have a positive authorization amount
# (a zero-amount credit card indicates no real authorization will occur)
#
# @return [Boolean]
#
def complete?
order.purchasable? && payment.purchasable?(order.total_price)
order.purchasable? &&
payment.purchasable?(order.total_price) &&
credit_card_authorizable?
end

private

# Ensure a credit card tender, if one is set, will actually be
# authorized (i.e. has a positive amount). A zero-amount credit card
# results in a no-op authorization and should not be treated as a
# complete payment.
#
# @return [Boolean]
#
def credit_card_authorizable?
return true unless payment.credit_card?
payment.credit_card.amount > 0.to_m
end

def set_payment_profile
payment.set(profile_id: payment_profile.try(:id))
end
Expand Down
9 changes: 7 additions & 2 deletions core/app/models/workarea/metrics/by_week.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,14 @@ module ByWeek
index({ reporting_on: 1 }, { expire_after_seconds: 2.years.seconds.to_i })

scope :last_week, -> do
# Use date math to avoid DST-related boundary shifts.
# We want the previous calendar week, not "now minus 7 days".
start_of_this_week = Time.current.to_date.beginning_of_week
start_of_last_week = start_of_this_week - 1.week

where(
:reporting_on.gte => Time.current.last_week,
:reporting_on.lt => Time.current.last_week.end_of_week
:reporting_on.gte => start_of_last_week.in_time_zone,
:reporting_on.lt => start_of_this_week.in_time_zone
)
end
end
Expand Down
8 changes: 6 additions & 2 deletions core/app/models/workarea/metrics/scoring.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,12 @@ def score(field)
end

def weeks_ago
difference = Time.current.beginning_of_week - reporting_on.beginning_of_week
difference / 1.week
# Use date math instead of second-based math to avoid DST boundary issues.
# (e.g. a "week" containing a DST shift is not always 7 * 24 hours)
current_week = Time.current.to_date.beginning_of_week
reporting_week = reporting_on.to_date.beginning_of_week

((current_week - reporting_week) / 7).to_i
end
end
end
Expand Down
13 changes: 11 additions & 2 deletions core/app/models/workarea/pricing/request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,17 @@ def all_skus

def save_order
# as_document won't contain items in the hash if there isn't any items left.
# ensure the items get cleared out when this happens
@persisted_order.update_attributes!(order.as_document.reverse_merge(items: []))
# ensure the items get cleared out when this happens.
#
# Use as_json for a deep-JSON-safe conversion of the document — Mongoid 7's
# as_document returns nested BSON::Document instances for embedded fields
# (e.g. Money hashes) which update_attributes! cannot properly deserialize,
# causing pricing totals to silently remain at zero. as_json recursively
# converts all values to JSON-safe plain Ruby types that Mongoid can cast.
# Using a string key for "items" avoids creating both symbol and string keys
# which can cause unpredictable attribute-merge behavior.
attrs = order.as_json.reverse_merge("items" => [])
@persisted_order.update_attributes!(attrs)
cache_key.order = @persisted_order
@persisted_order.set(pricing_cache_key: cache_key.to_s)
end
Expand Down
3 changes: 1 addition & 2 deletions core/app/queries/workarea/search/admin_index_search.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,7 @@ def query
{
query_string: {
query: sanitized_query,
fields: fields,
use_dis_max: true
fields: fields
}
},
{
Expand Down
3 changes: 1 addition & 2 deletions core/app/queries/workarea/search/help_search.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,7 @@ def query
{
query_string: {
query: sanitized_query,
fields: %w(name^1.5 facets^0.75 body),
use_dis_max: true
fields: %w(name^1.5 facets^0.75 body)
}
}
end
Expand Down
1 change: 0 additions & 1 deletion core/app/queries/workarea/search/product_search.rb
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,6 @@ def general_search_clause
query_string: {
query: customization.rewrite,
fields: boosted_fields,
use_dis_max: true,
default_operator: default_operator,
tie_breaker: Workarea.config.search_dismax_tie_breaker
}
Expand Down
2 changes: 2 additions & 0 deletions core/app/workers/workarea/bust_navigation_cache.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ def perform(id)
taxon = Navigation::Taxon.find(id)
taxon.ancestors.each(&:touch)
taxon.descendants.each(&:touch)
rescue Mongoid::Errors::DocumentNotFound
nil
end
end
end
2 changes: 2 additions & 0 deletions core/app/workers/workarea/generate_promo_codes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ class GeneratePromoCodes

def perform(id)
Pricing::Discount::CodeList.find(id).generate_promo_codes!
rescue Mongoid::Errors::DocumentNotFound
nil
end
end
end
2 changes: 2 additions & 0 deletions core/app/workers/workarea/index_categorization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ def self.perform(category)
def perform(id)
category = Catalog::Category.find(id)
self.class.perform(category)
rescue Mongoid::Errors::DocumentNotFound
nil
end
end
end
2 changes: 2 additions & 0 deletions core/app/workers/workarea/index_product_rule.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ class IndexProductRule
def perform(id)
product_list = Catalog::Category.find(id)
IndexCategorization.perform(product_list)
rescue Mongoid::Errors::DocumentNotFound
nil
end
end
end
2 changes: 2 additions & 0 deletions core/app/workers/workarea/mark_discounts_as_redeemed.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ def perform(order_id)
order = Order.find(order_id)
shippings = Shipping.where(order_id: order_id).to_a
mark_redeemed(order, shippings)
rescue Mongoid::Errors::DocumentNotFound
nil
end

def mark_redeemed(order, shippings)
Expand Down
2 changes: 2 additions & 0 deletions core/app/workers/workarea/save_order_metrics.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ def save_user(metrics)

def perform(order_id)
self.class.perform(Order.find(order_id))
rescue Mongoid::Errors::DocumentNotFound
nil
end
end
end
2 changes: 2 additions & 0 deletions core/app/workers/workarea/save_user_order_details.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ def perform(order_id)

save_payment_details(order, user)
save_shipping_details(order, user)
rescue Mongoid::Errors::DocumentNotFound
nil
end

def save_payment_details(order, user)
Expand Down
2 changes: 2 additions & 0 deletions core/app/workers/workarea/send_refund_email.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ def perform(id)
refund = Payment::Refund.find(id)
return if refund.total.zero?
Storefront::PaymentMailer.refunded(id.to_s).deliver_now
rescue Mongoid::Errors::DocumentNotFound
nil
end
end
end
5 changes: 5 additions & 0 deletions core/app/workers/workarea/synchronize_user_metrics.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ def perform(id)
},
upsert: true
)
rescue Mongoid::Errors::DocumentNotFound
# Callback workers may execute after the underlying document has been
# removed (e.g. test cleanup / truncation). Missing documents should be
# treated as a no-op.
nil
end
end
end
39 changes: 28 additions & 11 deletions core/lib/workarea/elasticsearch/index.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,40 @@ def exists?
Workarea.elasticsearch.indices.exists?(index: name)
end

# Create the index, optionally deleting it first when +force+ is true.
#
# The previous implementation used an +unless exists?+ guard which
# introduced a TOCTOU (time-of-check/time-of-use) race condition: the
# cluster state visible to +exists?+ could differ from the state seen by
# the subsequent +create+ call, causing intermittent
# +resource_already_exists_exception+ (400) errors in CI and during rapid
# sequential test runs. When +create!+ raised that exception the index
# remained deleted (because +delete!+ had already run), which caused the
# cascade +index_not_found_exception+ (404) seen in later test operations.
#
# The fix removes the guard and instead rescues the 400 BadRequest that
# Elasticsearch raises when the index already exists. This makes the
# operation atomic from the caller's perspective: if the index was already
# present we simply skip creation, just as the old +unless exists?+ path
# intended – but without the window for a concurrent creation to slip in.
#
def create!(force: false)
delete! if force

unless exists?
Workarea.elasticsearch.indices.create(
index: name,
body: {
settings: Search::Settings.current.elasticsearch_settings,
mappings: mappings,
aliases: aliases
}
)
end
Workarea.elasticsearch.indices.create(
index: name,
body: {
settings: Search::Settings.current.elasticsearch_settings,
mappings: mappings,
aliases: aliases
}
)
rescue ::Elasticsearch::Transport::Transport::Errors::BadRequest => e
raise unless e.message.include?('resource_already_exists_exception')
end

def delete!
Workarea.elasticsearch.indices.delete(index: name, ignore: 404)
Workarea.elasticsearch.indices.delete(index: name, ignore: [404])
end

def while_closed
Expand Down
42 changes: 42 additions & 0 deletions core/test/lib/workarea/elasticsearch/document_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,48 @@ def test_scroll
ensure
Foo.clear_scroll(results['_scroll_id'])
end

# Regression: Index#create! must be idempotent.
#
# The old implementation used `unless exists?` before calling
# `indices.create`. This introduced a TOCTOU race: between the `exists?`
# check and the actual `create` call another request could have created
# (or re-created) the same index, causing a
# `resource_already_exists_exception` (HTTP 400). When that exception
# propagated the index was left in a deleted state (because `delete!` had
# already run), which then caused `index_not_found_exception` (404) for
# any search performed by the same test.
#
# The fix removes the guard and rescues the 400 BadRequest instead, making
# the operation atomic.
def test_create_idempotent_when_index_already_exists
index = Foo.current_index

# First call – index exists from setup_indexes; should not raise.
assert_nothing_raised { index.create! }
end

def test_create_force_recreates_index_and_remains_searchable
Foo.save(id: 'before')
Foo.current_index.wait_for_health

# force: true must delete-and-recreate; previously saved documents are gone
Foo.reset_indexes!

assert_equal(0, Foo.count, 'expected empty index after force-recreate')

Foo.save(id: 'after')
assert_equal(1, Foo.count, 'expected new document after force-recreate')
end

def test_create_force_idempotent_called_twice
# Calling reset_indexes! back-to-back must not raise
# resource_already_exists_exception on the second call.
assert_nothing_raised do
Foo.reset_indexes!
Foo.reset_indexes!
end
end
end
end
end
17 changes: 12 additions & 5 deletions core/test/models/workarea/data_file/csv_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -369,14 +369,21 @@ def test_randomize_password_when_missing_and_new_record
file_type: 'csv'
)

# Deterministic: ensure we always assign a randomized password
# when the password column is missing for a new user.
SecureRandom.stubs(:hex).returns('0123456789abcdef0123')

assert_difference -> { User.count } do
Csv.new(import).import!
user = User.find_by_email('missing@example.com')

assert user.present?, 'user not imported'
refute user.authenticate('password1'),
"password authenticated when it shouldn't have"
end

user = User.find_by_email('missing@example.com')

assert user.present?, 'user not imported'
assert user.authenticate('0123456789abcdef0123_aA1'),
'random password not assigned as expected'
refute user.authenticate('password1'),
"password authenticated when it shouldn't have"
end

def test_ignore_password_column_when_user_exists
Expand Down
4 changes: 4 additions & 0 deletions core/test/models/workarea/data_file/json_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,17 @@ def test_randomize_password_when_missing_and_new_record
file_type: 'json'
)

SecureRandom.stubs(:hex).returns('0123456789abcdef0123')

assert_difference -> { User.count } do
Json.new(data).import!
end

user = User.find_by_email('test@example.com')

assert user.present?, 'user not imported'
assert user.authenticate('0123456789abcdef0123_aA1'),
'random password not assigned as expected'
refute user.authenticate(password), "password authenticated when it shouldn't have"
end

Expand Down
38 changes: 28 additions & 10 deletions core/test/models/workarea/metrics/product_by_week_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
module Workarea
module Metrics
class ProductByWeekTest < TestCase
setup do
Metrics::ProductByWeek.delete_all
Metrics::ProductForLastWeek.delete_all
end
def test_last_week
two_weeks_ago = create_product_by_week(reporting_on: Time.zone.local(2018, 11, 25))
last_week = create_product_by_week(reporting_on: Time.zone.local(2018, 12, 2))
Expand Down Expand Up @@ -117,20 +121,34 @@ def test_score
def test_weeks_ago
model = create_product_by_week(reporting_on: Time.zone.local(2019, 1, 25))

travel_to Time.zone.local(2019, 1, 25)
assert_equal(0, model.weeks_ago)
travel_to Time.zone.local(2019, 1, 25) do
assert_equal(0, model.weeks_ago)
end

travel_to Time.zone.local(2019, 1, 27) do
assert_equal(0, model.weeks_ago)
end

travel_to Time.zone.local(2019, 2, 8) do
assert_equal(2, model.weeks_ago)
end

travel_to Time.zone.local(2019, 1, 27)
assert_equal(0, model.weeks_ago)
travel_to Time.zone.local(2019, 2, 9) do
assert_equal(2, model.weeks_ago)
end

travel_to Time.zone.local(2019, 2, 8)
assert_equal(2, model.weeks_ago)
travel_to Time.zone.local(2019, 2, 11) do
assert_equal(3, model.weeks_ago)
end

travel_to Time.zone.local(2019, 2, 9)
assert_equal(2, model.weeks_ago)
# Regression: DST boundaries should not affect week math.
Time.use_zone('Eastern Time (US & Canada)') do
dst_model = create_product_by_week(reporting_on: Time.zone.local(2019, 3, 4, 0, 0, 0))

travel_to Time.zone.local(2019, 2, 11)
assert_equal(3, model.weeks_ago)
travel_to Time.zone.local(2019, 3, 11, 0, 0, 0) do
assert_equal(1, dst_model.weeks_ago)
end
end
end
end
end
Expand Down
Loading
Loading