Files
sure/test/models/transaction_test.rb
T
Guillem Arias Fauste 23e7db4f4d feat(transactions): surface recently-used categories in the category picker (#2829)
* fix(transactions): don't crash the rule-prompt flash when clearing a category

needs_rule_notification? only checked saved_change_to_category_id? and
eligible_for_category_rule?, neither of which accounts for category_id
being nil. Clearing a category (Clear category / entryable_attributes
category_id: nil) satisfies both, so the caller went on to read
transaction.category.name against a nil category and crashed.

A rule prompt only makes sense when a category was assigned, not
cleared, so bail out early when there's no category to build a rule
around.

* feat(transactions): surface recently-used categories in the category picker

Reframes the recency-vs-muscle-memory question as additive, not
either/or: a small "Recent" section pinned above the existing
alphabetical list, which stays exactly where it always was below it.
Precedent for reordering the primary list by frequency (Office's old
adaptive menus, browser-history-style resorting) is a well-known
anti-pattern — position drifts under the user's hand. Every picker
that does recency well (VS Code's command palette, Spotify, Slack's
emoji picker) adds a small separate recent cluster instead.

- Category#last_used_at, touched only in
  TransactionCategoriesController#update — the one place a category is
  actually hand-picked by a person, as opposed to a rule or import
  auto-assigning one.
- Category.recently_used_for(family:, excluding:, limit:) batches the
  family-scoped query; dropdowns_controller excludes the already-
  selected category from the Recent section since it's already pinned
  to the top of the main list.
- "Recent" hides itself the moment a search query is typed — it's a
  pre-search shortcut, not a second copy of search results. Its rows
  are force-hidden (not just filtered) so keyboard nav can't land on a
  row that's invisible only because its ancestor section is hidden.

* fix(categories): address review feedback on recent-categories picker

- Track last_used_at from every manual assignment path (transaction edit
  form, categorization wizard bulk-update, create-and-assign), not just
  the category-picker endpoint. Centralized as Transaction#record_category_usage!,
  called explicitly from each manual controller action rather than wired
  to a blanket after_save callback, since rule/import auto-assignment
  must not count as a "recent" pick.
- Give recent-section rows a distinct DOM id (recent_category_option_<id>)
  from their canonical-list counterpart so aria-activedescendant can't
  resolve to a hidden duplicate during keyboard nav.
- Fix migration to ActiveRecord::Migration[7.2] to match the rest of the repo.
- Materialize @recent_categories with .to_a to avoid a redundant query.
2026-08-04 23:03:12 +02:00

223 lines
7.2 KiB
Ruby

require "test_helper"
class TransactionTest < ActiveSupport::TestCase
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