mirror of
https://github.com/we-promise/sure.git
synced 2026-09-09 08:34:26 +00:00
* feat(transactions): cascade parent/subcategory checkboxes in the category filter Checking a parent category in the transaction filter sidebar now auto-checks its subcategories, and vice versa. Unchecking a single subcategory also unchecks the parent so the submitted filter never silently includes a category the user just deselected — Transaction::Search#apply_category_filter includes every subcategory whenever a parent name is present, with no way to exclude one individually, so the parent checkbox must reflect exactly what gets submitted. Also fixes the "swipe-to-categorize" pill picker (transactions/categorizes/show.html.erb), which was still a flat alphabetical list with no parent/child indication — now grouped and labeled consistently with the rest of the app (PR #2845, #3292). Adds an :indeterminate style for .checkbox--light (only .checkbox--dark had one). Closes discussion #3149. This code was written by Claude Code (Anthropic). * fix(transactions): preserve parent-only category filters on reopen connect() derived each checkbox's parent/child state independently from server-rendered checked attributes, so a parent-only filter (e.g. an incoming link naming only the parent category) rendered the parent checked with its children unchecked — syncParentState() then read that as "some children unchecked" and cleared the parent, silently dropping the filter on the next Apply. Cascade checked parents to their children before deriving parent state so the picker matches the active query. Also makes the internal helper methods private per review feedback. * fix(transactions): eager-load category parent, fix categorize-pill filter text Addresses PR #3356 review from jjmata: - Current.family.categories.alphabetically caused an N+1 (SELECT per parent) via display_name_with_parent in the categorize-wizard pill loop. Added .includes(:parent) at all three call sites. - data-filter-name still used the bare category name while the pill label showed "Parent > Child" for subcategories, so searching by the visible parent prefix found nothing. Both now share one computed label. --------- Co-authored-by: Gerald <248542187+gfr-free@users.noreply.github.com>
324 lines
11 KiB
Ruby
324 lines
11 KiB
Ruby
require "test_helper"
|
|
|
|
class Transactions::CategorizesControllerTest < ActionDispatch::IntegrationTest
|
|
include EntriesTestHelper
|
|
|
|
setup do
|
|
sign_in @user = users(:family_admin)
|
|
@family = @user.family
|
|
@account = accounts(:depository)
|
|
@category = categories(:food_and_drink)
|
|
# Clear entries for isolation
|
|
@family.accounts.each { |a| a.entries.delete_all }
|
|
end
|
|
|
|
# GET /transactions/categorize
|
|
|
|
test "show redirects with notice when nothing to categorize" do
|
|
get transactions_categorize_url
|
|
assert_redirected_to transactions_url
|
|
assert_match "categorized", flash[:notice]
|
|
end
|
|
|
|
test "show renders wizard when uncategorized transactions exist" do
|
|
create_transaction(account: @account, name: "Starbucks")
|
|
get transactions_categorize_url
|
|
assert_response :success
|
|
end
|
|
|
|
test "show groups subcategories immediately after their parent in the category select" do
|
|
create_transaction(account: @account, name: "Starbucks")
|
|
get transactions_categorize_url
|
|
|
|
assert_response :success
|
|
|
|
doc = Nokogiri::HTML::Document.parse(response.body)
|
|
option_values = doc.css("select[name='category_id'] option").map { |node| node["value"] }
|
|
|
|
parent_index = option_values.index(categories(:food_and_drink).id)
|
|
child_index = option_values.index(categories(:subcategory).id)
|
|
|
|
assert_not_nil parent_index
|
|
assert_not_nil child_index
|
|
assert_equal parent_index + 1, child_index
|
|
end
|
|
|
|
test "show groups subcategories immediately after their parent in the category pills" do
|
|
create_transaction(account: @account, name: "Starbucks")
|
|
get transactions_categorize_url
|
|
|
|
assert_response :success
|
|
|
|
doc = Nokogiri::HTML::Document.parse(response.body)
|
|
pill_values = doc.css("button[name='category_id']").map { |node| node["value"] }
|
|
|
|
parent_index = pill_values.index(categories(:food_and_drink).id)
|
|
child_index = pill_values.index(categories(:subcategory).id)
|
|
|
|
assert_not_nil parent_index
|
|
assert_not_nil child_index
|
|
assert_equal parent_index + 1, child_index
|
|
|
|
child_pill = doc.css("button[name='category_id'][value='#{categories(:subcategory).id}']").first
|
|
assert_includes child_pill.text, categories(:subcategory).display_name_with_parent
|
|
end
|
|
|
|
test "show renders full dates so multi-year lists are unambiguous" do
|
|
create_transaction(account: @account, name: "Starbucks", date: Date.new(2024, 7, 8))
|
|
|
|
get transactions_categorize_url
|
|
|
|
assert_response :success
|
|
# format_date uses the family's date_format preference, every variant of
|
|
# which includes the year; the previous :short format ("%b %d") did not,
|
|
# making rows ambiguous when the uncategorized list spans years.
|
|
expected = Date.new(2024, 7, 8).strftime(@family.date_format)
|
|
assert_match expected, response.body
|
|
end
|
|
|
|
test "show renders the first group at position 0" do
|
|
2.times { create_transaction(account: @account, name: "Netflix") }
|
|
3.times { create_transaction(account: @account, name: "Starbucks") }
|
|
|
|
get transactions_categorize_url(position: 0)
|
|
|
|
assert_response :success
|
|
assert_select "h2", text: "Starbucks"
|
|
end
|
|
|
|
test "show at position 1 skips first group" do
|
|
3.times { create_transaction(account: @account, name: "Starbucks") }
|
|
2.times { create_transaction(account: @account, name: "Netflix") }
|
|
|
|
get transactions_categorize_url(position: 1)
|
|
|
|
assert_response :success
|
|
assert_select "h2", text: "Netflix"
|
|
end
|
|
|
|
test "show redirects when position exceeds available groups" do
|
|
create_transaction(account: @account, name: "Starbucks")
|
|
|
|
get transactions_categorize_url(position: 99)
|
|
|
|
assert_redirected_to transactions_url
|
|
end
|
|
|
|
test "requires authentication" do
|
|
sign_out
|
|
get transactions_categorize_url
|
|
assert_redirected_to new_session_url
|
|
end
|
|
|
|
# Account sharing authorization
|
|
|
|
test "show only groups entries from accounts accessible to the user" do
|
|
accessible_account = accounts(:depository) # shared with family_member (full_control)
|
|
inaccessible_account = accounts(:investment) # not shared with family_member
|
|
|
|
create_transaction(account: accessible_account, name: "Starbucks")
|
|
create_transaction(account: inaccessible_account, name: "Starbucks")
|
|
|
|
sign_in users(:family_member)
|
|
get transactions_categorize_url(position: 0)
|
|
|
|
assert_response :success
|
|
# Only 1 entry should appear in the group — the inaccessible account's entry is hidden
|
|
assert_select "input[name='entry_ids[]']", count: 1
|
|
end
|
|
|
|
test "create does not categorize entries from inaccessible accounts" do
|
|
inaccessible_account = accounts(:investment) # not shared with family_member
|
|
entry = create_transaction(account: inaccessible_account, name: "Starbucks")
|
|
|
|
sign_in users(:family_member)
|
|
post transactions_categorize_url,
|
|
params: {
|
|
position: 0,
|
|
grouping_key: "Starbucks",
|
|
entry_ids: [ entry.id ],
|
|
all_entry_ids: [ entry.id ],
|
|
category_id: @category.id
|
|
},
|
|
headers: { "Accept" => "text/vnd.turbo-stream.html" }
|
|
|
|
assert_nil entry.transaction.reload.category
|
|
end
|
|
|
|
test "assign_entry does not categorize an entry from an inaccessible account" do
|
|
inaccessible_account = accounts(:investment) # not shared with family_member
|
|
entry = create_transaction(account: inaccessible_account, name: "Starbucks")
|
|
|
|
sign_in users(:family_member)
|
|
patch assign_entry_transactions_categorize_url, params: {
|
|
entry_id: entry.id,
|
|
category_id: @category.id,
|
|
position: 0,
|
|
all_entry_ids: [ entry.id ]
|
|
}
|
|
|
|
assert_response :not_found
|
|
assert_nil entry.transaction.reload.category
|
|
end
|
|
|
|
# GET /transactions/categorize/preview_rule
|
|
|
|
test "preview_rule returns matching entries for a filter" do
|
|
create_transaction(account: @account, name: "Amazon Prime")
|
|
create_transaction(account: @account, name: "Amazon Music")
|
|
create_transaction(account: @account, name: "Starbucks")
|
|
|
|
get preview_rule_transactions_categorize_url(filter: "Amazon"),
|
|
headers: { "Accept" => "text/vnd.turbo-stream.html" }
|
|
|
|
assert_response :success
|
|
assert_includes response.body, "Amazon Prime"
|
|
assert_includes response.body, "Amazon Music"
|
|
assert_not_includes response.body, "Starbucks"
|
|
end
|
|
|
|
test "preview_rule returns empty list for blank filter" do
|
|
create_transaction(account: @account, name: "Amazon")
|
|
|
|
get preview_rule_transactions_categorize_url(filter: ""),
|
|
headers: { "Accept" => "text/vnd.turbo-stream.html" }
|
|
|
|
assert_response :success
|
|
assert_not_includes response.body, "Amazon"
|
|
end
|
|
|
|
test "preview_rule requires authentication" do
|
|
sign_out
|
|
get preview_rule_transactions_categorize_url(filter: "Amazon")
|
|
assert_redirected_to new_session_url
|
|
end
|
|
|
|
private
|
|
|
|
def sign_out
|
|
# Deleting sessions through the controller de-authenticates the request the
|
|
# moment our own session dies, so every later delete in the loop is a
|
|
# silent no-op and whichever sessions sort after it survive. The order is
|
|
# unspecified, which made every suite that signs out this way flaky.
|
|
# Teardown hygiene is not the behavior under test, so destroy directly.
|
|
@user.sessions.destroy_all
|
|
end
|
|
|
|
# POST /transactions/categorize
|
|
|
|
test "create categorizes selected entries and returns redirect stream when all assigned" do
|
|
entry = create_transaction(account: @account, name: "Starbucks")
|
|
|
|
post transactions_categorize_url,
|
|
params: {
|
|
position: 0,
|
|
grouping_key: "Starbucks",
|
|
entry_ids: [ entry.id ],
|
|
all_entry_ids: [ entry.id ],
|
|
category_id: @category.id
|
|
},
|
|
headers: { "Accept" => "text/vnd.turbo-stream.html" }
|
|
|
|
assert_response :success
|
|
assert_equal @category, entry.transaction.reload.category
|
|
assert_includes response.body, "action=\"redirect\""
|
|
end
|
|
|
|
test "create removes assigned rows and replaces remaining when partial assignment" do
|
|
entry1 = create_transaction(account: @account, name: "Starbucks")
|
|
entry2 = create_transaction(account: @account, name: "Starbucks")
|
|
|
|
post transactions_categorize_url,
|
|
params: {
|
|
position: 0,
|
|
grouping_key: "Starbucks",
|
|
entry_ids: [ entry1.id ],
|
|
all_entry_ids: [ entry1.id, entry2.id ],
|
|
category_id: @category.id
|
|
},
|
|
headers: { "Accept" => "text/vnd.turbo-stream.html" }
|
|
|
|
assert_response :success
|
|
assert_equal @category, entry1.transaction.reload.category
|
|
assert_nil entry2.transaction.reload.category
|
|
# Remove stream for categorized entry
|
|
assert_includes response.body, "categorize_entry_#{entry1.id}"
|
|
# Replace stream for remaining entry (re-checked)
|
|
assert_includes response.body, "categorize_entry_#{entry2.id}"
|
|
# No redirect stream — still in the group
|
|
assert_not_includes response.body, "action=\"redirect\""
|
|
end
|
|
|
|
test "create with create_rule param creates rule with name and type conditions" do
|
|
entry = create_transaction(account: @account, name: "Netflix", amount: 15)
|
|
|
|
assert_difference "@family.rules.count", 1 do
|
|
post transactions_categorize_url,
|
|
params: {
|
|
position: 0,
|
|
grouping_key: "Netflix",
|
|
transaction_type: "expense",
|
|
entry_ids: [ entry.id ],
|
|
all_entry_ids: [ entry.id ],
|
|
category_id: @category.id,
|
|
create_rule: "1"
|
|
},
|
|
headers: { "Accept" => "text/vnd.turbo-stream.html" }
|
|
end
|
|
|
|
rule = @family.rules.find_by(name: "Netflix")
|
|
assert_not_nil rule
|
|
assert rule.active
|
|
assert rule.conditions.any? { |c| c.condition_type == "transaction_name" && c.value == "Netflix" }
|
|
assert rule.conditions.any? { |c| c.condition_type == "transaction_type" && c.value == "expense" }
|
|
end
|
|
|
|
test "create falls back to html redirect without turbo stream header" do
|
|
entry = create_transaction(account: @account, name: "Starbucks")
|
|
|
|
post transactions_categorize_url, params: {
|
|
position: 0,
|
|
grouping_key: "Starbucks",
|
|
entry_ids: [ entry.id ],
|
|
all_entry_ids: [ entry.id ],
|
|
category_id: @category.id
|
|
}
|
|
|
|
assert_redirected_to transactions_categorize_url(position: 0)
|
|
assert flash[:notice].present?
|
|
end
|
|
|
|
# PATCH /transactions/categorize/assign_entry
|
|
|
|
test "assign_entry categorizes single entry and returns remove stream" do
|
|
entry = create_transaction(account: @account, name: "Starbucks")
|
|
other = create_transaction(account: @account, name: "Starbucks")
|
|
|
|
patch assign_entry_transactions_categorize_url, params: {
|
|
entry_id: entry.id,
|
|
category_id: @category.id,
|
|
position: 0,
|
|
all_entry_ids: [ entry.id, other.id ]
|
|
}
|
|
|
|
assert_response :success
|
|
assert_equal @category, entry.transaction.reload.category
|
|
assert_includes response.body, "categorize_entry_#{entry.id}"
|
|
assert_not_includes response.body, "action=\"redirect\""
|
|
end
|
|
|
|
test "assign_entry returns redirect stream when last entry in group" do
|
|
entry = create_transaction(account: @account, name: "Starbucks")
|
|
|
|
patch assign_entry_transactions_categorize_url, params: {
|
|
entry_id: entry.id,
|
|
category_id: @category.id,
|
|
position: 0,
|
|
all_entry_ids: [ entry.id ]
|
|
}
|
|
|
|
assert_response :success
|
|
assert_includes response.body, "action=\"redirect\""
|
|
end
|
|
end
|