mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 20:14:08 +00:00
Add rules import/export support (#424)
* Add full import/export support for rules with versioned JSON schema This commit implements comprehensive import/export functionality for rules, allowing users to back up and restore their rule definitions. Key features: - Export rules to both CSV and NDJSON formats with versioned schema (v1) - Import rules from CSV with full support for nested conditions and actions - UUID to name mapping for categories and merchants for portability - Support for compound conditions with sub-conditions - Comprehensive test coverage for export and import functionality - UI integration for rules import in the imports interface Technical details: - Extended Family::DataExporter to generate rules.csv and include rules in all.ndjson - Created RuleImport model following the existing Import STI pattern - Added migration for rule-specific columns in import_rows table - Implemented serialization helpers to map UUIDs to human-readable names - Added i18n support for the new import option - Included versioning in NDJSON export to support future schema evolution The implementation ensures rules can be safely exported from one family and imported into another, even when category/merchant IDs differ, by mapping between names and IDs during export/import. * Fix AR migration version * Mention support for rules export * Rabbit suggestion * Fix tests * Missed schema.rb * Fix sample CSV download for rule import * Fix parsing in Rules import * Fix tests * Rule import message i18n * Export tag names, not UUIDs * Make sure tags are created if needed at import * Avoid test errors when running in parallel --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
238
test/models/rule_import_test.rb
Normal file
238
test/models/rule_import_test.rb
Normal file
@@ -0,0 +1,238 @@
|
||||
require "test_helper"
|
||||
|
||||
class RuleImportTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@category = @family.categories.create!(
|
||||
name: "Groceries",
|
||||
color: "#407706",
|
||||
classification: "expense",
|
||||
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
|
||||
assert_equal "expense", new_category.classification
|
||||
|
||||
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 "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
|
||||
end
|
||||
Reference in New Issue
Block a user