mirror of
https://github.com/we-promise/sure.git
synced 2026-09-09 00:24:15 +00:00
* feat(rules): support multiple tags in the set transaction tags action Fixes #3353. Reuses the existing DS::TagSelect multi-select tag picker (made generic via attribute:/show_label:) instead of a native <select multiple>, so the UX matches the rest of the app. Multiple tag ids are stored as a comma-separated string in the existing value column, keeping single-tag rows backward compatible with no migration. Also closes a read-modify-write race in SetTransactionTags#execute via with_lock. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(rules): address automated review findings on multi-tag actions - Fix data export/import: multi-tag actions were exported/imported as one opaque comma string, losing all but a bogus combined tag on restore. Each tag id is now resolved/reconstructed independently, with a backward-compatible scalar value_ref for single-tag actions. - Fix N+1 in Rule::Action#value_display (options queried once per tag). - Add aria-label to DS::TagSelect's trigger button when show_label is false, so the control keeps an accessible name. - Localize the "to" label in rule action rows (rules.actions.to_label). - Use a monotonic counter instead of Date.now() for nested form indices, closing a same-millisecond collision window. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(rules): batch tag lookups in multi-tag import resolution Avoids one find_by query per tag name when reconstructing multi-tag rule actions during import. * fix(rules): resolve jjmata review findings on multi-tag action - rules_controller.js: prefix the JS-side nested-form index counter with "new_" so it can never collide with the numeric indexes Rails assigns to already-persisted conditions/actions on an edit form. - data_exporter.rb: key the value_ref scalar/array decision off the number of tag ids on the action, not the number that still resolve, so a partially-orphaned multi-tag action keeps round-tripping as an array. - rule_import.rb: split comma-separated set_transaction_tags values into individual tag names during CSV rule import, matching the batched resolution already used by Family::DataImporter. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(rules): CSV-quote multi-tag names so commas don't split them CodeRabbit flagged that a tag name containing a comma (e.g. "Food, Dining") would be silently split into two tags when round-tripped through the comma-separated multi-tag value/CSV formats used by Family::DataExporter, Family::DataImporter, and RuleImport. Add Rule::Action.encode_multi_value_names/.decode_multi_value_names, backed by Ruby's CSV line quoting, and use them at all three call sites instead of a plain join(",")/split(","). A single name without a comma round-trips byte-identical to before, so existing exports and CSV rule templates are unaffected. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: GFR <248542187+gfr-free@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
372 lines
14 KiB
Ruby
372 lines
14 KiB
Ruby
require "test_helper"
|
|
|
|
class RuleImportTest < ActiveSupport::TestCase
|
|
setup do
|
|
@family = families(:dylan_family)
|
|
@category = @family.categories.create!(
|
|
name: "Groceries",
|
|
color: "#407706",
|
|
lucide_icon: "shopping-basket"
|
|
)
|
|
@csv = <<~CSV
|
|
name,resource_type,active,effective_date,conditions,actions
|
|
"Categorize groceries","transaction",true,2024-01-01,"[{\"condition_type\":\"transaction_name\",\"operator\":\"like\",\"value\":\"grocery\"}]","[{\"action_type\":\"set_transaction_category\",\"value\":\"Groceries\"}]"
|
|
"Auto-categorize transactions","transaction",false,,"[{\"condition_type\":\"transaction_amount\",\"operator\":\">\",\"value\":\"100\"}]","[{\"action_type\":\"auto_categorize\"}]"
|
|
CSV
|
|
end
|
|
|
|
test "imports rules from CSV" do
|
|
import = @family.imports.create!(type: "RuleImport", raw_file_str: @csv, col_sep: ",")
|
|
import.generate_rows_from_csv
|
|
assert_equal 2, import.rows.count
|
|
|
|
assert_difference -> { Rule.where(family: @family).count }, 2 do
|
|
import.send(:import!)
|
|
end
|
|
|
|
grocery_rule = Rule.find_by!(family: @family, name: "Categorize groceries")
|
|
auto_rule = Rule.find_by!(family: @family, name: "Auto-categorize transactions")
|
|
|
|
assert_equal "transaction", grocery_rule.resource_type
|
|
assert grocery_rule.active
|
|
assert_equal Date.parse("2024-01-01"), grocery_rule.effective_date
|
|
assert_equal 1, grocery_rule.conditions.count
|
|
assert_equal 1, grocery_rule.actions.count
|
|
|
|
assert_equal "transaction", auto_rule.resource_type
|
|
assert_not auto_rule.active
|
|
assert_nil auto_rule.effective_date
|
|
assert_equal 1, auto_rule.conditions.count
|
|
assert_equal 1, auto_rule.actions.count
|
|
end
|
|
|
|
test "imports rule conditions correctly" do
|
|
import = @family.imports.create!(type: "RuleImport", raw_file_str: @csv, col_sep: ",")
|
|
import.generate_rows_from_csv
|
|
import.send(:import!)
|
|
|
|
grocery_rule = Rule.find_by!(family: @family, name: "Categorize groceries")
|
|
condition = grocery_rule.conditions.first
|
|
|
|
assert_equal "transaction_name", condition.condition_type
|
|
assert_equal "like", condition.operator
|
|
assert_equal "grocery", condition.value
|
|
end
|
|
|
|
test "imports rule actions correctly and maps category names to IDs" do
|
|
import = @family.imports.create!(type: "RuleImport", raw_file_str: @csv, col_sep: ",")
|
|
import.generate_rows_from_csv
|
|
import.send(:import!)
|
|
|
|
grocery_rule = Rule.find_by!(family: @family, name: "Categorize groceries")
|
|
action = grocery_rule.actions.first
|
|
|
|
assert_equal "set_transaction_category", action.action_type
|
|
assert_equal @category.id, action.value
|
|
end
|
|
|
|
test "imports compound conditions with sub-conditions" do
|
|
csv = <<~CSV
|
|
name,resource_type,active,effective_date,conditions,actions
|
|
"Complex rule","transaction",true,,"[{\"condition_type\":\"compound\",\"operator\":\"or\",\"sub_conditions\":[{\"condition_type\":\"transaction_name\",\"operator\":\"like\",\"value\":\"walmart\"},{\"condition_type\":\"transaction_name\",\"operator\":\"like\",\"value\":\"target\"}]}]","[{\"action_type\":\"set_transaction_category\",\"value\":\"Groceries\"}]"
|
|
CSV
|
|
|
|
import = @family.imports.create!(type: "RuleImport", raw_file_str: csv, col_sep: ",")
|
|
import.generate_rows_from_csv
|
|
import.send(:import!)
|
|
|
|
rule = Rule.find_by!(family: @family, name: "Complex rule")
|
|
assert_equal 1, rule.conditions.count
|
|
|
|
compound_condition = rule.conditions.first
|
|
assert compound_condition.compound?
|
|
assert_equal "or", compound_condition.operator
|
|
assert_equal 2, compound_condition.sub_conditions.count
|
|
|
|
sub_condition_1 = compound_condition.sub_conditions.first
|
|
assert_equal "transaction_name", sub_condition_1.condition_type
|
|
assert_equal "like", sub_condition_1.operator
|
|
assert_equal "walmart", sub_condition_1.value
|
|
|
|
sub_condition_2 = compound_condition.sub_conditions.last
|
|
assert_equal "transaction_name", sub_condition_2.condition_type
|
|
assert_equal "like", sub_condition_2.operator
|
|
assert_equal "target", sub_condition_2.value
|
|
end
|
|
|
|
test "creates missing categories when importing actions" do
|
|
csv = <<~CSV
|
|
name,resource_type,active,effective_date,conditions,actions
|
|
"New category rule","transaction",true,,"[{\"condition_type\":\"transaction_name\",\"operator\":\"like\",\"value\":\"coffee\"}]","[{\"action_type\":\"set_transaction_category\",\"value\":\"Coffee Shops\"}]"
|
|
CSV
|
|
|
|
import = @family.imports.create!(type: "RuleImport", raw_file_str: csv, col_sep: ",")
|
|
import.generate_rows_from_csv
|
|
|
|
assert_difference -> { Category.where(family: @family).count }, 1 do
|
|
import.send(:import!)
|
|
end
|
|
|
|
new_category = Category.find_by!(family: @family, name: "Coffee Shops")
|
|
assert_equal Category::UNCATEGORIZED_COLOR, new_category.color
|
|
|
|
rule = Rule.find_by!(family: @family, name: "New category rule")
|
|
action = rule.actions.first
|
|
assert_equal new_category.id, action.value
|
|
end
|
|
|
|
test "creates missing tags when importing actions" do
|
|
csv = <<~CSV
|
|
name,resource_type,active,effective_date,conditions,actions
|
|
"New tag rule","transaction",true,,"[{\"condition_type\":\"transaction_name\",\"operator\":\"like\",\"value\":\"coffee\"}]","[{\"action_type\":\"set_transaction_tags\",\"value\":\"Coffee Tag\"}]"
|
|
CSV
|
|
|
|
import = @family.imports.create!(type: "RuleImport", raw_file_str: csv, col_sep: ",")
|
|
import.generate_rows_from_csv
|
|
|
|
assert_difference -> { Tag.where(family: @family).count }, 1 do
|
|
import.send(:import!)
|
|
end
|
|
|
|
new_tag = Tag.find_by!(family: @family, name: "Coffee Tag")
|
|
|
|
rule = Rule.find_by!(family: @family, name: "New tag rule")
|
|
action = rule.actions.first
|
|
assert_equal "set_transaction_tags", action.action_type
|
|
assert_equal new_tag.id, action.value
|
|
end
|
|
|
|
test "reuses existing tags when importing actions" do
|
|
existing_tag = @family.tags.create!(name: "Existing Tag")
|
|
|
|
csv = <<~CSV
|
|
name,resource_type,active,effective_date,conditions,actions
|
|
"Tag rule","transaction",true,,"[{\"condition_type\":\"transaction_name\",\"operator\":\"like\",\"value\":\"test\"}]","[{\"action_type\":\"set_transaction_tags\",\"value\":\"Existing Tag\"}]"
|
|
CSV
|
|
|
|
import = @family.imports.create!(type: "RuleImport", raw_file_str: csv, col_sep: ",")
|
|
import.generate_rows_from_csv
|
|
|
|
assert_no_difference -> { Tag.where(family: @family).count } do
|
|
import.send(:import!)
|
|
end
|
|
|
|
rule = Rule.find_by!(family: @family, name: "Tag rule")
|
|
action = rule.actions.first
|
|
assert_equal "set_transaction_tags", action.action_type
|
|
assert_equal existing_tag.id, action.value
|
|
end
|
|
|
|
test "imports multi-tag actions, reusing existing tags and creating missing ones" do
|
|
existing_tag = @family.tags.create!(name: "Existing Tag")
|
|
|
|
csv = <<~CSV
|
|
name,resource_type,active,effective_date,conditions,actions
|
|
"Multi tag rule","transaction",true,,"[{\"condition_type\":\"transaction_name\",\"operator\":\"like\",\"value\":\"test\"}]","[{\"action_type\":\"set_transaction_tags\",\"value\":\"Existing Tag,New Tag\"}]"
|
|
CSV
|
|
|
|
import = @family.imports.create!(type: "RuleImport", raw_file_str: csv, col_sep: ",")
|
|
import.generate_rows_from_csv
|
|
|
|
assert_difference -> { Tag.where(family: @family).count }, 1 do
|
|
import.send(:import!)
|
|
end
|
|
|
|
new_tag = Tag.find_by!(family: @family, name: "New Tag")
|
|
|
|
rule = Rule.find_by!(family: @family, name: "Multi tag rule")
|
|
action = rule.actions.first
|
|
assert_equal "set_transaction_tags", action.action_type
|
|
assert_equal [ existing_tag.id, new_tag.id ], action.value.split(",")
|
|
end
|
|
|
|
test "imports a multi-tag action preserving a tag name that contains a comma" do
|
|
existing_tag = @family.tags.create!(name: "Existing Tag")
|
|
|
|
actions_json = [
|
|
{
|
|
action_type: "set_transaction_tags",
|
|
value: CSV.generate_line([ "Existing Tag", "Food, Dining" ], row_sep: "")
|
|
}
|
|
].to_json
|
|
conditions_json = [
|
|
{ condition_type: "transaction_name", operator: "like", value: "test" }
|
|
].to_json
|
|
|
|
csv = CSV.generate do |csv_out|
|
|
csv_out << %w[name resource_type active effective_date conditions actions]
|
|
csv_out << [ "Comma tag rule", "transaction", true, nil, conditions_json, actions_json ]
|
|
end
|
|
|
|
import = @family.imports.create!(type: "RuleImport", raw_file_str: csv, col_sep: ",")
|
|
import.generate_rows_from_csv
|
|
|
|
assert_difference -> { Tag.where(family: @family).count }, 1 do
|
|
import.send(:import!)
|
|
end
|
|
|
|
comma_tag = Tag.find_by!(family: @family, name: "Food, Dining")
|
|
|
|
rule = Rule.find_by!(family: @family, name: "Comma tag rule")
|
|
action = rule.actions.first
|
|
assert_equal "set_transaction_tags", action.action_type
|
|
assert_equal [ existing_tag.id, comma_tag.id ], action.value.split(",")
|
|
end
|
|
|
|
test "updates existing rule when re-importing with same name" do
|
|
# First import
|
|
import1 = @family.imports.create!(type: "RuleImport", raw_file_str: @csv, col_sep: ",")
|
|
import1.generate_rows_from_csv
|
|
import1.send(:import!)
|
|
|
|
original_rule = Rule.find_by!(family: @family, name: "Categorize groceries")
|
|
assert original_rule.active
|
|
|
|
# Second import with updated rule
|
|
csv2 = <<~CSV
|
|
name,resource_type,active,effective_date,conditions,actions
|
|
"Categorize groceries","transaction",false,2024-02-01,"[{\"condition_type\":\"transaction_name\",\"operator\":\"like\",\"value\":\"market\"}]","[{\"action_type\":\"auto_categorize\"}]"
|
|
CSV
|
|
|
|
import2 = @family.imports.create!(type: "RuleImport", raw_file_str: csv2, col_sep: ",")
|
|
import2.generate_rows_from_csv
|
|
|
|
assert_no_difference -> { Rule.where(family: @family).count } do
|
|
import2.send(:import!)
|
|
end
|
|
|
|
updated_rule = Rule.find_by!(family: @family, name: "Categorize groceries")
|
|
assert_equal original_rule.id, updated_rule.id
|
|
assert_not updated_rule.active
|
|
assert_equal Date.parse("2024-02-01"), updated_rule.effective_date
|
|
|
|
# Verify old conditions/actions are replaced
|
|
condition = updated_rule.conditions.first
|
|
assert_equal "market", condition.value
|
|
|
|
action = updated_rule.actions.first
|
|
assert_equal "auto_categorize", action.action_type
|
|
end
|
|
|
|
test "validates resource_type" do
|
|
csv = <<~CSV
|
|
name,resource_type,active,effective_date,conditions,actions
|
|
"Invalid rule","invalid_type",true,,"[{\"condition_type\":\"transaction_name\",\"operator\":\"like\",\"value\":\"test\"}]","[{\"action_type\":\"auto_categorize\"}]"
|
|
CSV
|
|
|
|
import = @family.imports.create!(type: "RuleImport", raw_file_str: csv, col_sep: ",")
|
|
import.generate_rows_from_csv
|
|
|
|
assert_raises ActiveRecord::RecordInvalid do
|
|
import.send(:import!)
|
|
end
|
|
end
|
|
|
|
test "validates at least one action exists" do
|
|
csv = <<~CSV
|
|
name,resource_type,active,effective_date,conditions,actions
|
|
"No actions rule","transaction",true,,"[{\"condition_type\":\"transaction_name\",\"operator\":\"like\",\"value\":\"test\"}]","[]"
|
|
CSV
|
|
|
|
import = @family.imports.create!(type: "RuleImport", raw_file_str: csv, col_sep: ",")
|
|
import.generate_rows_from_csv
|
|
|
|
assert_raises ActiveRecord::RecordInvalid do
|
|
import.send(:import!)
|
|
end
|
|
end
|
|
|
|
test "handles invalid JSON in conditions or actions" do
|
|
csv = <<~CSV
|
|
name,resource_type,active,effective_date,conditions,actions
|
|
"Bad JSON rule","transaction",true,,"invalid json","[{\"action_type\":\"auto_categorize\"}]"
|
|
CSV
|
|
|
|
import = @family.imports.create!(type: "RuleImport", raw_file_str: csv, col_sep: ",")
|
|
import.generate_rows_from_csv
|
|
|
|
assert_raises ActiveRecord::RecordInvalid do
|
|
import.send(:import!)
|
|
end
|
|
end
|
|
|
|
test "imports valid JSON conditions whose values contain escaped quotes" do
|
|
csv = CSV.generate do |out|
|
|
out << %w[name resource_type active effective_date conditions actions]
|
|
out << [
|
|
"Quoted value rule",
|
|
"transaction",
|
|
true,
|
|
"",
|
|
[ { condition_type: "transaction_name", operator: "=", value: "ני\\u0022ע-קניה" } ].to_json,
|
|
[ { action_type: "set_transaction_name", value: "Quoted transfer" } ].to_json
|
|
]
|
|
end
|
|
|
|
import = @family.imports.create!(type: "RuleImport", raw_file_str: csv, col_sep: ",")
|
|
import.generate_rows_from_csv
|
|
|
|
assert_nothing_raised do
|
|
import.send(:import!)
|
|
end
|
|
|
|
rule = Rule.find_by!(family: @family, name: "Quoted value rule")
|
|
condition = rule.conditions.first
|
|
assert_equal "ני\"ע-קניה", condition.value
|
|
end
|
|
|
|
test "imports wrapped JSON payloads from legacy rows" do
|
|
wrapped_conditions = [ { condition_type: "transaction_name", operator: "like", value: "legacy grocery" } ].to_json.to_json
|
|
wrapped_actions = [ { action_type: "set_transaction_category", value: "Groceries" } ].to_json.to_json
|
|
|
|
csv = CSV.generate do |out|
|
|
out << %w[name resource_type active effective_date conditions actions]
|
|
out << [
|
|
"Wrapped payload rule",
|
|
"transaction",
|
|
true,
|
|
"",
|
|
wrapped_conditions,
|
|
wrapped_actions
|
|
]
|
|
end
|
|
|
|
import = @family.imports.create!(type: "RuleImport", raw_file_str: csv, col_sep: ",")
|
|
import.generate_rows_from_csv
|
|
|
|
assert_nothing_raised do
|
|
import.send(:import!)
|
|
end
|
|
|
|
rule = Rule.find_by!(family: @family, name: "Wrapped payload rule")
|
|
condition = rule.conditions.first
|
|
action = rule.actions.first
|
|
|
|
assert_equal "legacy grocery", condition.value
|
|
assert_equal @category.id, action.value
|
|
end
|
|
|
|
test "preserves literal backslash sequences in valid JSON values" do
|
|
csv = CSV.generate do |out|
|
|
out << %w[name resource_type active effective_date conditions actions]
|
|
out << [
|
|
"Literal backslash rule",
|
|
"transaction",
|
|
true,
|
|
"",
|
|
[ { condition_type: "transaction_name", operator: "=", value: 'C:\new\test' } ].to_json,
|
|
[ { action_type: "set_transaction_name", value: "Path rule" } ].to_json
|
|
]
|
|
end
|
|
|
|
import = @family.imports.create!(type: "RuleImport", raw_file_str: csv, col_sep: ",")
|
|
import.generate_rows_from_csv
|
|
import.send(:import!)
|
|
|
|
rule = Rule.find_by!(family: @family, name: "Literal backslash rule")
|
|
condition = rule.conditions.first
|
|
|
|
assert_equal 'C:\new\test', condition.value
|
|
end
|
|
end
|