diff --git a/app/controllers/api/v1/imports_controller.rb b/app/controllers/api/v1/imports_controller.rb index 0243b13c4..23b93ae7d 100644 --- a/app/controllers/api/v1/imports_controller.rb +++ b/app/controllers/api/v1/imports_controller.rb @@ -4,9 +4,10 @@ class Api::V1::ImportsController < Api::V1::BaseController include Pagy::Backend # Ensure proper scope authorization - before_action :ensure_read_scope, only: [ :index, :show ] + before_action :ensure_read_scope, only: [ :index, :show, :rows ] before_action :ensure_write_scope, only: [ :create ] - before_action :set_import, only: [ :show ] + before_action :set_import_with_rows, only: [ :show ] + before_action :set_import, only: [ :rows ] def index family = current_resource_owner.family @@ -44,6 +45,22 @@ class Api::V1::ImportsController < Api::V1::BaseController render json: { error: "internal_server_error", message: e.message }, status: :internal_server_error end + def rows + @per_page = safe_per_page_param + @pagy, @rows = pagy( + @import.rows_ordered, + page: safe_page_param, + limit: @per_page + ) + @rows.each(&:valid?) + @row_mapping_lookup = @import.mappings.includes(:mappable).index_by { |mapping| [ mapping.type, mapping.key.to_s ] } + + render :rows + rescue StandardError => e + Rails.logger.error "ImportsController#rows error: #{e.message}" + render json: { error: "internal_server_error", message: e.message }, status: :internal_server_error + end + def create family = current_resource_owner.family @@ -122,8 +139,22 @@ class Api::V1::ImportsController < Api::V1::BaseController private def set_import - @import = current_resource_owner.family.imports.includes(:rows).find(params[:id]) + @import = import_scope.find(params[:id]) rescue ActiveRecord::RecordNotFound + render_import_not_found + end + + def set_import_with_rows + @import = import_scope.includes(:rows).find(params[:id]) + rescue ActiveRecord::RecordNotFound + render_import_not_found + end + + def import_scope + current_resource_owner.family.imports + end + + def render_import_not_found render json: { error: "not_found", message: "Import not found" }, status: :not_found end diff --git a/app/models/category_import.rb b/app/models/category_import.rb index f58a50b70..15e3a6dd8 100644 --- a/app/models/category_import.rb +++ b/app/models/category_import.rb @@ -65,8 +65,9 @@ class CategoryImport < Import parent_header = header_for("parent_category", "parent category") icon_header = header_for("lucide_icon", "lucide icon", "icon") - csv_rows.each do |row| + csv_rows.each.with_index(1) do |row, index| rows.create!( + source_row_number: index, name: row[name_header].to_s.strip, category_color: row[color_header].to_s.strip, category_parent: row[parent_header].to_s.strip, diff --git a/app/models/import.rb b/app/models/import.rb index a893e7f13..fba9c8c32 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -202,8 +202,9 @@ class Import < ApplicationRecord def generate_rows_from_csv rows.destroy_all - mapped_rows = csv_rows.map do |row| + mapped_rows = csv_rows.map.with_index(1) do |row, index| { + source_row_number: index, account: row[account_col_label].to_s, date: row[date_col_label].to_s, qty: sanitize_number(row[qty_col_label]).to_s, diff --git a/app/models/import/row.rb b/app/models/import/row.rb index 6b68626bd..783afa3be 100644 --- a/app/models/import/row.rb +++ b/app/models/import/row.rb @@ -8,7 +8,7 @@ class Import::Row < ApplicationRecord validate :required_columns validate :currency_is_valid - scope :ordered, -> { order(:id) } + scope :ordered, -> { order(:source_row_number, :id) } def tags_list if tags.blank? diff --git a/app/models/mint_import.rb b/app/models/mint_import.rb index 932353d8a..87997d400 100644 --- a/app/models/mint_import.rb +++ b/app/models/mint_import.rb @@ -4,8 +4,9 @@ class MintImport < Import def generate_rows_from_csv rows.destroy_all - mapped_rows = csv_rows.map do |row| + mapped_rows = csv_rows.map.with_index(1) do |row, index| { + source_row_number: index, account: row[account_col_label].to_s, date: row[date_col_label].to_s, amount: signed_csv_amount(row).to_s, diff --git a/app/models/pdf_import.rb b/app/models/pdf_import.rb index e18d8ce24..f610e6b0f 100644 --- a/app/models/pdf_import.rb +++ b/app/models/pdf_import.rb @@ -114,9 +114,10 @@ class PdfImport < Import currency = account&.currency || family.currency - mapped_rows = extracted_transactions.map do |txn| + mapped_rows = extracted_transactions.map.with_index(1) do |txn, index| { import_id: id, + source_row_number: index, date: format_date_for_import(txn["date"]), amount: txn["amount"].to_s, name: txn["name"].to_s, diff --git a/app/models/qif_import.rb b/app/models/qif_import.rb index aa259f5b4..a0590cb21 100644 --- a/app/models/qif_import.rb +++ b/app/models/qif_import.rb @@ -166,8 +166,9 @@ class QifImport < Import def generate_transaction_rows transactions = QifParser.parse(raw_file_str, date_format: qif_date_format) - mapped_rows = transactions.map do |trn| + mapped_rows = transactions.map.with_index(1) do |trn, index| { + source_row_number: index, date: trn.date.to_s, amount: trn.amount.to_s, currency: default_currency.to_s, @@ -193,11 +194,12 @@ class QifImport < Import def generate_investment_rows inv_transactions = QifParser.parse_investment_transactions(raw_file_str, date_format: qif_date_format) - mapped_rows = inv_transactions.map do |trn| + mapped_rows = inv_transactions.map.with_index(1) do |trn, index| if QifParser::TRADE_ACTIONS.include?(trn.action) qty = trade_qty_for(trn.action, trn.qty) { + source_row_number: index, date: trn.date.to_s, ticker: trn.security_ticker.to_s, qty: qty.to_s, @@ -214,6 +216,7 @@ class QifImport < Import } else { + source_row_number: index, date: trn.date.to_s, amount: trn.amount.to_s, currency: default_currency.to_s, diff --git a/app/models/rule_import.rb b/app/models/rule_import.rb index 48ae775fc..2fac4cb21 100644 --- a/app/models/rule_import.rb +++ b/app/models/rule_import.rb @@ -52,10 +52,11 @@ class RuleImport < Import def generate_rows_from_csv rows.destroy_all - csv_rows.each do |row| + csv_rows.each.with_index(1) do |row, index| normalized_row = normalize_rule_row(row) rows.create!( + source_row_number: index, name: normalized_row[:name].to_s.strip, resource_type: normalized_row[:resource_type].to_s.strip, active: parse_boolean(normalized_row[:active]), diff --git a/app/views/api/v1/imports/rows.json.jbuilder b/app/views/api/v1/imports/rows.json.jbuilder new file mode 100644 index 000000000..75d1ad5ac --- /dev/null +++ b/app/views/api/v1/imports/rows.json.jbuilder @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +mapping_summary = lambda do |type, key| + mapping = @row_mapping_lookup[[ type, key.to_s ]] + + if mapping + mappable = if mapping.mappable + { + id: mapping.mappable.id, + type: mapping.mappable_type, + name: mapping.mappable.try(:name) + } + end + + { + key: mapping.key, + type: mapping.type, + value: mapping.value, + create_when_empty: mapping.create_when_empty, + creatable: mapping.creatable?, + mappable: mappable + } + else + { + key: key, + type: type, + value: nil, + create_when_empty: false, + creatable: false, + mappable: nil + } + end +end + +json.data do + json.array! @rows do |row| + json.id row.id + json.row_number row.source_row_number + json.valid row.errors.empty? + json.errors row.errors.full_messages + + json.fields do + json.account row.account + json.date row.date + json.qty row.qty + json.ticker row.ticker + json.exchange_operating_mic row.exchange_operating_mic + json.price row.price + json.amount row.amount + json.currency row.currency + json.name row.name + json.category row.category + json.tags row.tags + json.entity_type row.entity_type + json.notes row.notes + json.active row.active + json.effective_date row.effective_date + json.conditions row.conditions + json.actions row.actions + end + + json.mappings do + json.account mapping_summary.call("Import::AccountMapping", row.account) if row.account.present? + json.category mapping_summary.call("Import::CategoryMapping", row.category) if row.category.present? + json.account_type mapping_summary.call("Import::AccountTypeMapping", row.entity_type) if row.entity_type.present? + json.tags row.tags_list.reject(&:blank?).map { |tag| mapping_summary.call("Import::TagMapping", tag) } + end + end +end + +json.meta do + json.current_page @pagy.page + json.next_page @pagy.next + json.prev_page @pagy.prev + json.total_pages @pagy.pages + json.total_count @pagy.count + json.per_page @per_page +end diff --git a/config/routes.rb b/config/routes.rb index 047af6677..11105021b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -441,7 +441,9 @@ Rails.application.routes.draw do resources :family_exports, only: [ :index, :show, :create ] do get :download, on: :member end - resources :imports, only: [ :index, :show, :create ] + resources :imports, only: [ :index, :show, :create ] do + get :rows, on: :member + end resource :usage, only: [ :show ], controller: :usage resource :balance_sheet, only: [ :show ], controller: :balance_sheet resource :family_settings, only: [ :show ], controller: :family_settings diff --git a/db/migrate/20260502000000_add_source_row_number_to_import_rows.rb b/db/migrate/20260502000000_add_source_row_number_to_import_rows.rb new file mode 100644 index 000000000..97b116cb1 --- /dev/null +++ b/db/migrate/20260502000000_add_source_row_number_to_import_rows.rb @@ -0,0 +1,27 @@ +class AddSourceRowNumberToImportRows < ActiveRecord::Migration[7.2] + def up + add_column :import_rows, :source_row_number, :integer + + execute <<~SQL + WITH numbered AS ( + SELECT id, + ROW_NUMBER() OVER (PARTITION BY import_id ORDER BY created_at, id) AS row_number + FROM import_rows + ) + UPDATE import_rows + SET source_row_number = numbered.row_number + FROM numbered + WHERE import_rows.id = numbered.id + SQL + + change_column_null :import_rows, :source_row_number, false + add_check_constraint :import_rows, "source_row_number > 0", name: "chk_import_rows_source_row_number_positive" + add_index :import_rows, [ :import_id, :source_row_number ], unique: true, name: "index_import_rows_on_import_id_and_source_row_number" + end + + def down + remove_index :import_rows, name: "index_import_rows_on_import_id_and_source_row_number" + remove_check_constraint :import_rows, name: "chk_import_rows_source_row_number_positive" + remove_column :import_rows, :source_row_number + end +end diff --git a/db/schema.rb b/db/schema.rb index 3289baace..72858f535 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -721,7 +721,10 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_02_120000) do t.string "effective_date" t.text "conditions" t.text "actions" + t.integer "source_row_number", null: false + t.index ["import_id", "source_row_number"], name: "index_import_rows_on_import_id_and_source_row_number", unique: true t.index ["import_id"], name: "index_import_rows_on_import_id" + t.check_constraint "source_row_number > 0", name: "chk_import_rows_source_row_number_positive" end create_table "imports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 5f5740b78..74606c0df 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1318,6 +1318,165 @@ components: properties: data: "$ref": "#/components/schemas/ImportDetail" + ImportRowMapping: + type: object + required: + - key + - type + - value + - create_when_empty + - creatable + - mappable + properties: + key: + type: string + nullable: true + type: + type: string + value: + type: string + nullable: true + create_when_empty: + type: boolean + creatable: + type: boolean + mappable: + type: object + nullable: true + properties: + id: + type: string + format: uuid + type: + type: string + name: + type: string + nullable: true + ImportRowDiagnostic: + type: object + required: + - id + - row_number + - valid + - errors + - fields + - mappings + properties: + id: + type: string + format: uuid + row_number: + type: integer + minimum: 1 + valid: + type: boolean + errors: + type: array + items: + type: string + fields: + type: object + properties: + account: + type: string + nullable: true + date: + type: string + nullable: true + qty: + type: string + nullable: true + ticker: + type: string + nullable: true + exchange_operating_mic: + type: string + nullable: true + price: + type: string + nullable: true + amount: + type: string + nullable: true + currency: + type: string + nullable: true + name: + type: string + nullable: true + category: + type: string + nullable: true + tags: + type: string + nullable: true + entity_type: + type: string + nullable: true + notes: + type: string + nullable: true + active: + type: boolean + nullable: true + effective_date: + type: string + nullable: true + conditions: + type: string + nullable: true + actions: + type: string + nullable: true + mappings: + type: object + properties: + account: + "$ref": "#/components/schemas/ImportRowMapping" + category: + "$ref": "#/components/schemas/ImportRowMapping" + account_type: + "$ref": "#/components/schemas/ImportRowMapping" + tags: + type: array + items: + "$ref": "#/components/schemas/ImportRowMapping" + ImportRowDiagnosticCollection: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + "$ref": "#/components/schemas/ImportRowDiagnostic" + meta: + type: object + required: + - current_page + - total_pages + - total_count + - per_page + properties: + current_page: + type: integer + minimum: 1 + next_page: + type: integer + nullable: true + prev_page: + type: integer + nullable: true + total_pages: + type: integer + minimum: 0 + total_count: + type: integer + minimum: 0 + per_page: + type: integer + minimum: 1 Trade: type: object required: @@ -3266,6 +3425,66 @@ paths: application/json: schema: "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/imports/{id}/rows": + parameters: + - name: id + in: path + required: true + description: Import ID + schema: + type: string + get: + summary: List import row diagnostics + description: List sanitized import rows with validation errors and mapping resolution + state. + tags: + - Imports + security: + - apiKeyAuth: [] + parameters: + - name: page + in: query + required: false + description: 'Page number (default: 1)' + schema: + type: integer + - name: per_page + in: query + required: false + description: 'Items per page (default: 25, max: 100)' + schema: + type: integer + responses: + '200': + description: import rows listed + content: + application/json: + schema: + "$ref": "#/components/schemas/ImportRowDiagnosticCollection" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: insufficient scope + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '404': + description: import not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '500': + description: internal server error + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" "/api/v1/merchants": get: summary: List merchants diff --git a/spec/requests/api/v1/imports_spec.rb b/spec/requests/api/v1/imports_spec.rb index 220ee1f89..540422c33 100644 --- a/spec/requests/api/v1/imports_spec.rb +++ b/spec/requests/api/v1/imports_spec.rb @@ -31,6 +31,17 @@ RSpec.describe 'API V1 Imports', type: :request do ) end + let(:api_key_without_read_scope) do + key = ApiKey.generate_secure_key + ApiKey.new( + user: user, + name: 'No Read Docs Key', + key: key, + scopes: %w[write], + source: 'web' + ).tap { |api_key| api_key.save!(validate: false) } + end + let(:'X-Api-Key') { api_key.plain_key } let(:account) do @@ -52,6 +63,16 @@ RSpec.describe 'API V1 Imports', type: :request do ) end + let!(:import_row) do + pending_import.rows.create!( + source_row_number: 1, + date: '01/01/2024', + amount: '10.00', + currency: 'USD', + name: 'Test Transaction' + ) + end + let!(:complete_import) do family.imports.create!( type: 'TransactionImport', @@ -287,4 +308,61 @@ RSpec.describe 'API V1 Imports', type: :request do end end end + + path '/api/v1/imports/{id}/rows' do + parameter name: :id, in: :path, type: :string, required: true, description: 'Import ID' + + get 'List import row diagnostics' do + description 'List sanitized import rows with validation errors and mapping resolution state.' + tags 'Imports' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + parameter name: :page, in: :query, type: :integer, required: false, + description: 'Page number (default: 1)' + parameter name: :per_page, in: :query, type: :integer, required: false, + description: 'Items per page (default: 25, max: 100)' + + let(:id) { pending_import.id } + + response '200', 'import rows listed' do + schema '$ref' => '#/components/schemas/ImportRowDiagnosticCollection' + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { nil } + + run_test! + end + + response '403', 'insufficient scope' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { api_key_without_read_scope.plain_key } + + run_test! + end + + response '404', 'import not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { SecureRandom.uuid } + + run_test! + end + + response '500', 'internal server error' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + before do + allow_any_instance_of(Import::Row).to receive(:valid?).and_raise(StandardError, 'validation down') + end + + run_test! + end + end + end end diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb index cc33e18f2..e6976f4b5 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -743,6 +743,95 @@ RSpec.configure do |config| data: { '$ref' => '#/components/schemas/ImportDetail' } } }, + ImportRowMapping: { + type: :object, + required: %w[key type value create_when_empty creatable mappable], + properties: { + key: { type: :string, nullable: true }, + type: { type: :string }, + value: { type: :string, nullable: true }, + create_when_empty: { type: :boolean }, + creatable: { type: :boolean }, + mappable: { + type: :object, + nullable: true, + properties: { + id: { type: :string, format: :uuid }, + type: { type: :string }, + name: { type: :string, nullable: true } + } + } + } + }, + ImportRowDiagnostic: { + type: :object, + required: %w[id row_number valid errors fields mappings], + properties: { + id: { type: :string, format: :uuid }, + row_number: { type: :integer, minimum: 1 }, + valid: { type: :boolean }, + errors: { + type: :array, + items: { type: :string } + }, + fields: { + type: :object, + properties: { + account: { type: :string, nullable: true }, + date: { type: :string, nullable: true }, + qty: { type: :string, nullable: true }, + ticker: { type: :string, nullable: true }, + exchange_operating_mic: { type: :string, nullable: true }, + price: { type: :string, nullable: true }, + amount: { type: :string, nullable: true }, + currency: { type: :string, nullable: true }, + name: { type: :string, nullable: true }, + category: { type: :string, nullable: true }, + tags: { type: :string, nullable: true }, + entity_type: { type: :string, nullable: true }, + notes: { type: :string, nullable: true }, + active: { type: :boolean, nullable: true }, + effective_date: { type: :string, nullable: true }, + conditions: { type: :string, nullable: true }, + actions: { type: :string, nullable: true } + } + }, + mappings: { + type: :object, + properties: { + account: { '$ref' => '#/components/schemas/ImportRowMapping' }, + category: { '$ref' => '#/components/schemas/ImportRowMapping' }, + account_type: { '$ref' => '#/components/schemas/ImportRowMapping' }, + tags: { + type: :array, + items: { '$ref' => '#/components/schemas/ImportRowMapping' } + } + } + } + } + }, + ImportRowDiagnosticCollection: { + type: :object, + required: %w[data meta], + properties: { + data: { + type: :array, + items: { '$ref' => '#/components/schemas/ImportRowDiagnostic' } + }, + meta: { + type: :object, + required: %w[current_page total_pages total_count per_page], + properties: { + current_page: { type: :integer, minimum: 1 }, + next_page: { type: :integer, nullable: true }, + prev_page: { type: :integer, nullable: true }, + total_pages: { type: :integer, minimum: 0 }, + total_count: { type: :integer, minimum: 0 }, + per_page: { type: :integer, minimum: 1 } + } + } + } + }, Trade: { type: :object, required: %w[id date amount currency name qty price account created_at updated_at], diff --git a/test/controllers/api/v1/imports_controller_test.rb b/test/controllers/api/v1/imports_controller_test.rb index a14377947..bab5e3787 100644 --- a/test/controllers/api/v1/imports_controller_test.rb +++ b/test/controllers/api/v1/imports_controller_test.rb @@ -29,6 +29,53 @@ class Api::V1::ImportsControllerTest < ActionDispatch::IntegrationTest Redis.new.del("api_rate_limit:#{@api_key.id}") Redis.new.del("api_rate_limit:#{@read_only_api_key.id}") + + @diagnostic_category_name = "Diagnostic Groceries #{SecureRandom.hex(4)}" + @diagnostic_import = @family.imports.create!( + type: "TransactionImport", + status: "pending", + account: @account, + raw_file_str: "date,amount,name,category,tags\n01/15/2024,-10.00,Grocery Run,#{@diagnostic_category_name},Food|Weekly", + date_col_label: "date", + amount_col_label: "amount", + name_col_label: "name", + category_col_label: "category", + tags_col_label: "tags" + ) + @diagnostic_row = @diagnostic_import.rows.create!( + source_row_number: 7, + date: "01/15/2024", + amount: "-10.00", + currency: "USD", + name: "Grocery Run", + category: @diagnostic_category_name, + entity_type: "checking", + tags: "Food|Weekly" + ) + @invalid_diagnostic_row = @diagnostic_import.rows.build( + source_row_number: 8, + date: "not-a-date", + amount: "not-a-number", + currency: "BAD", + name: "Bad Row" + ) + @invalid_diagnostic_row.save!(validate: false) + + @diagnostic_category = @family.categories.create!( + name: @diagnostic_category_name, + color: "#407706", + lucide_icon: "shopping-basket" + ) + Import::CategoryMapping.create!( + import: @diagnostic_import, + key: @diagnostic_category_name, + mappable: @diagnostic_category + ) + Import::AccountTypeMapping.create!( + import: @diagnostic_import, + key: "checking", + value: "Depository" + ) end test "should list imports" do @@ -73,6 +120,105 @@ class Api::V1::ImportsControllerTest < ActionDispatch::IntegrationTest json_response["data"]["stats"]["unassigned_mappings_count"] end + test "should list sanitized import row diagnostics" do + get rows_api_v1_import_url(@diagnostic_import), headers: api_headers(@read_only_api_key) + + assert_response :success + json_response = JSON.parse(response.body) + + assert_equal 2, json_response["meta"]["total_count"] + row_data = json_response["data"].find { |row| row["id"] == @diagnostic_row.id } + + assert_not_nil row_data + assert_equal true, row_data["valid"] + assert_equal 7, row_data["row_number"] + assert_equal "Grocery Run", row_data.dig("fields", "name") + assert_equal @diagnostic_category_name, row_data.dig("fields", "category") + assert_equal @diagnostic_category.id, row_data.dig("mappings", "category", "mappable", "id") + assert_equal "Depository", row_data.dig("mappings", "account_type", "value") + tag_mapping = row_data.dig("mappings", "tags").find { |mapping| mapping["key"] == "Weekly" } + assert_not_nil tag_mapping + assert_nil tag_mapping["value"] + assert_not row_data.key?("raw_file_str") + refute_includes response.body, @diagnostic_import.raw_file_str + end + + test "should include validation errors for invalid import rows" do + get rows_api_v1_import_url(@diagnostic_import), headers: api_headers(@api_key) + + assert_response :success + json_response = JSON.parse(response.body) + row_data = json_response["data"].find { |row| row["id"] == @invalid_diagnostic_row.id } + + assert_not_nil row_data + assert_equal false, row_data["valid"] + assert_not_empty row_data["errors"] + end + + test "should paginate import row diagnostics" do + get rows_api_v1_import_url(@diagnostic_import), + params: { page: 1, per_page: 1 }, + headers: api_headers(@api_key) + + assert_response :success + json_response = JSON.parse(response.body) + + assert_equal 1, json_response["data"].length + assert_equal 2, json_response["meta"]["total_count"] + assert_equal 1, json_response["meta"]["per_page"] + end + + test "should list import row diagnostics in source row order" do + @diagnostic_import.rows.create!( + source_row_number: 6, + date: "01/14/2024", + amount: "-5.00", + currency: "USD", + name: "Earlier Source Row" + ) + + get rows_api_v1_import_url(@diagnostic_import), headers: api_headers(@api_key) + + assert_response :success + json_response = JSON.parse(response.body) + + assert_equal [ 6, 7, 8 ], json_response["data"].map { |row| row["row_number"] } + end + + test "should not expose another family's import rows" do + other_family = Family.create!(name: "Other Family", currency: "USD", locale: "en") + other_import = other_family.imports.create!(type: "TransactionImport", raw_file_str: "date,amount,name") + + get rows_api_v1_import_url(other_import), headers: api_headers(@api_key) + + assert_response :not_found + json_response = JSON.parse(response.body) + assert_equal "not_found", json_response["error"] + end + + test "should require authentication for import row diagnostics" do + get rows_api_v1_import_url(@diagnostic_import) + + assert_response :unauthorized + end + + test "should require read scope for import row diagnostics" do + api_key_without_read = ApiKey.new( + user: @user, + name: "No Read Key", + scopes: [], + source: "web", + display_key: "no_read_#{SecureRandom.hex(8)}" + ) + api_key_without_read.save!(validate: false) + + get rows_api_v1_import_url(@diagnostic_import), headers: api_headers(api_key_without_read) + + assert_response :forbidden + ensure + api_key_without_read&.destroy + end + test "should create import with raw content" do csv_content = "date,amount,name\n2023-01-01,-10.00,Test Transaction" diff --git a/test/controllers/import/rows_controller_test.rb b/test/controllers/import/rows_controller_test.rb index e6394d944..c58c07d02 100644 --- a/test/controllers/import/rows_controller_test.rb +++ b/test/controllers/import/rows_controller_test.rb @@ -18,7 +18,7 @@ class Import::RowsControllerTest < ActionDispatch::IntegrationTest test "show trade row" do import = @user.family.imports.create!(type: "TradeImport") - row = import.rows.create!(date: "01/01/2024", currency: "USD", qty: 10, price: 100, ticker: "AAPL") + row = import.rows.create!(source_row_number: 1, date: "01/01/2024", currency: "USD", qty: 10, price: 100, ticker: "AAPL") get import_row_path(import, row) @@ -29,7 +29,7 @@ class Import::RowsControllerTest < ActionDispatch::IntegrationTest test "show account row" do import = @user.family.imports.create!(type: "AccountImport") - row = import.rows.create!(name: "Test Account", amount: 10000, currency: "USD") + row = import.rows.create!(source_row_number: 1, name: "Test Account", amount: 10000, currency: "USD") get import_row_path(import, row) @@ -40,7 +40,7 @@ class Import::RowsControllerTest < ActionDispatch::IntegrationTest test "show mint row" do import = @user.family.imports.create!(type: "MintImport") - row = import.rows.create!(date: "01/01/2024", amount: 100, currency: "USD") + row = import.rows.create!(source_row_number: 1, date: "01/01/2024", amount: 100, currency: "USD") get import_row_path(import, row) diff --git a/test/fixtures/import/rows.yml b/test/fixtures/import/rows.yml index 7cc0fd7f4..55dc1efbf 100644 --- a/test/fixtures/import/rows.yml +++ b/test/fixtures/import/rows.yml @@ -1,5 +1,6 @@ one: import: transaction + source_row_number: 1 date: 01/01/2024 amount: 100 - currency: USD \ No newline at end of file + currency: USD diff --git a/test/models/account_import_test.rb b/test/models/account_import_test.rb index d433cab8f..d790a3134 100644 --- a/test/models/account_import_test.rb +++ b/test/models/account_import_test.rb @@ -164,6 +164,7 @@ class AccountImportTest < ActiveSupport::TestCase test "dry_run returns expected counts" do @import.rows.create!( + source_row_number: 1, entity_type: "depository", name: "Test Account", amount: "1000.00", diff --git a/test/models/mint_import_test.rb b/test/models/mint_import_test.rb new file mode 100644 index 000000000..15095cefa --- /dev/null +++ b/test/models/mint_import_test.rb @@ -0,0 +1,19 @@ +require "test_helper" + +class MintImportTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + end + + test "generated rows preserve stable source row numbers" do + import = @family.imports.create!( + type: "MintImport", + raw_file_str: file_fixture("imports/mint.csv").read, + col_sep: "," + ) + + import.generate_rows_from_csv + + assert_equal (1..10).to_a, import.rows.order(:source_row_number).pluck(:source_row_number) + end +end diff --git a/test/models/pdf_import_test.rb b/test/models/pdf_import_test.rb index f8568d452..e6d89bfe2 100644 --- a/test/models/pdf_import_test.rb +++ b/test/models/pdf_import_test.rb @@ -131,6 +131,7 @@ class PdfImportTest < ActiveSupport::TestCase test "mapping_steps includes CategoryMapping when rows have categories" do @import_with_rows.rows.create!( + source_row_number: 1, date: "01/15/2024", amount: -50.00, currency: "USD",