Files
sure/test/models/snaptrade_account_test.rb
ghost 94d2ee908d fix(jobs): enqueue jobs after transaction commit to fix SyncJob deserialization race (#2354)
* fix(jobs): enqueue jobs after transaction commit to fix SyncJob deserialization race

Syncable#sync_later creates a Sync row and enqueues SyncJob inside the
same transaction. Rails 7.2 deferred such enqueues until commit by
default; Rails 8.0 changed the default to enqueue immediately and 8.1
left the global config toggle non-functional, so a worker could dequeue
the job before COMMIT, fail to resolve the Sync GlobalID
(ActiveJob::DeserializationError), and have it silently dropped by
discard_on -- surfacing as stuck syncs after the Rails 8.1 upgrade.

Set enqueue_after_transaction_commit = true on ApplicationJob (the
Rails 8.2 default, inherited by every job) and drop the dead :never
symbol override on DestroyJob. Add regression and invariant tests.

* test(jobs): use OpenStruct for DestroyJob failure case

Replace the bare mock and the respond_to? expectation (an implementation
detail of how DestroyJob probes the model) with an OpenStruct that genuinely
responds to scheduled_for_deletion. Keeps only the command-facing assertions:
destroy raises and update! is called with scheduled_for_deletion: false.
Matches the repo convention of preferring OpenStruct for mock instances.

* test(snaptrade): assert connection-cleanup enqueue defers until commit

SnaptradeAccount#after_destroy enqueues SnaptradeConnectionCleanupJob, which
references the item/account by id. Before the ApplicationJob fix, Rails 8.1
enqueued it immediately inside the destroy transaction, so a worker could run
before COMMIT, see the not-yet-deleted row in its shared-authorization guard,
skip the provider call, and leak the SnapTrade connection. This regression
test destroys an account inside a transaction and asserts the job is not
enqueued until the transaction commits.
2026-06-15 21:43:08 +02:00

105 lines
3.1 KiB
Ruby

require "test_helper"
class SnaptradeAccountTest < ActiveSupport::TestCase
include ActiveJob::TestHelper
setup do
@family_a = families(:dylan_family)
@family_b = families(:empty)
@item_a = SnaptradeItem.create!(
family: @family_a,
name: "Family A Broker",
client_id: "client_a",
consumer_key: "key_a",
status: "good"
)
@item_b = SnaptradeItem.create!(
family: @family_b,
name: "Family B Broker",
client_id: "client_b",
consumer_key: "key_b",
status: "good"
)
end
test "same snaptrade_account_id can be linked under different snaptrade_items" do
SnaptradeAccount.create!(
snaptrade_item: @item_a,
snaptrade_account_id: "shared_snap_uuid_1",
name: "IRA",
currency: "USD",
current_balance: 5000
)
assert_difference "SnaptradeAccount.count", 1 do
SnaptradeAccount.create!(
snaptrade_item: @item_b,
snaptrade_account_id: "shared_snap_uuid_1",
name: "IRA",
currency: "USD",
current_balance: 5000
)
end
end
test "same snaptrade_account_id cannot appear twice under the same snaptrade_item" do
SnaptradeAccount.create!(
snaptrade_item: @item_a,
snaptrade_account_id: "dup_snap_uuid",
name: "Brokerage",
currency: "USD",
current_balance: 1000
)
duplicate = SnaptradeAccount.new(
snaptrade_item: @item_a,
snaptrade_account_id: "dup_snap_uuid",
name: "Brokerage",
currency: "USD",
current_balance: 1000
)
refute duplicate.valid?
assert_includes duplicate.errors[:snaptrade_account_id], "has already been taken"
assert_raises(ActiveRecord::RecordInvalid) do
SnaptradeAccount.create!(
snaptrade_item: @item_a,
snaptrade_account_id: "dup_snap_uuid",
name: "Brokerage",
currency: "USD",
current_balance: 1000
)
end
end
# Regression: the after_destroy callback enqueues SnaptradeConnectionCleanupJob,
# which references the account/item by id. If it is enqueued before the destroy
# transaction commits (Rails 8.1's immediate-enqueue default), a worker can run
# before COMMIT: its "do other accounts still share this authorization?" guard
# then sees the not-yet-deleted row, skips the provider call, and leaks the
# SnapTrade connection. Enqueuing must be deferred until commit.
test "connection cleanup job is deferred until the destroy transaction commits" do
account = SnaptradeAccount.create!(
snaptrade_item: @item_a,
snaptrade_account_id: "cleanup_uuid",
snaptrade_authorization_id: "auth_cleanup",
name: "Brokerage",
currency: "USD",
current_balance: 1000
)
enqueued_mid_transaction = nil
ActiveRecord::Base.transaction do
account.destroy!
enqueued_mid_transaction = enqueued_jobs.any? { |job| job[:job] == SnaptradeConnectionCleanupJob }
end
assert_not enqueued_mid_transaction,
"SnaptradeConnectionCleanupJob was enqueued before the destroy committed (would race COMMIT and leak the provider connection)"
assert_enqueued_with job: SnaptradeConnectionCleanupJob
end
end