diff --git a/app/models/rule_import.rb b/app/models/rule_import.rb index bae5820d7..48ae775fc 100644 --- a/app/models/rule_import.rb +++ b/app/models/rule_import.rb @@ -289,9 +289,14 @@ class RuleImport < Import def parse_json_safely(json_string, field_name) return [] if json_string.blank? - # Clean up the JSON string - remove extra escaping that might come from CSV parsing cleaned = json_string.to_s.strip + # Most API-created rows already store valid JSON. Parse them as-is before + # falling back to the legacy cleanup path for older malformed payloads. + parse_json_payload(cleaned, normalize_legacy_strings: false) + rescue JSON::ParserError + # Clean up the JSON string - remove extra escaping that might come from CSV parsing + # Remove surrounding quotes if present (both single and double) cleaned = cleaned.gsub(/\A["']+|["']+\z/, "") @@ -321,8 +326,46 @@ class RuleImport < Import end # Try parsing - JSON.parse(cleaned) + parse_json_payload(cleaned, normalize_legacy_strings: true) rescue JSON::ParserError => e raise JSON::ParserError.new("Invalid JSON in #{field_name}: #{e.message}. Raw value: #{json_string.inspect}") end + + def parse_json_payload(payload, normalize_legacy_strings:) + parsed = JSON.parse(payload) + parsed = JSON.parse(parsed) if wrapped_json_payload?(parsed) + + normalize_json_values(parsed, normalize_legacy_strings:) + end + + def wrapped_json_payload?(value) + return false unless value.is_a?(String) + + stripped_value = value.strip + stripped_value.start_with?("[", "{") + end + + def normalize_json_values(value, normalize_legacy_strings:) + case value + when Array + value.map { |item| normalize_json_values(item, normalize_legacy_strings:) } + when Hash + value.transform_values { |item| normalize_json_values(item, normalize_legacy_strings:) } + when String + normalized = value + .gsub(/\\u([0-9a-fA-F]{4})/i) { [ $1.to_i(16) ].pack("U") } + .gsub('\\"', '"') + + if normalize_legacy_strings + normalized = normalized + .gsub("\\n", "\n") + .gsub("\\r", "\r") + .gsub("\\t", "\t") + end + + normalized + else + value + end + end end diff --git a/test/models/rule_import_test.rb b/test/models/rule_import_test.rb index 23cc246f8..ca95f8390 100644 --- a/test/models/rule_import_test.rb +++ b/test/models/rule_import_test.rb @@ -233,4 +233,83 @@ class RuleImportTest < ActiveSupport::TestCase 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