mirror of
https://github.com/we-promise/sure.git
synced 2026-09-03 13:51:29 +00:00
* 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.
223 lines
7.2 KiB
Ruby
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
|