Files
sure/test/models/sync_test.rb
Guillem Arias Fauste ab14944a9f fix(sync): guard finalization against externally-staled syncs (#2680)
A sync marked stale by SyncCleanerJob while its job is still running
hits two lost-update paths when that job finishes:

- success path: finalize skipped the status transition but still ran
  perform_post_sync, re-applying transfer matching, rules, and
  broadcasts for a sync the system had already written off
- failure path: the rescue's unguarded fail! silently overwrote the
  terminal stale status with failed (the in-memory record still read
  syncing, so the AASM guard never fired)

Fix: re-check state under a row lock (with_lock reloads) before
failing, and skip post-sync for stale syncs in finalize. Post-sync
still runs for failed syncs — that behavior is intentional and
covered by existing tests.
2026-07-14 19:15:09 +02:00

319 lines
11 KiB
Ruby

require "test_helper"
class SyncTest < ActiveSupport::TestCase
include ActiveJob::TestHelper
test "does not run if not in a valid state" do
syncable = accounts(:depository)
sync = Sync.create!(syncable: syncable, status: :completed)
syncable.expects(:perform_sync).never
sync.perform
assert_equal "completed", sync.status
end
test "runs successful sync" do
syncable = accounts(:depository)
sync = Sync.create!(syncable: syncable)
syncable.expects(:perform_sync).with(sync).once
assert_equal "pending", sync.status
sync.perform
assert sync.completed_at < Time.now
assert_equal "completed", sync.status
end
test "handles sync errors" do
syncable = accounts(:depository)
sync = Sync.create!(syncable: syncable)
syncable.expects(:perform_sync).with(sync).raises(StandardError.new("test sync error"))
assert_equal "pending", sync.status
sync.perform
assert sync.failed_at < Time.now
assert_equal "failed", sync.status
assert_equal "test sync error", sync.error
end
test "can run nested syncs that alert the parent when complete" do
family = families(:dylan_family)
plaid_item = plaid_items(:one)
account = accounts(:connected)
family_sync = Sync.create!(syncable: family)
plaid_item_sync = Sync.create!(syncable: plaid_item, parent: family_sync)
account_sync = Sync.create!(syncable: account, parent: plaid_item_sync)
assert_equal "pending", family_sync.status
assert_equal "pending", plaid_item_sync.status
assert_equal "pending", account_sync.status
family.expects(:perform_sync).with(family_sync).once
family_sync.perform
assert_equal "syncing", family_sync.reload.status
plaid_item.expects(:perform_sync).with(plaid_item_sync).once
plaid_item_sync.perform
assert_equal "syncing", family_sync.reload.status
assert_equal "syncing", plaid_item_sync.reload.status
account.expects(:perform_sync).with(account_sync).once
# Since these are accessed through `parent`, they won't necessarily be the same
# instance we configured above
Account.any_instance.expects(:perform_post_sync).once
Account.any_instance.expects(:broadcast_sync_complete).once
PlaidItem.any_instance.expects(:perform_post_sync).once
PlaidItem.any_instance.expects(:broadcast_sync_complete).once
Family.any_instance.expects(:perform_post_sync).once
Family.any_instance.expects(:broadcast_sync_complete).once
account_sync.perform
assert_equal "completed", plaid_item_sync.reload.status
assert_equal "completed", account_sync.reload.status
assert_equal "completed", family_sync.reload.status
end
test "failures propagate up the chain" do
family = families(:dylan_family)
plaid_item = plaid_items(:one)
account = accounts(:connected)
family_sync = Sync.create!(syncable: family)
plaid_item_sync = Sync.create!(syncable: plaid_item, parent: family_sync)
account_sync = Sync.create!(syncable: account, parent: plaid_item_sync)
assert_equal "pending", family_sync.status
assert_equal "pending", plaid_item_sync.status
assert_equal "pending", account_sync.status
family.expects(:perform_sync).with(family_sync).once
family_sync.perform
assert_equal "syncing", family_sync.reload.status
plaid_item.expects(:perform_sync).with(plaid_item_sync).once
plaid_item_sync.perform
assert_equal "syncing", family_sync.reload.status
assert_equal "syncing", plaid_item_sync.reload.status
# This error should "bubble up" to the PlaidItem and Family sync results
account.expects(:perform_sync).with(account_sync).raises(StandardError.new("test account sync error"))
# Since these are accessed through `parent`, they won't necessarily be the same
# instance we configured above
Account.any_instance.expects(:perform_post_sync).once
PlaidItem.any_instance.expects(:perform_post_sync).once
Family.any_instance.expects(:perform_post_sync).once
Account.any_instance.expects(:broadcast_sync_complete).once
PlaidItem.any_instance.expects(:broadcast_sync_complete).once
Family.any_instance.expects(:broadcast_sync_complete).once
account_sync.perform
assert_equal "failed", plaid_item_sync.reload.status
assert_equal "failed", account_sync.reload.status
assert_equal "failed", family_sync.reload.status
end
test "parent failure should not change status if child succeeds" do
family = families(:dylan_family)
plaid_item = plaid_items(:one)
account = accounts(:connected)
family_sync = Sync.create!(syncable: family)
plaid_item_sync = Sync.create!(syncable: plaid_item, parent: family_sync)
account_sync = Sync.create!(syncable: account, parent: plaid_item_sync)
assert_equal "pending", family_sync.status
assert_equal "pending", plaid_item_sync.status
assert_equal "pending", account_sync.status
family.expects(:perform_sync).with(family_sync).raises(StandardError.new("test family sync error"))
family_sync.perform
assert_equal "failed", family_sync.reload.status
plaid_item.expects(:perform_sync).with(plaid_item_sync).raises(StandardError.new("test plaid item sync error"))
plaid_item_sync.perform
assert_equal "failed", family_sync.reload.status
assert_equal "failed", plaid_item_sync.reload.status
# Leaf level sync succeeds, but shouldn't change the status of the already-failed parent syncs
account.expects(:perform_sync).with(account_sync).once
# Since these are accessed through `parent`, they won't necessarily be the same
# instance we configured above
Account.any_instance.expects(:perform_post_sync).once
PlaidItem.any_instance.expects(:perform_post_sync).once
Family.any_instance.expects(:perform_post_sync).once
Account.any_instance.expects(:broadcast_sync_complete).once
PlaidItem.any_instance.expects(:broadcast_sync_complete).once
Family.any_instance.expects(:broadcast_sync_complete).once
account_sync.perform
assert_equal "failed", plaid_item_sync.reload.status
assert_equal "failed", family_sync.reload.status
assert_equal "completed", account_sync.reload.status
end
test "sync staled mid-run does not run post-sync when its job finishes" do
syncable = accounts(:depository)
sync = Sync.create!(syncable: syncable)
# Simulate SyncCleanerJob marking the sync stale while the job is still running
syncable.expects(:perform_sync).with { |s| Sync.find(s.id).mark_stale!; true }
Account.any_instance.expects(:perform_post_sync).never
Account.any_instance.expects(:broadcast_sync_complete).never
sync.perform
assert_equal "stale", sync.reload.status
end
test "sync staled mid-run does not raise when its job fails" do
syncable = accounts(:depository)
sync = Sync.create!(syncable: syncable)
syncable.expects(:perform_sync).with { |s| Sync.find(s.id).mark_stale!; true }
.raises(StandardError.new("provider blew up"))
Account.any_instance.expects(:perform_post_sync).never
Account.any_instance.expects(:broadcast_sync_complete).never
assert_nothing_raised do
sync.perform
end
assert_equal "stale", sync.reload.status
assert_equal "provider blew up", sync.error
end
test "clean marks stale incomplete rows" do
stale_pending = Sync.create!(
syncable: accounts(:depository),
status: :pending,
created_at: 25.hours.ago
)
stale_syncing = Sync.create!(
syncable: accounts(:depository),
status: :syncing,
created_at: 25.hours.ago,
pending_at: 24.hours.ago,
syncing_at: 23.hours.ago
)
Sync.clean
assert_equal "stale", stale_pending.reload.status
assert_equal "stale", stale_syncing.reload.status
end
test "ordered uses id as deterministic tie breaker" do
timestamp = Time.current.change(usec: 0)
older_id = SecureRandom.uuid
newer_id = SecureRandom.uuid
older_id, newer_id = [ older_id, newer_id ].sort
older_sync = Sync.create!(id: older_id, syncable: accounts(:depository), status: :completed, created_at: timestamp)
newer_sync = Sync.create!(id: newer_id, syncable: accounts(:connected), status: :completed, created_at: timestamp)
ordered_ids = Sync.where(id: [ older_sync.id, newer_sync.id ]).ordered.pluck(:id)
assert_equal [ newer_sync.id, older_sync.id ], ordered_ids
end
test "for_family includes syncable provider item associations from family reflections" do
family = families(:dylan_family)
syncable_item_associations = Family.reflect_on_all_associations(:has_many).select do |association|
association.name.to_s.end_with?("_items") &&
association.klass.included_modules.include?(Syncable)
rescue NameError
false
end
syncs = syncable_item_associations.filter_map do |association|
syncable = family.public_send(association.name).first
next unless syncable
Sync.create!(syncable: syncable, status: :completed)
end
assert syncs.any?, "Expected syncable provider item fixtures for this family"
assert_equal syncs.map(&:id).sort, Sync.for_family(family).where(id: syncs.map(&:id)).pluck(:id).sort
end
test "any_incomplete_for? fires on a Sync against any Syncable provider item association" do
family = families(:dylan_family)
Sync.for_family(family).incomplete.find_each(&:destroy)
assert_not Sync.any_incomplete_for?(family)
mercury_item = mercury_items(:one)
incomplete = Sync.create!(syncable: mercury_item, status: :pending)
assert Sync.any_incomplete_for?(family),
"any_incomplete_for? should report true for an in-flight Mercury sync"
incomplete.update!(status: :completed)
assert_not Sync.any_incomplete_for?(family)
end
test "any_incomplete_for? fires on a Sync against the family itself" do
family = families(:dylan_family)
Sync.for_family(family).incomplete.find_each(&:destroy)
assert_not Sync.any_incomplete_for?(family)
Sync.create!(syncable: family, status: :syncing)
assert Sync.any_incomplete_for?(family)
end
test "api error payload is present for failed syncs without raw error text" do
sync = Sync.create!(syncable: accounts(:depository), status: :failed)
assert_equal({ message: "Sync failed" }, sync.api_error_payload)
end
test "expand_window_if_needed widens start and end dates on a pending sync" do
initial_start = 1.day.ago.to_date
initial_end = 1.day.ago.to_date
sync = Sync.create!(
syncable: accounts(:depository),
window_start_date: initial_start,
window_end_date: initial_end
)
new_start = 5.days.ago.to_date
new_end = Date.current
sync.expand_window_if_needed(new_start, new_end)
sync.reload
assert_equal new_start, sync.window_start_date
assert_equal new_end, sync.window_end_date
end
end