Files
sure/test/controllers/api/v1/imports_controller_test.rb
Sure Admin (bot) 4fd460d551 Add Actual Budget CSV import flow (#1830)
* Add Actual Budget CSV import flow

* Address Actual import review feedback
2026-05-18 18:38:53 +02:00

1190 lines
39 KiB
Ruby

# frozen_string_literal: true
require "test_helper"
class Api::V1::ImportsControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:family_admin)
@family = @user.family
@account = accounts(:depository)
@import = imports(:transaction)
@user.api_keys.active.destroy_all
@api_key = ApiKey.create!(
user: @user,
name: "Test Read-Write Key",
scopes: [ "read_write" ],
display_key: "test_rw_#{SecureRandom.hex(8)}",
source: "web"
)
@read_only_api_key = ApiKey.create!(
user: @user,
name: "Test Read-Only Key",
scopes: [ "read" ],
display_key: "test_ro_#{SecureRandom.hex(8)}",
source: "mobile"
)
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
get api_v1_imports_url, headers: api_headers(@api_key)
assert_response :success
json_response = JSON.parse(response.body)
assert_not_empty json_response["data"]
assert_equal @family.imports.count, json_response["meta"]["total_count"]
import_data = json_response["data"].detect { |data| data["id"] == @import.id }
assert_not_nil import_data
assert_equal @import.uploaded?, import_data["status_detail"]["uploaded"]
assert_equal @import.configured?, import_data["status_detail"]["configured"]
assert_equal @import.complete? || @import.failed? || @import.revert_failed?, import_data["status_detail"]["terminal"]
end
test "should show import" do
get api_v1_import_url(@import), headers: api_headers(@api_key)
assert_response :success
json_response = JSON.parse(response.body)
rows = @import.rows.to_a
valid_rows_count = rows.count(&:valid?)
invalid_rows_count = rows.length - valid_rows_count
assert_equal @import.id, json_response["data"]["id"]
assert_equal @import.status, json_response["data"]["status"]
assert json_response["data"].key?("status_detail")
assert_equal @import.uploaded?, json_response["data"]["status_detail"]["uploaded"]
assert_equal @import.configured?, json_response["data"]["status_detail"]["configured"]
assert_equal @import.cleaned_from_validation_stats?(invalid_rows_count: invalid_rows_count),
json_response["data"]["status_detail"]["cleaned"]
assert_equal @import.publishable_from_validation_stats?(invalid_rows_count: invalid_rows_count),
json_response["data"]["status_detail"]["publishable"]
assert_equal @import.revertable?, json_response["data"]["status_detail"]["revertable"]
assert_equal @import.rows_count, json_response["data"]["stats"]["rows_count"]
assert_equal valid_rows_count, json_response["data"]["stats"]["valid_rows_count"]
assert_equal invalid_rows_count, json_response["data"]["stats"]["invalid_rows_count"]
assert_equal @import.mappings.count, json_response["data"]["stats"]["mappings_count"]
assert_equal @import.mappings.where(mappable_id: nil).count,
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"
assert_difference("Import.count") do
post api_v1_imports_url,
params: {
raw_file_content: csv_content,
date_col_label: "date",
amount_col_label: "amount",
name_col_label: "name",
account_id: @account.id
},
headers: api_headers(@api_key)
end
assert_response :created
json_response = JSON.parse(response.body)
assert_equal "pending", json_response["data"]["status"]
created_import = Import.find(json_response["data"]["id"])
assert_equal csv_content, created_import.raw_file_str
end
test "should create import and generate rows when configured" do
csv_content = "date,amount,name\n2023-01-01,-10.00,Test Transaction"
assert_difference([ "Import.count", "Import::Row.count" ], 1) do
post api_v1_imports_url,
params: {
raw_file_content: csv_content,
date_col_label: "date",
amount_col_label: "amount",
name_col_label: "name",
account_id: @account.id
},
headers: api_headers(@api_key)
end
assert_response :created
json_response = JSON.parse(response.body)
import = Import.find(json_response["data"]["id"])
assert_equal 1, import.rows_count
assert_equal "Test Transaction", import.rows.first.name
assert_equal "-10.00", import.rows.first.amount # Normalized
end
test "should instantiate RuleImport before generating rows" do
@family.categories.create!(
name: "Groceries",
color: "#407706",
lucide_icon: "shopping-basket"
)
csv_content = <<~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""}]"
CSV
assert_difference([ "Import.count", "Import::Row.count" ], 1) do
post api_v1_imports_url,
params: {
type: "RuleImport",
raw_file_content: csv_content,
col_sep: ","
},
headers: api_headers(@api_key)
end
assert_response :created
json_response = JSON.parse(response.body)
import = Import.find(json_response["data"]["id"])
row = import.rows.first
assert_instance_of RuleImport, import
assert_equal 1, import.rows_count
assert_equal "Categorize groceries", row.name
assert_equal "transaction", row.resource_type
assert_equal true, row.active
assert_equal "2024-01-01", row.effective_date
assert_equal '[{"condition_type":"transaction_name","operator":"like","value":"grocery"}]', row.conditions
assert_equal '[{"action_type":"set_transaction_category","value":"Groceries"}]', row.actions
end
test "should create Sure import with raw NDJSON content" do
ndjson_content = { type: "Account", data: { id: "account_1", name: "Checking" } }.to_json
assert_difference("Import.count") do
post api_v1_imports_url,
params: {
type: "SureImport",
raw_file_content: ndjson_content
},
headers: api_headers(@api_key)
end
assert_response :created
json_response = JSON.parse(response.body)
import = Import.find(json_response["data"]["id"])
assert_instance_of SureImport, import
assert import.ndjson_file.attached?
assert_equal 1, import.rows_count
assert_equal "pending", import.status
end
test "should require authentication for Sure import" do
ndjson_content = { type: "Account", data: { id: "account_1", name: "Checking" } }.to_json
assert_no_difference("Import.count") do
post api_v1_imports_url,
params: {
type: "SureImport",
raw_file_content: ndjson_content
}
end
assert_response :unauthorized
end
test "should reject Sure import with read-only API key" do
ndjson_content = { type: "Account", data: { id: "account_1", name: "Checking" } }.to_json
assert_no_difference("Import.count") do
post api_v1_imports_url,
params: {
type: "SureImport",
raw_file_content: ndjson_content
},
headers: api_headers(@read_only_api_key)
end
assert_response :forbidden
json_response = JSON.parse(response.body)
assert_equal "insufficient_scope", json_response["error"]
end
test "should create Sure import with uploaded NDJSON file" do
ndjson_content = { type: "Account", data: { id: "account_1", name: "Checking" } }.to_json
valid_file = Rack::Test::UploadedFile.new(
StringIO.new(ndjson_content),
"application/x-ndjson",
original_filename: "sure-backup.ndjson"
)
assert_difference("Import.count") do
post api_v1_imports_url,
params: {
type: "SureImport",
file: valid_file
},
headers: api_headers(@api_key)
end
assert_response :created
import = Import.find(JSON.parse(response.body)["data"]["id"])
assert_instance_of SureImport, import
assert import.ndjson_file.attached?
assert_equal 1, import.rows_count
end
test "should reject Sure import with no file or raw content" do
assert_no_difference("Import.count") do
post api_v1_imports_url,
params: {
type: "SureImport"
},
headers: api_headers(@api_key)
end
assert_response :unprocessable_entity
json_response = JSON.parse(response.body)
assert_equal "missing_content", json_response["error"]
end
test "should reject Sure import uploaded file exceeding max size" do
test_limit = 1.kilobyte
large_file = Rack::Test::UploadedFile.new(
StringIO.new("x" * (test_limit + 1)),
"application/x-ndjson",
original_filename: "large.ndjson"
)
SureImport.stubs(:max_ndjson_size).returns(test_limit)
assert_no_difference("Import.count") do
post api_v1_imports_url,
params: {
type: "SureImport",
file: large_file
},
headers: api_headers(@api_key)
end
assert_response :unprocessable_entity
json_response = JSON.parse(response.body)
assert_equal "file_too_large", json_response["error"]
end
test "should reject Sure import uploaded file with invalid type" do
ndjson_content = { type: "Account", data: { id: "account_1", name: "Checking" } }.to_json
invalid_file = Rack::Test::UploadedFile.new(
StringIO.new(ndjson_content),
"application/pdf",
original_filename: "sure-backup.pdf"
)
assert_no_difference("Import.count") do
post api_v1_imports_url,
params: {
type: "SureImport",
file: invalid_file
},
headers: api_headers(@api_key)
end
assert_response :unprocessable_entity
json_response = JSON.parse(response.body)
assert_equal "invalid_file_type", json_response["error"]
end
test "should clean up Sure import if row sync fails" do
ndjson_content = { type: "Account", data: { id: "account_1", name: "Checking" } }.to_json
SureImport.any_instance.stubs(:sync_ndjson_rows_count!).raises(StandardError, "sync failed")
assert_no_difference("Import.count") do
post api_v1_imports_url,
params: {
type: "SureImport",
raw_file_content: ndjson_content
},
headers: api_headers(@api_key)
end
assert_response :internal_server_error
json_response = JSON.parse(response.body)
assert_equal "internal_server_error", json_response["error"]
end
test "should clean up Sure import if row sync validation fails" do
ndjson_content = { type: "Account", data: { id: "account_1", name: "Checking" } }.to_json
invalid_import = SureImport.new
invalid_import.errors.add(:base, "invalid rows")
SureImport.any_instance.stubs(:sync_ndjson_rows_count!).raises(ActiveRecord::RecordInvalid.new(invalid_import))
assert_no_difference("Import.count") do
post api_v1_imports_url,
params: {
type: "SureImport",
raw_file_content: ndjson_content
},
headers: api_headers(@api_key)
end
assert_response :unprocessable_entity
json_response = JSON.parse(response.body)
assert_equal "validation_failed", json_response["error"]
assert_includes json_response["errors"], "invalid rows"
end
test "should preserve Sure import if publish queueing fails" do
ndjson_content = { type: "Account", data: { id: "account_1", name: "Checking" } }.to_json
ImportJob.stubs(:perform_later).raises(StandardError, "queue offline")
assert_difference("Import.count") do
post api_v1_imports_url,
params: {
type: "SureImport",
raw_file_content: ndjson_content,
publish: "true"
},
headers: api_headers(@api_key)
end
assert_response :internal_server_error
json_response = JSON.parse(response.body)
assert_equal "publish_failed", json_response["error"]
import = Import.find(json_response["import_id"])
assert_instance_of SureImport, import
assert import.ndjson_file.attached?
assert_equal 1, import.rows_count
assert_equal "pending", import.status
end
test "should preserve Sure import if auto publish exceeds row count" do
ndjson_content = { type: "Account", data: { id: "account_1", name: "Checking" } }.to_json
SureImport.any_instance.stubs(:publish_later).raises(Import::MaxRowCountExceededError)
assert_difference("Import.count") do
post api_v1_imports_url,
params: {
type: "SureImport",
raw_file_content: ndjson_content,
publish: "true"
},
headers: api_headers(@api_key)
end
assert_response :unprocessable_entity
json_response = JSON.parse(response.body)
assert_equal "max_row_count_exceeded", json_response["error"]
import = Import.find(json_response["import_id"])
assert_instance_of SureImport, import
assert import.ndjson_file.attached?
assert_equal 1, import.rows_count
end
test "should reject invalid Sure import NDJSON content" do
assert_no_difference("Import.count") do
post api_v1_imports_url,
params: {
type: "SureImport",
raw_file_content: "not ndjson"
},
headers: api_headers(@api_key)
end
assert_response :unprocessable_entity
json_response = JSON.parse(response.body)
assert_equal "invalid_ndjson", json_response["error"]
end
test "should preflight CSV import without persisting records" do
csv_content = "date,amount,name\n2023-01-01,-10.00,Test Transaction"
assert_no_difference([ "Import.count", "Import::Row.count" ]) do
post preflight_api_v1_imports_url,
params: {
raw_file_content: csv_content,
date_col_label: "date",
amount_col_label: "amount",
name_col_label: "name",
account_id: @account.id
},
headers: api_headers(@api_key)
end
assert_response :success
json_response = JSON.parse(response.body)
data = json_response["data"]
assert_equal "TransactionImport", data["type"]
assert_equal true, data["valid"]
assert_equal 1, data["stats"]["rows_count"]
assert_not data["stats"].key?("valid_rows_count")
assert_not data["stats"].key?("invalid_rows_count")
assert_equal %w[date amount name], data["headers"]
assert_empty data["missing_required_headers"]
assert_empty data["errors"]
end
test "should report missing required CSV headers during preflight" do
csv_content = "name\nMissing Amount"
assert_no_difference("Import.count") do
post preflight_api_v1_imports_url,
params: {
raw_file_content: csv_content,
date_col_label: "date",
amount_col_label: "amount",
name_col_label: "name",
account_id: @account.id
},
headers: api_headers(@api_key)
end
assert_response :success
data = JSON.parse(response.body)["data"]
assert_equal false, data["valid"]
assert_equal 1, data["stats"]["rows_count"]
assert_not data["stats"].key?("valid_rows_count")
assert_not data["stats"].key?("invalid_rows_count")
assert_equal [ "date", "amount" ], data["missing_required_headers"]
assert_equal "missing_required_headers", data["errors"].first["code"]
end
test "should apply rows_to_skip before CSV preflight header validation" do
csv_content = [
"Generated by bank export",
"posted,amount,description",
"2024-01-01,-10.00,Coffee"
].join("\n")
assert_no_difference("Import.count") do
post preflight_api_v1_imports_url,
params: {
raw_file_content: csv_content,
rows_to_skip: 1,
date_col_label: "posted",
amount_col_label: "amount",
name_col_label: "description",
account_id: @account.id
},
headers: api_headers(@read_only_api_key)
end
assert_response :success
data = JSON.parse(response.body)["data"]
assert_equal true, data["valid"]
assert_equal 1, data["stats"]["rows_count"]
assert_equal %w[posted amount description], data["headers"]
assert_empty data["missing_required_headers"]
end
test "should preflight semicolon separated CSV content" do
csv_content = "date;amount;name\n2024-01-01;-10.00;Coffee"
assert_no_difference("Import.count") do
post preflight_api_v1_imports_url,
params: {
raw_file_content: csv_content,
col_sep: ";",
date_col_label: "date",
amount_col_label: "amount",
name_col_label: "name",
account_id: @account.id
},
headers: api_headers(@read_only_api_key)
end
assert_response :success
data = JSON.parse(response.body)["data"]
assert_equal true, data["valid"]
assert_equal 1, data["stats"]["rows_count"]
assert_equal %w[date amount name], data["headers"]
end
test "should report invalid preflight CSV parser config without parsing" do
csv_content = "date,amount,name\n2024-01-01,-10.00,Coffee"
assert_no_difference("Import.count") do
post preflight_api_v1_imports_url,
params: {
raw_file_content: csv_content,
col_sep: "",
date_col_label: "date",
amount_col_label: "amount",
name_col_label: "name",
account_id: @account.id
},
headers: api_headers(@read_only_api_key)
end
assert_response :success
data = JSON.parse(response.body)["data"]
assert_equal false, data["valid"]
assert_equal 0, data["stats"]["rows_count"]
assert_empty data["headers"]
assert_equal "validation_failed", data["errors"].first["code"]
end
test "should reject malformed CSV during preflight" do
csv_content = "date,amount,name\n2024-01-01,-10.00,\"Coffee Shop"
assert_no_difference("Import.count") do
post preflight_api_v1_imports_url,
params: {
raw_file_content: csv_content,
date_col_label: "date",
amount_col_label: "amount",
name_col_label: "name",
account_id: @account.id
},
headers: api_headers(@read_only_api_key)
end
assert_response :unprocessable_entity
json_response = JSON.parse(response.body)
assert_equal "invalid_csv", json_response["error"]
end
test "should include preflight exception message in internal server error response" do
Import::Preflight.any_instance.stubs(:call).raises(StandardError, "boom")
post preflight_api_v1_imports_url,
params: {
raw_file_content: "date,amount,name\n2024-01-01,-10.00,Coffee",
date_col_label: "date",
amount_col_label: "amount",
name_col_label: "name"
},
headers: api_headers(@read_only_api_key)
assert_response :internal_server_error
json_response = JSON.parse(response.body)
assert_equal "internal_server_error", json_response["error"]
assert_equal "Error: boom", json_response["message"]
end
test "should reject unknown preflight import type" do
assert_no_difference("Import.count") do
post preflight_api_v1_imports_url,
params: {
type: "FakeImport",
raw_file_content: "date,amount,name\n2023-01-01,-10.00,Test Transaction"
},
headers: api_headers(@read_only_api_key)
end
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "invalid_import_type", response_data["error"]
assert_not response_data.key?("errors")
end
test "should reject import types excluded from preflight" do
assert_no_difference("Import.count") do
post preflight_api_v1_imports_url,
params: {
type: "QifImport",
raw_file_content: "!Type:Bank\nD01/01/2024\nT-10.00\nPTest\n^"
},
headers: api_headers(@read_only_api_key)
end
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "invalid_import_type", response_data["error"]
assert_not response_data.key?("errors")
assert_not_includes response_data["message"], "QifImport"
assert_not_includes response_data["message"], "PdfImport"
end
test "should report empty CSV preflight content as invalid" do
assert_no_difference("Import.count") do
post preflight_api_v1_imports_url,
params: {
raw_file_content: "date,amount,name\n",
date_col_label: "date",
amount_col_label: "amount",
name_col_label: "name",
account_id: @account.id
},
headers: api_headers(@read_only_api_key)
end
assert_response :success
data = JSON.parse(response.body)["data"]
assert_equal false, data["valid"]
assert_equal 0, data["stats"]["rows_count"]
assert_equal "no_data_rows", data["errors"].first["code"]
assert_empty data["warnings"]
end
test "should preflight Sure import without persisting records" do
ndjson_content = [
{ type: "Account", data: { id: "account_1", name: "Checking" } }.to_json,
{ type: "Transaction", data: { id: "entry_1", account_id: "account_1" } }.to_json
].join("\n")
assert_no_difference("Import.count") do
post preflight_api_v1_imports_url,
params: {
type: "SureImport",
raw_file_content: ndjson_content
},
headers: api_headers(@api_key)
end
assert_response :success
data = JSON.parse(response.body)["data"]
assert_equal "SureImport", data["type"]
assert_equal true, data["valid"]
assert_equal 2, data["stats"]["rows_count"]
assert_equal 1, data["stats"]["entity_counts"]["accounts"]
assert_equal 1, data["stats"]["entity_counts"]["transactions"]
assert_empty data["errors"]
end
test "should report invalid Sure import NDJSON during preflight" do
assert_no_difference("Import.count") do
post preflight_api_v1_imports_url,
params: {
type: "SureImport",
raw_file_content: "not ndjson"
},
headers: api_headers(@api_key)
end
assert_response :success
data = JSON.parse(response.body)["data"]
assert_equal false, data["valid"]
assert_equal 1, data["stats"]["invalid_rows_count"]
assert_equal "invalid_json", data["errors"].first["code"]
end
test "should report non-object Sure import NDJSON records during preflight" do
assert_no_difference("Import.count") do
post preflight_api_v1_imports_url,
params: {
type: "SureImport",
raw_file_content: "[]"
},
headers: api_headers(@read_only_api_key)
end
assert_response :success
data = JSON.parse(response.body)["data"]
assert_equal false, data["valid"]
assert_equal 1, data["stats"]["invalid_rows_count"]
assert_equal "invalid_ndjson_record", data["errors"].first["code"]
end
test "should report empty Sure import file as invalid during preflight" do
empty_file = Rack::Test::UploadedFile.new(
StringIO.new(""),
"application/x-ndjson",
original_filename: "empty.ndjson"
)
assert_no_difference("Import.count") do
post preflight_api_v1_imports_url,
params: {
type: "SureImport",
file: empty_file
},
headers: api_headers(@read_only_api_key)
end
assert_response :success
data = JSON.parse(response.body)["data"]
assert_equal false, data["valid"]
assert_equal 0, data["stats"]["rows_count"]
assert_equal "no_data_rows", data["errors"].first["code"]
assert_empty data["warnings"]
end
test "should reject preflight with no file or raw content" do
assert_no_difference("Import.count") do
post preflight_api_v1_imports_url,
params: { type: "SureImport" },
headers: api_headers(@api_key)
end
assert_response :unprocessable_entity
assert_equal "missing_content", JSON.parse(response.body)["error"]
end
test "should reject oversized file uploads during preflight" do
test_limit = 1.kilobyte
large_file = Rack::Test::UploadedFile.new(
StringIO.new("x" * (test_limit + 1)),
"text/csv",
original_filename: "large.csv"
)
Import.stubs(:max_csv_size).returns(test_limit)
assert_no_difference("Import.count") do
post preflight_api_v1_imports_url,
params: { file: large_file },
headers: api_headers(@read_only_api_key)
end
assert_response :unprocessable_entity
assert_equal "file_too_large", JSON.parse(response.body)["error"]
end
test "should preflight with read-only API key" do
csv_content = "date,amount,name\n2023-01-01,-10.00,Test Transaction"
assert_no_difference("Import.count") do
post preflight_api_v1_imports_url,
params: {
raw_file_content: csv_content,
date_col_label: "date",
amount_col_label: "amount",
name_col_label: "name",
account_id: @account.id
},
headers: api_headers(@read_only_api_key)
end
assert_response :success
assert_equal true, JSON.parse(response.body)["data"]["valid"]
end
test "should require authentication for preflight" do
post preflight_api_v1_imports_url, params: {
raw_file_content: "date,amount,name\n2023-01-01,-10.00,Test Transaction"
}
assert_response :unauthorized
end
test "should return not found for preflight account outside family" do
other_family = Family.create!(name: "Other Family", currency: "USD", locale: "en")
other_depository = Depository.create!(subtype: "checking")
other_account = Account.create!(
family: other_family,
name: "Other Account",
currency: "USD",
classification: "asset",
accountable: other_depository,
balance: 0
)
assert_no_difference("Import.count") do
post preflight_api_v1_imports_url,
params: {
raw_file_content: "date,amount,name\n2023-01-01,-10.00,Test Transaction",
date_col_label: "date",
amount_col_label: "amount",
name_col_label: "name",
account_id: other_account.id
},
headers: api_headers(@read_only_api_key)
end
assert_response :not_found
assert_equal "record_not_found", JSON.parse(response.body)["error"]
end
test "should return not found for malformed preflight account id" do
assert_no_difference("Import.count") do
post preflight_api_v1_imports_url,
params: {
raw_file_content: "date,amount,name\n2023-01-01,-10.00,Test Transaction",
date_col_label: "date",
amount_col_label: "amount",
name_col_label: "name",
account_id: "not-a-uuid"
},
headers: api_headers(@read_only_api_key)
end
assert_response :not_found
assert_equal "record_not_found", JSON.parse(response.body)["error"]
end
test "should apply Mint defaults before preflight header validation" do
mint_content = [
"Date,Amount,Account Name,Description,Category,Labels,Currency,Notes,Transaction Type",
"01/01/2024,-8.55,Checking,Starbucks,Food & Drink,Coffee,USD,Morning coffee,debit"
].join("\n")
assert_no_difference("Import.count") do
post preflight_api_v1_imports_url,
params: {
type: "MintImport",
raw_file_content: mint_content
},
headers: api_headers(@read_only_api_key)
end
assert_response :success
data = JSON.parse(response.body)["data"]
assert_equal "MintImport", data["type"]
assert_equal true, data["valid"]
assert_empty data["missing_required_headers"]
assert_includes data["required_headers"], "Date"
assert_includes data["required_headers"], "Amount"
end
test "should apply Actual defaults before preflight header validation" do
actual_content = [
"Account,Date,Payee,Notes,Category_Group,Category,Amount,Split_Amount,Cleared",
"Checking Account,2024-01-01,Coffee Shop,Morning coffee,Food,Coffee,-4.25,0,Cleared"
].join("\n")
assert_no_difference("Import.count") do
post preflight_api_v1_imports_url,
params: {
type: "ActualImport",
raw_file_content: actual_content
},
headers: api_headers(@read_only_api_key)
end
assert_response :success
data = JSON.parse(response.body)["data"]
assert_equal "ActualImport", data["type"]
assert_equal true, data["valid"]
assert_empty data["missing_required_headers"]
assert_includes data["required_headers"], "Date"
assert_includes data["required_headers"], "Amount"
end
test "should not overwrite explicit Actual preflight column mappings with defaults" do
actual_content = [
"Booked On,Value,Payee",
"2024-01-01,-4.25,Coffee Shop"
].join("\n")
assert_no_difference("Import.count") do
post preflight_api_v1_imports_url,
params: {
type: "ActualImport",
raw_file_content: actual_content,
date_col_label: "Booked On",
amount_col_label: "Value"
},
headers: api_headers(@read_only_api_key)
end
assert_response :success
data = JSON.parse(response.body)["data"]
assert_equal true, data["valid"]
assert_equal [ "Booked On", "Value" ], data["required_headers"]
assert_empty data["missing_required_headers"]
end
test "should not overwrite explicit Mint preflight column mappings with defaults" do
mint_content = [
"Posted On,Value,Description",
"01/01/2024,-8.55,Starbucks"
].join("\n")
assert_no_difference("Import.count") do
post preflight_api_v1_imports_url,
params: {
type: "MintImport",
raw_file_content: mint_content,
date_col_label: "Posted On",
amount_col_label: "Value"
},
headers: api_headers(@read_only_api_key)
end
assert_response :success
data = JSON.parse(response.body)["data"]
assert_equal true, data["valid"]
assert_equal [ "Posted On", "Value" ], data["required_headers"]
assert_empty data["missing_required_headers"]
end
test "should create import and auto-publish when configured and requested" do
csv_content = "date,amount,name\n2023-01-01,-10.00,Test Transaction"
assert_enqueued_with(job: ImportJob) do
post api_v1_imports_url,
params: {
raw_file_content: csv_content,
date_col_label: "date",
amount_col_label: "amount",
name_col_label: "name",
account_id: @account.id,
date_format: "%Y-%m-%d",
publish: "true"
},
headers: api_headers(@api_key)
end
assert_response :created
json_response = JSON.parse(response.body)
assert_equal "importing", json_response["data"]["status"]
end
test "should not create import for account in another family" do
other_family = Family.create!(name: "Other Family", currency: "USD", locale: "en")
other_depository = Depository.create!(subtype: "checking")
other_account = Account.create!(family: other_family, name: "Other Account", currency: "USD", classification: "asset", accountable: other_depository, balance: 0)
csv_content = "date,amount,name\n2023-01-01,-10.00,Test Transaction"
post api_v1_imports_url,
params: {
raw_file_content: csv_content,
account_id: other_account.id
},
headers: api_headers(@api_key)
assert_response :unprocessable_entity
json_response = JSON.parse(response.body)
assert_includes json_response["errors"], "Account must belong to your family"
end
test "should reject file upload exceeding max size" do
large_file = Rack::Test::UploadedFile.new(
StringIO.new("x" * (Import::MAX_CSV_SIZE + 1)),
"text/csv",
original_filename: "large.csv"
)
assert_no_difference("Import.count") do
post api_v1_imports_url,
params: { file: large_file },
headers: api_headers(@api_key)
end
assert_response :unprocessable_entity
json_response = JSON.parse(response.body)
assert_equal "file_too_large", json_response["error"]
end
test "should reject file upload with invalid mime type" do
invalid_file = Rack::Test::UploadedFile.new(
StringIO.new("not a csv"),
"application/pdf",
original_filename: "document.pdf"
)
assert_no_difference("Import.count") do
post api_v1_imports_url,
params: { file: invalid_file },
headers: api_headers(@api_key)
end
assert_response :unprocessable_entity
json_response = JSON.parse(response.body)
assert_equal "invalid_file_type", json_response["error"]
end
test "should reject raw content exceeding max size" do
# Use a small test limit to avoid Rack request size limits
test_limit = 1.kilobyte
large_content = "x" * (test_limit + 1)
Import.stubs(:max_csv_size).returns(test_limit)
assert_no_difference("Import.count") do
post api_v1_imports_url,
params: { raw_file_content: large_content },
headers: api_headers(@api_key)
end
assert_response :unprocessable_entity
json_response = JSON.parse(response.body)
assert_equal "content_too_large", json_response["error"]
end
test "should accept file upload with valid csv mime type" do
csv_content = "date,amount,name\n2023-01-01,-10.00,Test Transaction"
valid_file = Rack::Test::UploadedFile.new(
StringIO.new(csv_content),
"text/csv",
original_filename: "transactions.csv"
)
assert_difference("Import.count") do
post api_v1_imports_url,
params: {
file: valid_file,
date_col_label: "date",
amount_col_label: "amount",
name_col_label: "name",
account_id: @account.id
},
headers: api_headers(@api_key)
end
assert_response :created
end
private
def api_headers(api_key)
{ "X-Api-Key" => api_key.plain_key }
end
end