Files
sure/test/models/transaction_test.rb
T
9d225a21e6 fix: disable Mark as Recurring button when a manual recurring transaction already exists (#3103)
* fix: disable "Mark as Recurring" button when a manual recurring transaction already exists

Previously the button was always clickable and only failed after a POST,
showing "A manual recurring transaction already exists for this pattern".
Extract the lookup into Transaction#existing_manual_recurring_transaction
(reused by the controller guard) so the view can disable the button ahead
of time and show the reason inline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix: address CodeRabbit review feedback on PR #3103

Move the existing_manual_recurring lookup out of the show view and into
the controller so rendering no longer runs an Active Record query
in-template, and strengthen the "no match" model test with near-match
recurring transactions that individually differ by account, merchant,
amount, currency, and manual flag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix: move mark-recurring presentation state fully into controller, fix stale state on failed update

Address CodeRabbit follow-up on PR #3103:
- Compute the mark-recurring button's subtitle text/class, href, disabled
  state, title, and class entirely in TransactionsController (via a shared
  assign_mark_recurring_state helper) instead of deriving them with
  ternaries in the view.
- Populate that state before TransactionsController#update re-renders
  :show on a failed entry update, so the button doesn't incorrectly appear
  enabled when a matching manual recurring transaction exists.
- Add a controller test covering the failed-update render path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix: use blank name instead of blank date to trigger validation failure in mark-recurring test

The CI test_unit run flagged a real bug in the test itself: TransactionsController#entry_params
strips blank :date/:amount before update, so date: "" never reached model validation and the
update succeeded (302) instead of failing (422) as the test expected. Use a blank :name instead,
which isn't stripped, and add DOM assertions (disabled button, no mark_as_recurring form action)
per CodeRabbit's follow-up review.

Verified against a live NAS Rails console reproduction (bypassing the test stack's broken
fixtures) that the failed-update render now correctly shows the button as disabled with the
"already exists" message and no action link.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix: use transaction id instead of entry id in mark-recurring route assertion

mark_as_recurring is a member route on the transactions resource keyed by the
Transaction's id, not the Entry's id (Entry uses delegated_type, so Entry and
its Transaction entryable have distinct ids). The prior assertion built the
path from `entry`, which could produce a different URL than the one actually
rendered, so the "no href" check could pass even if the button leaked a link.
Use entry.entryable so the assertion matches the real route.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix: refresh mark-recurring state on turbo_stream update, avoid unconditional query, keep DS::Button href

Addresses jjmata's review on PR #3103:
- Extract the "Mark as Recurring" block into a dom_id-wrapped partial and
  replace it in the successful update turbo_stream response, so inline
  edits that change whether the transaction matches an existing manual
  recurring transaction are reflected immediately instead of only on the
  next full page render.
- Skip the existing_manual_recurring_transaction lookup entirely when the
  block won't be rendered (no edit permission, or split-child entry),
  avoiding an unconditional extra query on every transaction show/failed
  update.
- Keep href present on the DS::Button and only toggle disabled, matching
  the established pattern elsewhere in the app, instead of nulling href
  (which flips the component to a bare <button> and leaks a stray
  method="post" attribute).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Gerald <248542187+gfr-free@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 02:43:29 +02:00

311 lines
11 KiB
Ruby

require "test_helper"
class TransactionTest < ActiveSupport::TestCase
include EntriesTestHelper
test "existing_manual_recurring_transaction finds a match by merchant" do
family = families(:empty)
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
merchant = family.merchants.create! name: "Test Merchant"
entry = create_transaction(account: account, amount: 100, merchant: merchant)
transaction = entry.entryable
recurring = family.recurring_transactions.create!(
account: account,
merchant: merchant,
amount: entry.amount,
currency: entry.currency,
expected_day_of_month: entry.date.day,
last_occurrence_date: entry.date,
next_expected_date: 1.month.from_now,
status: "active",
manual: true,
occurrence_count: 1
)
assert_equal recurring, transaction.existing_manual_recurring_transaction
end
test "existing_manual_recurring_transaction finds a match by name when no merchant" do
family = families(:empty)
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
entry = create_transaction(account: account, amount: 50, name: "Netflix")
transaction = entry.entryable
recurring = family.recurring_transactions.create!(
account: account,
name: "Netflix",
amount: entry.amount,
currency: entry.currency,
expected_day_of_month: entry.date.day,
last_occurrence_date: entry.date,
next_expected_date: 1.month.from_now,
status: "active",
manual: true,
occurrence_count: 1
)
assert_equal recurring, transaction.existing_manual_recurring_transaction
end
test "existing_manual_recurring_transaction returns nil when no manual recurring transaction matches" do
family = families(:empty)
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
other_account = family.accounts.create! name: "Other", balance: 0, currency: "USD", accountable: Depository.new
merchant = family.merchants.create! name: "Test Merchant"
other_merchant = family.merchants.create! name: "Other Merchant"
entry = create_transaction(account: account, amount: 100, merchant: merchant)
transaction = entry.entryable
base_attrs = {
expected_day_of_month: entry.date.day,
last_occurrence_date: entry.date,
next_expected_date: 1.month.from_now,
status: "active",
occurrence_count: 1
}
# Differs by account
family.recurring_transactions.create!(base_attrs.merge(
account: other_account, merchant: merchant, amount: entry.amount, currency: entry.currency, manual: true
))
# Differs by merchant
family.recurring_transactions.create!(base_attrs.merge(
account: account, merchant: other_merchant, amount: entry.amount, currency: entry.currency, manual: true
))
# Differs by amount
family.recurring_transactions.create!(base_attrs.merge(
account: account, merchant: merchant, amount: entry.amount + 1, currency: entry.currency, manual: true
))
# Differs by currency
family.recurring_transactions.create!(base_attrs.merge(
account: account, merchant: merchant, amount: entry.amount, currency: "EUR", manual: true
))
# Differs by manual flag
family.recurring_transactions.create!(base_attrs.merge(
account: account, merchant: merchant, amount: entry.amount, currency: entry.currency, manual: false
))
assert_nil transaction.existing_manual_recurring_transaction
end
test "pending? is true when extra.simplefin.pending is truthy" do
transaction = Transaction.new(extra: { "simplefin" => { "pending" => true } })
assert transaction.pending?
end
test "pending? is true when extra.plaid.pending is truthy" do
transaction = Transaction.new(extra: { "plaid" => { "pending" => "true" } })
assert transaction.pending?
end
test "pending? is true when extra.lunchflow.pending is truthy" do
transaction = Transaction.new(extra: { "lunchflow" => { "pending" => true } })
assert transaction.pending?
end
test "pending? is false when no provider pending metadata is present" do
transaction = Transaction.new(extra: { "plaid" => { "pending" => false } })
assert_not transaction.pending?
end
test "pending? returns true for enable_banking pending transactions" do
transaction = Transaction.new(extra: { "enable_banking" => { "pending" => true } })
assert transaction.pending?
end
test "pending? returns false for enable_banking non-pending transactions" do
transaction = Transaction.new(extra: { "enable_banking" => { "pending" => false } })
assert_not transaction.pending?
end
test "investment_contribution is a valid kind" do
transaction = Transaction.new(kind: "investment_contribution")
assert_equal "investment_contribution", transaction.kind
assert transaction.investment_contribution?
end
test "TRANSFER_KINDS constant matches transfer? method" do
Transaction::TRANSFER_KINDS.each do |kind|
assert Transaction.new(kind: kind).transfer?, "#{kind} should be a transfer kind"
end
non_transfer_kinds = Transaction.kinds.keys - Transaction::TRANSFER_KINDS
non_transfer_kinds.each do |kind|
assert_not Transaction.new(kind: kind).transfer?, "#{kind} should NOT be a transfer kind"
end
end
test "all transaction kinds are valid" do
valid_kinds = %w[standard funds_movement cc_payment loan_payment one_time investment_contribution]
valid_kinds.each do |kind|
transaction = Transaction.new(kind: kind)
assert_equal kind, transaction.kind, "#{kind} should be a valid transaction kind"
end
end
test "ACTIVITY_LABELS contains all valid labels" do
assert_includes Transaction::ACTIVITY_LABELS, "Buy"
assert_includes Transaction::ACTIVITY_LABELS, "Sell"
assert_includes Transaction::ACTIVITY_LABELS, "Sweep In"
assert_includes Transaction::ACTIVITY_LABELS, "Sweep Out"
assert_includes Transaction::ACTIVITY_LABELS, "Dividend"
assert_includes Transaction::ACTIVITY_LABELS, "Reinvestment"
assert_includes Transaction::ACTIVITY_LABELS, "Interest"
assert_includes Transaction::ACTIVITY_LABELS, "Fee"
assert_includes Transaction::ACTIVITY_LABELS, "Transfer"
assert_includes Transaction::ACTIVITY_LABELS, "Contribution"
assert_includes Transaction::ACTIVITY_LABELS, "Withdrawal"
assert_includes Transaction::ACTIVITY_LABELS, "Exchange"
assert_includes Transaction::ACTIVITY_LABELS, "Other"
end
test "exchange_rate getter returns nil when extra is nil" do
transaction = Transaction.new
assert_nil transaction.exchange_rate
end
test "exchange_rate setter stores normalized numeric value" do
transaction = Transaction.new
transaction.exchange_rate = "1.5"
assert_equal 1.5, transaction.exchange_rate
end
test "exchange_rate setter marks invalid input" do
transaction = Transaction.new
transaction.exchange_rate = "not a number"
assert_equal "not a number", transaction.extra["exchange_rate"]
assert transaction.extra["exchange_rate_invalid"]
end
test "exchange_rate setter rejects non-finite input" do
transaction = Transaction.new
transaction.exchange_rate = "Infinity"
assert_equal "Infinity", transaction.extra["exchange_rate"]
assert transaction.extra["exchange_rate_invalid"]
end
test "exchange_rate setter clears invalid flag for valid input" do
transaction = Transaction.new
transaction.exchange_rate = "not a number"
transaction.exchange_rate = "1.5"
assert_equal 1.5, transaction.exchange_rate
assert_equal false, transaction.extra["exchange_rate_invalid"]
end
test "exchange_rate validation rejects non-numeric input" do
transaction = Transaction.new(
category: categories(:income),
extra: { "exchange_rate" => "invalid" }
)
transaction.exchange_rate = "not a number"
assert_not transaction.valid?
assert_includes transaction.errors[:exchange_rate], "must be a number"
end
test "exchange_rate validation rejects zero values" do
transaction = Transaction.new(
category: categories(:income)
)
transaction.exchange_rate = 0
assert_not transaction.valid?
assert_includes transaction.errors[:exchange_rate], "must be greater than 0"
end
test "exchange_rate validation rejects negative values" do
transaction = Transaction.new(
category: categories(:income)
)
transaction.exchange_rate = -1.5
assert_not transaction.valid?
assert_includes transaction.errors[:exchange_rate], "must be greater than 0"
end
test "exchange_rate validation allows positive values" do
transaction = Transaction.new(
category: categories(:income)
)
transaction.exchange_rate = 1.5
assert transaction.valid?
end
test "activity_security returns the referenced security from extra metadata" do
security = securities(:aapl)
transaction = Transaction.new(extra: { "security_id" => security.id })
assert_equal security, transaction.activity_security
end
test "activity_security returns nil when no security metadata is present" do
transaction = Transaction.new(extra: {})
assert_nil transaction.activity_security
end
test "activity_security refreshes when security metadata changes on the same instance" do
transaction = Transaction.new(extra: { "security_id" => securities(:aapl).id })
assert_equal securities(:aapl), transaction.activity_security
transaction.extra["security_id"] = securities(:msft).id
assert_equal securities(:msft), transaction.activity_security
end
test "record_category_usage! touches the new category's last_used_at" do
transaction = transactions(:one)
category = categories(:income)
assert_nil category.last_used_at
transaction.update!(category: category)
transaction.record_category_usage!
assert_not_nil category.reload.last_used_at
end
test "record_category_usage! does nothing when category_id did not change" do
transaction = transactions(:one)
category = transaction.category
assert_nil category.last_used_at
transaction.reload
transaction.record_category_usage!
assert_nil category.reload.last_used_at
end
test "record_category_usage! does nothing when category is cleared" do
transaction = transactions(:one)
transaction.update!(category: nil)
assert_nothing_raised { transaction.record_category_usage! }
end
test "record_category_usage! is not invoked by rule-driven category enrichment" do
transaction = transactions(:one)
category = categories(:income)
assert_nil category.last_used_at
transaction.enrich_attribute(:category_id, category.id, source: "rule")
assert_nil category.reload.last_used_at
end
end