mirror of
https://github.com/we-promise/sure.git
synced 2026-09-09 00:24:15 +00:00
Disabled accounts were already excluded from the dashboard and the new transaction modal, but the new transfer modal's from/to account selects still listed them. Add the same `.active` filter used by the transaction form to TransfersController#set_accounts. Claude-Session: https://claude.ai/code/session_01P9MCGiD5KEZGy9wB6ySuRA Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
714 lines
24 KiB
Ruby
714 lines
24 KiB
Ruby
require "test_helper"
|
|
|
|
class TransfersControllerTest < ActionDispatch::IntegrationTest
|
|
setup do
|
|
sign_in users(:family_admin)
|
|
end
|
|
|
|
test "should get new" do
|
|
get new_transfer_url
|
|
assert_response :success
|
|
end
|
|
|
|
test "excludes disabled accounts from the new transfer form" do
|
|
disabled_account = accounts(:depository)
|
|
disabled_account.update!(status: "disabled")
|
|
|
|
get new_transfer_url
|
|
|
|
assert_response :success
|
|
assert_no_match disabled_account.name, response.body
|
|
end
|
|
|
|
test "can create transfers" do
|
|
assert_difference "Transfer.count", 1 do
|
|
post transfers_url, params: {
|
|
transfer: {
|
|
from_account_id: accounts(:depository).id,
|
|
to_account_id: accounts(:credit_card).id,
|
|
date: Date.current,
|
|
amount: 100,
|
|
name: "Test Transfer"
|
|
}
|
|
}
|
|
assert_enqueued_with job: SyncJob
|
|
end
|
|
end
|
|
|
|
test "resubmitting the same idempotency key does not create a duplicate transfer" do
|
|
idempotency_key = SecureRandom.uuid
|
|
params = {
|
|
transfer: {
|
|
from_account_id: accounts(:depository).id,
|
|
to_account_id: accounts(:credit_card).id,
|
|
date: Date.current,
|
|
amount: 100,
|
|
name: "Test Transfer",
|
|
idempotency_key: idempotency_key
|
|
}
|
|
}
|
|
|
|
assert_difference "Transfer.count", 1 do
|
|
assert_difference "Entry.count", 2 do
|
|
post transfers_url, params: params
|
|
end
|
|
end
|
|
first_transfer = Transfer.order(:created_at).last
|
|
|
|
# Simulates a double-click or a browser retry: same form, same
|
|
# idempotency key, submitted again after the first request already
|
|
# completed and committed.
|
|
assert_no_difference [ "Transfer.count", "Entry.count" ] do
|
|
post transfers_url, params: params
|
|
end
|
|
assert_redirected_to transactions_path
|
|
end
|
|
|
|
test "the idempotency key does not mark either leg as provider-linked" do
|
|
# Regression test: the key must not be stored in external_id/source
|
|
# (Entry#linked? = external_id.present?), or an ordinary manual transfer
|
|
# would incorrectly look provider-synced.
|
|
post transfers_url, params: {
|
|
transfer: {
|
|
from_account_id: accounts(:depository).id,
|
|
to_account_id: accounts(:credit_card).id,
|
|
date: Date.current,
|
|
amount: 100,
|
|
name: "Test Transfer",
|
|
source_fee_amount: 3,
|
|
idempotency_key: SecureRandom.uuid
|
|
}
|
|
}
|
|
|
|
transfer = Transfer.order(:created_at).last
|
|
[ transfer.outflow_transaction.entry, transfer.inflow_transaction.entry, transfer.fee_transactions.first.entry ].each do |entry|
|
|
assert_not entry.linked?
|
|
assert_nil entry.external_id
|
|
assert_nil entry.source
|
|
end
|
|
end
|
|
|
|
test "each leg gets a distinct idempotency key so fee legs don't collide with their primary leg" do
|
|
# Regression test: the outflow/source-fee share source_account, and the
|
|
# inflow/destination-fee share destination_account. The unique index is
|
|
# scoped per account_id, so without role-specific suffixes a fee leg
|
|
# would collide with its primary leg under the same idempotency key.
|
|
post transfers_url, params: {
|
|
transfer: {
|
|
from_account_id: accounts(:depository).id,
|
|
to_account_id: accounts(:credit_card).id,
|
|
date: Date.current,
|
|
amount: 100,
|
|
name: "Test Transfer",
|
|
source_fee_amount: 2,
|
|
destination_fee_amount: 3,
|
|
idempotency_key: SecureRandom.uuid
|
|
}
|
|
}
|
|
|
|
transfer = Transfer.order(:created_at).last
|
|
keys = [
|
|
transfer.outflow_transaction.entry.idempotency_key,
|
|
transfer.inflow_transaction.entry.idempotency_key,
|
|
transfer.fee_transactions.map { |t| t.entry.idempotency_key }
|
|
].flatten
|
|
|
|
assert_equal keys.uniq.length, keys.length
|
|
assert keys.all?(&:present?)
|
|
end
|
|
|
|
test "handles a genuine concurrent double-submit without raising or duplicating" do
|
|
idempotency_key = SecureRandom.uuid
|
|
from_account = accounts(:depository)
|
|
to_account = accounts(:credit_card)
|
|
|
|
# Simulates the race: another request with the same idempotency key wins
|
|
# and commits its transfer in the window between our pre-check (which
|
|
# therefore still sees nothing, hence the first `nil`) and our own
|
|
# Transfer#save! (which then hits the real partial unique index on
|
|
# entries(account_id, idempotency_key) and raises RecordNotUnique,
|
|
# exactly like the DB would under real concurrent requests). The rescue
|
|
# then re-runs the same lookup, this time finding the winner.
|
|
winning_transfer = Transfer::Creator.new(
|
|
family: users(:family_admin).family,
|
|
source_account_id: from_account.id,
|
|
destination_account_id: to_account.id,
|
|
date: Date.current,
|
|
amount: 100,
|
|
idempotency_key: idempotency_key
|
|
).create
|
|
Transfer::Creator.any_instance.stubs(:find_existing_transfer).returns(nil, winning_transfer)
|
|
Transfer.any_instance.stubs(:save!).raises(ActiveRecord::RecordNotUnique.new("duplicate key value violates unique constraint"))
|
|
|
|
assert_no_difference [ "Transfer.count", "Entry.count" ] do
|
|
post transfers_url, params: {
|
|
transfer: {
|
|
from_account_id: from_account.id,
|
|
to_account_id: to_account.id,
|
|
date: Date.current,
|
|
amount: 100,
|
|
name: "Test Transfer",
|
|
idempotency_key: idempotency_key
|
|
}
|
|
}
|
|
end
|
|
|
|
assert_redirected_to transactions_path
|
|
end
|
|
|
|
test "a RecordNotUnique with no matching transfer is not silently swallowed" do
|
|
idempotency_key = SecureRandom.uuid
|
|
|
|
# Defensive-branch coverage: if the unique index ever rejects an insert
|
|
# for a reason other than "another request with this exact idempotency
|
|
# key already won," we must not pretend it succeeded - it should
|
|
# surface as a stale-form error instead of a raw DB exception.
|
|
Transfer::Creator.any_instance.stubs(:find_existing_transfer).returns(nil)
|
|
Transfer.any_instance.stubs(:save!).raises(ActiveRecord::RecordNotUnique.new("duplicate key value violates unique constraint"))
|
|
|
|
assert_no_difference [ "Transfer.count", "Entry.count" ] do
|
|
post transfers_url, params: {
|
|
transfer: {
|
|
from_account_id: accounts(:depository).id,
|
|
to_account_id: accounts(:credit_card).id,
|
|
date: Date.current,
|
|
amount: 100,
|
|
name: "Test Transfer",
|
|
idempotency_key: idempotency_key
|
|
}
|
|
}
|
|
end
|
|
|
|
assert_response :unprocessable_entity
|
|
end
|
|
|
|
test "reusing a key for a genuinely different request does not silently return the old transfer" do
|
|
idempotency_key = SecureRandom.uuid
|
|
from_account = accounts(:depository)
|
|
to_account = accounts(:credit_card)
|
|
other_destination = accounts(:other_liability)
|
|
|
|
assert_difference "Transfer.count", 1 do
|
|
post transfers_url, params: {
|
|
transfer: {
|
|
from_account_id: from_account.id,
|
|
to_account_id: to_account.id,
|
|
date: Date.current,
|
|
amount: 100,
|
|
name: "Test Transfer",
|
|
idempotency_key: idempotency_key
|
|
}
|
|
}
|
|
end
|
|
|
|
# Simulates a stale hidden field (cached page / reopened dialog) still
|
|
# carrying the first request's key while the user changed the
|
|
# destination account before resubmitting.
|
|
assert_no_difference [ "Transfer.count", "Entry.count" ] do
|
|
post transfers_url, params: {
|
|
transfer: {
|
|
from_account_id: from_account.id,
|
|
to_account_id: other_destination.id,
|
|
date: Date.current,
|
|
amount: 100,
|
|
name: "Test Transfer",
|
|
idempotency_key: idempotency_key
|
|
}
|
|
}
|
|
end
|
|
|
|
assert_response :unprocessable_entity
|
|
end
|
|
|
|
test "reusing a key with a different fee does not silently return the old transfer" do
|
|
idempotency_key = SecureRandom.uuid
|
|
from_account = accounts(:depository)
|
|
to_account = accounts(:credit_card)
|
|
|
|
assert_difference "Transfer.count", 1 do
|
|
post transfers_url, params: {
|
|
transfer: {
|
|
from_account_id: from_account.id,
|
|
to_account_id: to_account.id,
|
|
date: Date.current,
|
|
amount: 100,
|
|
name: "Test Transfer",
|
|
idempotency_key: idempotency_key
|
|
}
|
|
}
|
|
end
|
|
|
|
# Same accounts/date/amount/key as the first request, but a fee was
|
|
# added before resubmitting - the persisted effect differs, so this
|
|
# must not be reported as a successful retry of the fee-less transfer.
|
|
assert_no_difference [ "Transfer.count", "Entry.count" ] do
|
|
post transfers_url, params: {
|
|
transfer: {
|
|
from_account_id: from_account.id,
|
|
to_account_id: to_account.id,
|
|
date: Date.current,
|
|
amount: 100,
|
|
name: "Test Transfer",
|
|
source_fee_amount: 5,
|
|
idempotency_key: idempotency_key
|
|
}
|
|
}
|
|
end
|
|
|
|
assert_response :unprocessable_entity
|
|
end
|
|
|
|
test "reusing a key with a different exchange rate does not silently return the old transfer" do
|
|
idempotency_key = SecureRandom.uuid
|
|
usd_account = accounts(:depository)
|
|
eur_account = users(:family_admin).family.accounts.create!(
|
|
name: "EUR Account",
|
|
balance: 1000,
|
|
currency: "EUR",
|
|
accountable: Depository.new
|
|
)
|
|
|
|
assert_difference "Transfer.count", 1 do
|
|
post transfers_url, params: {
|
|
transfer: {
|
|
from_account_id: usd_account.id,
|
|
to_account_id: eur_account.id,
|
|
date: Date.current,
|
|
amount: 100,
|
|
exchange_rate: 0.9,
|
|
idempotency_key: idempotency_key
|
|
}
|
|
}
|
|
end
|
|
|
|
# Same accounts/date/amount/key, but a different exchange rate - the
|
|
# persisted inflow amount would differ, so this must not be reported as
|
|
# a successful retry of the first, differently-converted transfer.
|
|
assert_no_difference [ "Transfer.count", "Entry.count" ] do
|
|
post transfers_url, params: {
|
|
transfer: {
|
|
from_account_id: usd_account.id,
|
|
to_account_id: eur_account.id,
|
|
date: Date.current,
|
|
amount: 100,
|
|
exchange_rate: 0.5,
|
|
idempotency_key: idempotency_key
|
|
}
|
|
}
|
|
end
|
|
|
|
assert_response :unprocessable_entity
|
|
end
|
|
|
|
test "can create transfer with custom exchange rate" do
|
|
usd_account = accounts(:depository)
|
|
eur_account = users(:family_admin).family.accounts.create!(
|
|
name: "EUR Account",
|
|
balance: 1000,
|
|
currency: "EUR",
|
|
accountable: Depository.new
|
|
)
|
|
|
|
assert_equal "USD", usd_account.currency
|
|
assert_equal "EUR", eur_account.currency
|
|
|
|
assert_difference "Transfer.count", 1 do
|
|
post transfers_url, params: {
|
|
transfer: {
|
|
from_account_id: usd_account.id,
|
|
to_account_id: eur_account.id,
|
|
date: Date.current,
|
|
amount: 100,
|
|
exchange_rate: 0.92
|
|
}
|
|
}
|
|
end
|
|
|
|
transfer = Transfer.where(
|
|
"outflow_transaction_id IN (?) AND inflow_transaction_id IN (?)",
|
|
usd_account.transactions.pluck(:id),
|
|
eur_account.transactions.pluck(:id)
|
|
).last
|
|
assert_not_nil transfer
|
|
assert_equal "USD", transfer.outflow_transaction.entry.currency
|
|
assert_equal "EUR", transfer.inflow_transaction.entry.currency
|
|
assert_equal 100, transfer.outflow_transaction.entry.amount
|
|
assert_in_delta(-92, transfer.inflow_transaction.entry.amount, 0.01)
|
|
end
|
|
|
|
test "exchange_rate endpoint returns 400 when from currency is missing" do
|
|
get exchange_rate_url, params: {
|
|
to: "USD"
|
|
}
|
|
|
|
assert_response :bad_request
|
|
json_response = JSON.parse(response.body)
|
|
assert_equal "from and to currencies are required", json_response["error"]
|
|
end
|
|
|
|
test "exchange_rate endpoint returns 400 when to currency is missing" do
|
|
get exchange_rate_url, params: {
|
|
from: "EUR"
|
|
}
|
|
|
|
assert_response :bad_request
|
|
json_response = JSON.parse(response.body)
|
|
assert_equal "from and to currencies are required", json_response["error"]
|
|
end
|
|
|
|
test "exchange_rate endpoint returns 400 on invalid date format" do
|
|
get exchange_rate_url, params: {
|
|
from: "EUR",
|
|
to: "USD",
|
|
date: "not-a-date"
|
|
}
|
|
|
|
assert_response :bad_request
|
|
json_response = JSON.parse(response.body)
|
|
assert_equal "Invalid date format", json_response["error"]
|
|
end
|
|
|
|
test "exchange_rate endpoint returns rate for different currencies" do
|
|
ExchangeRate.expects(:find_or_fetch_rate)
|
|
.with(from: "USD", to: "EUR", date: Date.current)
|
|
.returns(OpenStruct.new(rate: 0.92))
|
|
|
|
get exchange_rate_url, params: {
|
|
from: "USD",
|
|
to: "EUR",
|
|
date: Date.current.to_s
|
|
}
|
|
|
|
assert_response :success
|
|
json_response = JSON.parse(response.body)
|
|
assert_equal 0.92, json_response["rate"]
|
|
end
|
|
|
|
test "exchange_rate endpoint returns error when exchange rate unavailable" do
|
|
ExchangeRate.expects(:find_or_fetch_rate)
|
|
.with(from: "USD", to: "EUR", date: Date.current)
|
|
.returns(nil)
|
|
|
|
get exchange_rate_url, params: {
|
|
from: "USD",
|
|
to: "EUR",
|
|
date: Date.current.to_s
|
|
}
|
|
|
|
assert_response :not_found
|
|
json_response = JSON.parse(response.body)
|
|
assert_equal "Exchange rate not found", json_response["error"]
|
|
end
|
|
|
|
test "cannot create transfer when exchange rate unavailable and no custom rate provided" do
|
|
usd_account = accounts(:depository)
|
|
eur_account = users(:family_admin).family.accounts.create!(
|
|
name: "EUR Account",
|
|
balance: 1000,
|
|
currency: "EUR",
|
|
accountable: Depository.new
|
|
)
|
|
|
|
ExchangeRate.stubs(:find_or_fetch_rate).returns(nil)
|
|
|
|
assert_no_difference "Transfer.count" do
|
|
post transfers_url, params: {
|
|
transfer: {
|
|
from_account_id: usd_account.id,
|
|
to_account_id: eur_account.id,
|
|
date: Date.current,
|
|
amount: 100
|
|
}
|
|
}
|
|
end
|
|
|
|
assert_response :unprocessable_entity
|
|
end
|
|
|
|
test "can create transfer with source fee" do
|
|
assert_difference "Transfer.count", 1 do
|
|
post transfers_url, params: {
|
|
transfer: {
|
|
from_account_id: accounts(:depository).id,
|
|
to_account_id: accounts(:credit_card).id,
|
|
date: Date.current,
|
|
amount: 100,
|
|
source_fee_amount: 3
|
|
}
|
|
}
|
|
end
|
|
|
|
transfer = Transfer.order(created_at: :desc).first
|
|
assert_equal 100, transfer.amount
|
|
assert_equal 3, transfer.derived_source_fee_amount
|
|
assert_equal 0, transfer.derived_destination_fee_amount
|
|
# Outflow should be principal only (no fee baked in)
|
|
assert_equal 100, transfer.outflow_transaction.entry.amount
|
|
# Inflow should be -(converted_principal)
|
|
assert_equal(-100, transfer.inflow_transaction.entry.amount)
|
|
# Fee transaction should be created
|
|
assert_equal 1, transfer.fee_transactions.count
|
|
fee_tx = transfer.fee_transactions.first
|
|
assert_equal "standard", fee_tx.kind
|
|
assert_equal 3, fee_tx.entry.amount
|
|
assert_equal accounts(:depository).id, fee_tx.entry.account_id
|
|
assert transfer.has_source_fee?
|
|
assert_not transfer.has_destination_fee?
|
|
end
|
|
|
|
test "can create transfer with destination fee" do
|
|
assert_difference "Transfer.count", 1 do
|
|
post transfers_url, params: {
|
|
transfer: {
|
|
from_account_id: accounts(:depository).id,
|
|
to_account_id: accounts(:credit_card).id,
|
|
date: Date.current,
|
|
amount: 100,
|
|
destination_fee_amount: 3
|
|
}
|
|
}
|
|
end
|
|
|
|
transfer = Transfer.order(created_at: :desc).first
|
|
assert_equal 100, transfer.amount
|
|
assert_equal 0, transfer.derived_source_fee_amount
|
|
assert_equal 3, transfer.derived_destination_fee_amount
|
|
# Outflow should be principal only
|
|
assert_equal 100, transfer.outflow_transaction.entry.amount
|
|
# Inflow should be -(converted_principal)
|
|
assert_equal(-100, transfer.inflow_transaction.entry.amount)
|
|
# Fee transaction should be created
|
|
assert_equal 1, transfer.fee_transactions.count
|
|
fee_tx = transfer.fee_transactions.first
|
|
assert_equal "standard", fee_tx.kind
|
|
assert_equal 3, fee_tx.entry.amount
|
|
assert_equal accounts(:credit_card).id, fee_tx.entry.account_id
|
|
assert_not transfer.has_source_fee?
|
|
assert transfer.has_destination_fee?
|
|
end
|
|
|
|
test "can create transfer with both source and destination fees" do
|
|
assert_difference "Transfer.count", 1 do
|
|
post transfers_url, params: {
|
|
transfer: {
|
|
from_account_id: accounts(:depository).id,
|
|
to_account_id: accounts(:credit_card).id,
|
|
date: Date.current,
|
|
amount: 100,
|
|
source_fee_amount: 2,
|
|
destination_fee_amount: 3
|
|
}
|
|
}
|
|
end
|
|
|
|
transfer = Transfer.order(created_at: :desc).first
|
|
assert_equal 100, transfer.amount
|
|
assert_equal 2, transfer.derived_source_fee_amount
|
|
assert_equal 3, transfer.derived_destination_fee_amount
|
|
# Outflow should be principal only
|
|
assert_equal 100, transfer.outflow_transaction.entry.amount
|
|
# Inflow should be -(converted_principal)
|
|
assert_equal(-100, transfer.inflow_transaction.entry.amount)
|
|
# Two fee transactions should be created
|
|
assert_equal 2, transfer.fee_transactions.count
|
|
source_fee_tx = transfer.fee_transactions.find { |t| t.entry.account_id == accounts(:depository).id }
|
|
dest_fee_tx = transfer.fee_transactions.find { |t| t.entry.account_id == accounts(:credit_card).id }
|
|
assert_equal 2, source_fee_tx.entry.amount
|
|
assert_equal 3, dest_fee_tx.entry.amount
|
|
assert transfer.has_fees?
|
|
end
|
|
|
|
test "derived fee methods reflect fee transaction entry edits" do
|
|
post transfers_url, params: {
|
|
transfer: {
|
|
from_account_id: accounts(:depository).id,
|
|
to_account_id: accounts(:credit_card).id,
|
|
date: Date.current,
|
|
amount: 100,
|
|
source_fee_amount: 3
|
|
}
|
|
}
|
|
|
|
transfer = Transfer.order(created_at: :desc).first
|
|
assert_equal 3, transfer.derived_source_fee_amount
|
|
|
|
# Simulate an independent edit of the fee transaction entry
|
|
fee_tx = transfer.fee_transactions.first
|
|
fee_tx.entry.update!(amount: 5)
|
|
|
|
# Derived fee should reflect the updated entry
|
|
transfer.reload
|
|
assert_equal 5, transfer.derived_source_fee_amount
|
|
assert transfer.has_source_fee?
|
|
end
|
|
|
|
test "exchange_rate endpoint returns same_currency for matching currencies" do
|
|
get exchange_rate_url, params: {
|
|
from: "USD",
|
|
to: "USD"
|
|
}
|
|
|
|
assert_response :success
|
|
json_response = JSON.parse(response.body)
|
|
assert_equal true, json_response["same_currency"]
|
|
assert_equal 1.0, json_response["rate"]
|
|
end
|
|
|
|
test "soft deletes transfer" do
|
|
assert_difference -> { Transfer.count }, -1 do
|
|
delete transfer_url(transfers(:one))
|
|
end
|
|
end
|
|
|
|
test "can create transfer with tags on both sides" do
|
|
tag = tags(:one)
|
|
|
|
assert_difference "Transfer.count", 1 do
|
|
post transfers_url, params: {
|
|
transfer: {
|
|
from_account_id: accounts(:depository).id,
|
|
to_account_id: accounts(:credit_card).id,
|
|
date: Date.current,
|
|
amount: 100,
|
|
tag_ids: [ tag.id ]
|
|
}
|
|
}
|
|
end
|
|
|
|
transfer = Transfer.order(:created_at).last
|
|
assert_equal [ tag.id ], transfer.outflow_transaction.tag_ids
|
|
assert_equal [ tag.id ], transfer.inflow_transaction.tag_ids
|
|
end
|
|
|
|
test "can update transfer tags on both sides" do
|
|
transfer = transfers(:one)
|
|
tag = tags(:one)
|
|
|
|
patch tags_transfer_url(transfer), params: { tag_ids: [ tag.id ] }, as: :json
|
|
|
|
assert_response :success
|
|
assert_equal [ tag.id ], transfer.outflow_transaction.reload.tag_ids
|
|
assert_equal [ tag.id ], transfer.inflow_transaction.reload.tag_ids
|
|
assert_equal [ tag.id ], JSON.parse(response.body)["tag_ids"]
|
|
end
|
|
|
|
test "update transfer tags ignores tags from other families" do
|
|
transfer = transfers(:one)
|
|
family_tag = tags(:one)
|
|
other_family = Family.create!(name: "Other Family", currency: "USD")
|
|
other_tag = other_family.tags.create!(name: "Foreign")
|
|
|
|
patch tags_transfer_url(transfer), params: {
|
|
tag_ids: [ family_tag.id, other_tag.id ]
|
|
}, as: :json
|
|
|
|
assert_response :success
|
|
assert_equal [ family_tag.id ], transfer.outflow_transaction.reload.tag_ids
|
|
assert_equal [ family_tag.id ], transfer.inflow_transaction.reload.tag_ids
|
|
end
|
|
|
|
test "can clear transfer tags" do
|
|
transfer = transfers(:one)
|
|
tag = tags(:one)
|
|
transfer.outflow_transaction.update!(tag_ids: [ tag.id ])
|
|
transfer.inflow_transaction.update!(tag_ids: [ tag.id ])
|
|
|
|
patch tags_transfer_url(transfer), params: { tag_ids: [] }, as: :json
|
|
|
|
assert_response :success
|
|
assert_empty transfer.outflow_transaction.reload.tag_ids
|
|
assert_empty transfer.inflow_transaction.reload.tag_ids
|
|
end
|
|
|
|
test "update tags requires annotate permission on both transfer sides" do
|
|
# family_member: full_control on depository (outflow), read_only on credit_card (inflow)
|
|
sign_in users(:family_member)
|
|
transfer = transfers(:one)
|
|
tag = tags(:one)
|
|
original_outflow_tags = transfer.outflow_transaction.tag_ids
|
|
original_inflow_tags = transfer.inflow_transaction.tag_ids
|
|
|
|
patch tags_transfer_url(transfer), params: { tag_ids: [ tag.id ] }, as: :json
|
|
|
|
assert_response :forbidden
|
|
assert_equal I18n.t("accounts.not_authorized"), JSON.parse(response.body)["error"]
|
|
assert_equal original_outflow_tags, transfer.outflow_transaction.reload.tag_ids
|
|
assert_equal original_inflow_tags, transfer.inflow_transaction.reload.tag_ids
|
|
end
|
|
|
|
test "can add notes to transfer" do
|
|
transfer = transfers(:one)
|
|
assert_nil transfer.notes
|
|
|
|
patch transfer_url(transfer), params: { transfer: { notes: "Test notes" } }
|
|
|
|
assert_redirected_to transactions_url
|
|
assert_equal "Transfer updated", flash[:notice]
|
|
assert_equal "Test notes", transfer.reload.notes
|
|
end
|
|
|
|
test "handles rejection without FrozenError" do
|
|
transfer = transfers(:one)
|
|
|
|
assert_difference "Transfer.count", -1 do
|
|
patch transfer_url(transfer), params: {
|
|
transfer: {
|
|
status: "rejected"
|
|
}
|
|
}
|
|
end
|
|
|
|
assert_redirected_to transactions_url
|
|
assert_equal "Transfer updated", flash[:notice]
|
|
|
|
# Verify the transfer was actually destroyed
|
|
assert_raises(ActiveRecord::RecordNotFound) do
|
|
transfer.reload
|
|
end
|
|
end
|
|
|
|
test "mark_as_recurring creates a recurring transfer" do
|
|
transfer = transfers(:one)
|
|
family = users(:family_admin).family
|
|
family.recurring_transactions.destroy_all
|
|
|
|
assert_difference -> { RecurringTransaction.where(family: family).count }, +1 do
|
|
post mark_as_recurring_transfer_url(transfer)
|
|
end
|
|
|
|
rt = RecurringTransaction.where(family: family).last
|
|
assert rt.transfer?
|
|
assert_equal transfer.outflow_transaction.entry.account, rt.account
|
|
assert_equal transfer.inflow_transaction.entry.account, rt.destination_account
|
|
assert rt.manual?
|
|
assert_equal I18n.t("recurring_transactions.transfer_marked_as_recurring"), flash[:notice]
|
|
assert_redirected_to transactions_path
|
|
end
|
|
|
|
test "mark_as_recurring is idempotent: second call flashes already-exists" do
|
|
transfer = transfers(:one)
|
|
family = users(:family_admin).family
|
|
family.recurring_transactions.destroy_all
|
|
|
|
post mark_as_recurring_transfer_url(transfer)
|
|
assert_equal I18n.t("recurring_transactions.transfer_marked_as_recurring"), flash[:notice]
|
|
|
|
assert_no_difference -> { RecurringTransaction.where(family: family).count } do
|
|
post mark_as_recurring_transfer_url(transfer)
|
|
end
|
|
assert_equal I18n.t("recurring_transactions.transfer_already_exists"), flash[:alert]
|
|
end
|
|
|
|
test "mark_as_recurring is rejected when recurring_transactions_disabled" do
|
|
transfer = transfers(:one)
|
|
family = users(:family_admin).family
|
|
family.update!(recurring_transactions_disabled: true)
|
|
family.recurring_transactions.destroy_all
|
|
|
|
assert_no_difference -> { RecurringTransaction.where(family: family).count } do
|
|
post mark_as_recurring_transfer_url(transfer)
|
|
end
|
|
assert_equal I18n.t("recurring_transactions.transfer_feature_disabled"), flash[:alert]
|
|
end
|
|
end
|