Merge branch 'main' into feat/savings-goals

Signed-off-by: Guillem Arias Fauste <accounts@gariasf.com>
This commit is contained in:
Guillem Arias Fauste
2026-05-13 18:22:55 +02:00
committed by GitHub
267 changed files with 19408 additions and 455 deletions

View File

@@ -405,9 +405,7 @@ class Api::V1::ImportsControllerTest < ActionDispatch::IntegrationTest
original_filename: "large.ndjson"
)
original_value = SureImport::MAX_NDJSON_SIZE
SureImport.send(:remove_const, :MAX_NDJSON_SIZE)
SureImport.const_set(:MAX_NDJSON_SIZE, test_limit)
SureImport.stubs(:max_ndjson_size).returns(test_limit)
assert_no_difference("Import.count") do
post api_v1_imports_url,
@@ -421,9 +419,6 @@ class Api::V1::ImportsControllerTest < ActionDispatch::IntegrationTest
assert_response :unprocessable_entity
json_response = JSON.parse(response.body)
assert_equal "file_too_large", json_response["error"]
ensure
SureImport.send(:remove_const, :MAX_NDJSON_SIZE)
SureImport.const_set(:MAX_NDJSON_SIZE, original_value)
end
test "should reject Sure import uploaded file with invalid type" do
@@ -551,6 +546,473 @@ class Api::V1::ImportsControllerTest < ActionDispatch::IntegrationTest
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 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"
@@ -633,9 +1095,7 @@ class Api::V1::ImportsControllerTest < ActionDispatch::IntegrationTest
test_limit = 1.kilobyte
large_content = "x" * (test_limit + 1)
original_value = Import::MAX_CSV_SIZE
Import.send(:remove_const, :MAX_CSV_SIZE)
Import.const_set(:MAX_CSV_SIZE, test_limit)
Import.stubs(:max_csv_size).returns(test_limit)
assert_no_difference("Import.count") do
post api_v1_imports_url,
@@ -646,9 +1106,6 @@ class Api::V1::ImportsControllerTest < ActionDispatch::IntegrationTest
assert_response :unprocessable_entity
json_response = JSON.parse(response.body)
assert_equal "content_too_large", json_response["error"]
ensure
Import.send(:remove_const, :MAX_CSV_SIZE)
Import.const_set(:MAX_CSV_SIZE, original_value)
end
test "should accept file upload with valid csv mime type" do

View File

@@ -104,12 +104,28 @@ class Api::V1::ProviderConnectionsControllerTest < ActionDispatch::IntegrationTe
failed_at: Time.current,
error: "raw provider token secret"
)
kraken_item = kraken_items(:one)
kraken_item.syncs.create!(
status: "failed",
failed_at: Time.current,
error: "raw kraken key secret"
)
get api_v1_provider_connections_url, headers: api_headers(@api_key)
assert_response :success
json_response = JSON.parse(response.body)
kraken_connection = json_response["data"].detect do |connection|
connection["id"] == kraken_item.id && connection["provider"] == "kraken"
end
assert_not_nil kraken_connection
assert_equal "KrakenItem", kraken_connection["provider_type"]
refute_includes response.body, @mercury_item.token
refute_includes response.body, kraken_item.api_key
refute_includes response.body, kraken_item.api_secret
refute_includes response.body, "raw provider token secret"
refute_includes response.body, "raw kraken key secret"
end
test "fails closed when credential readiness is unknown" do
@@ -142,6 +158,24 @@ class Api::V1::ProviderConnectionsControllerTest < ActionDispatch::IntegrationTe
assert_response :success
end
test "lists Brex provider connection status" do
brex_item = brex_items(:one)
get api_v1_provider_connections_url, headers: api_headers(@api_key)
assert_response :success
brex_connection = JSON.parse(response.body)["data"].detect do |connection|
connection["id"] == brex_item.id && connection["provider"] == "brex"
end
assert_not_nil brex_connection
assert_equal "BrexItem", brex_connection["provider_type"]
assert_equal brex_item.name, brex_connection["name"]
assert_equal brex_item.brex_accounts.count, brex_connection["accounts"]["total_count"]
assert_equal brex_item.linked_accounts_count, brex_connection["accounts"]["linked_count"]
assert_equal brex_item.unlinked_accounts_count, brex_connection["accounts"]["unlinked_count"]
end
test "returns an empty list when no provider connections exist" do
ProviderConnectionStatus.stub(:for_family, []) do
get api_v1_provider_connections_url, headers: api_headers(@api_key)

View File

@@ -66,6 +66,44 @@ class Api::V1::TransactionsControllerTest < ActionDispatch::IntegrationTest
end
end
test "should include disabled account transactions in index history" do
disabled_transaction = create_disabled_account_transaction(name: "Closed Account Grocery")
get api_v1_transactions_url, headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
transaction_ids = response_data["transactions"].map { |transaction| transaction["id"] }
assert_includes transaction_ids, disabled_transaction.id
end
test "should exclude pending deletion account transactions from index history" do
pending_deletion_transaction = create_account_transaction(
status: "pending_deletion",
name: "Pending Delete Account Grocery"
)
get api_v1_transactions_url, headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
transaction_ids = response_data["transactions"].map { |transaction| transaction["id"] }
assert_not_includes transaction_ids, pending_deletion_transaction.id
end
test "should filter disabled account transactions by account_id" do
disabled_transaction = create_disabled_account_transaction(name: "Closed Account Filter")
disabled_account = disabled_transaction.entry.account
get api_v1_transactions_url,
params: { account_id: disabled_account.id },
headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_equal [ disabled_transaction.id ], response_data["transactions"].map { |transaction| transaction["id"] }
end
test "should filter transactions by date range" do
start_date = 1.month.ago.to_date
end_date = Date.current
@@ -83,6 +121,22 @@ class Api::V1::TransactionsControllerTest < ActionDispatch::IntegrationTest
end
end
test "should filter disabled account transactions by date range" do
disabled_transaction = create_disabled_account_transaction(
name: "Closed Account Date Range",
date: Date.current - 3.days
)
get api_v1_transactions_url,
params: { start_date: Date.current - 4.days, end_date: Date.current - 2.days },
headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
transaction_ids = response_data["transactions"].map { |transaction| transaction["id"] }
assert_includes transaction_ids, disabled_transaction.id
end
test "should search transactions" do
# Create a transaction with a specific name for testing
entry = @account.entries.create!(
@@ -103,6 +157,19 @@ class Api::V1::TransactionsControllerTest < ActionDispatch::IntegrationTest
assert_not_nil found_transaction, "Should find the coffee transaction"
end
test "should search disabled account transactions" do
disabled_transaction = create_disabled_account_transaction(name: "Closed Account Coffee")
get api_v1_transactions_url,
params: { search: "Closed Account Coffee" },
headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
found_transaction = response_data["transactions"].find { |transaction| transaction["id"] == disabled_transaction.id }
assert_not_nil found_transaction, "Should find disabled account transactions in global history search"
end
test "should paginate transactions" do
get api_v1_transactions_url,
params: { page: 1, per_page: 5 },
@@ -144,9 +211,33 @@ class Api::V1::TransactionsControllerTest < ActionDispatch::IntegrationTest
assert_response :success
end
test "should return 404 for non-existent transaction" do
test "should show disabled account transaction" do
disabled_transaction = create_disabled_account_transaction(name: "Closed Account Show")
get api_v1_transaction_url(disabled_transaction), headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_equal disabled_transaction.id, response_data["id"]
assert_equal disabled_transaction.entry.account_id, response_data["account"]["id"]
end
test "should return 404 for valid missing transaction id" do
get api_v1_transaction_url(SecureRandom.uuid), headers: api_headers(@api_key)
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "not_found", response_data["error"]
assert_equal "Transaction not found", response_data["message"]
end
test "should return 404 for malformed id" do
get api_v1_transaction_url(999999), headers: api_headers(@api_key)
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "not_found", response_data["error"]
assert_equal "Transaction not found", response_data["message"]
end
test "should reject show request without API key" do
@@ -179,6 +270,220 @@ class Api::V1::TransactionsControllerTest < ActionDispatch::IntegrationTest
assert_equal @account.id, response_data["account"]["id"]
end
test "should create transaction with external idempotency key" do
transaction_params = {
transaction: {
account_id: @account.id,
name: "Imported Transaction",
amount: 25.00,
date: Date.current,
currency: "USD",
nature: "expense",
external_id: "import-txn-1",
source: "external_import"
}
}
assert_difference("@account.entries.count", 1) do
post api_v1_transactions_url,
params: transaction_params,
headers: api_headers(@api_key)
end
assert_response :created
response_data = JSON.parse(response.body)
assert_equal "import-txn-1", response_data["external_id"]
assert_equal "external_import", response_data["source"]
entry = @account.entries.find_by!(external_id: "import-txn-1", source: "external_import")
assert_equal response_data["id"], entry.transaction.id
end
test "should use default source when external_id provided without source" do
transaction_params = {
transaction: {
account_id: @account.id,
name: "Imported Transaction",
amount: 25.00,
date: Date.current,
currency: "USD",
nature: "expense",
external_id: "default-source-test"
}
}
assert_difference("@account.entries.count", 1) do
post api_v1_transactions_url,
params: transaction_params,
headers: api_headers(@api_key)
end
assert_response :created
response_data = JSON.parse(response.body)
entry = @account.entries.find_by!(external_id: "default-source-test")
assert_equal "api", entry.source
assert_equal "api", response_data["source"]
assert_no_difference("@account.entries.count") do
post api_v1_transactions_url,
params: transaction_params.deep_merge(transaction: { name: "Changed Name" }),
headers: api_headers(@api_key)
end
assert_response :ok
end
test "should reject source without external idempotency key" do
transaction_params = {
transaction: {
account_id: @account.id,
name: "Imported Transaction",
amount: 25.00,
date: Date.current,
currency: "USD",
nature: "expense",
source: "external_import"
}
}
assert_no_difference("@account.entries.count") do
post api_v1_transactions_url,
params: transaction_params,
headers: api_headers(@api_key)
end
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
assert_equal "Source requires external_id", response_data["message"]
assert_equal [ "Source requires external_id" ], response_data["errors"]
end
test "should return existing transaction for duplicate external idempotency key" do
transaction_params = {
transaction: {
account_id: @account.id,
name: "Imported Transaction",
amount: 25.00,
date: Date.current,
currency: "USD",
nature: "expense",
external_id: "import-txn-2",
source: "external_import"
}
}
post api_v1_transactions_url,
params: transaction_params,
headers: api_headers(@api_key)
assert_response :created
created_data = JSON.parse(response.body)
assert_no_difference("@account.entries.count") do
post api_v1_transactions_url,
params: transaction_params.deep_merge(transaction: { name: "Changed Name" }),
headers: api_headers(@api_key)
end
assert_response :ok
response_data = JSON.parse(response.body)
assert_equal created_data["id"], response_data["id"]
assert_equal "Imported Transaction", response_data["name"]
end
test "should scope external idempotency keys to account" do
other_account = @family.accounts.create!(
name: "Other API Account",
accountable: Depository.new,
balance: 0,
currency: "USD"
)
transaction_params = {
transaction: {
name: "Imported Transaction",
amount: 25.00,
date: Date.current,
currency: "USD",
nature: "expense",
external_id: "shared-import-txn",
source: "external_import"
}
}
assert_difference("Entry.count", 2) do
post api_v1_transactions_url,
params: transaction_params.deep_merge(transaction: { account_id: @account.id }),
headers: api_headers(@api_key)
assert_response :created
post api_v1_transactions_url,
params: transaction_params.deep_merge(transaction: { account_id: other_account.id }),
headers: api_headers(@api_key)
assert_response :created
end
end
test "should scope external idempotency keys to source" do
transaction_params = {
transaction: {
account_id: @account.id,
name: "Imported Transaction",
amount: 25.00,
date: Date.current,
currency: "USD",
nature: "expense",
external_id: "shared-source-txn",
source: "external_import"
}
}
assert_difference("Entry.count", 2) do
post api_v1_transactions_url,
params: transaction_params,
headers: api_headers(@api_key)
assert_response :created
post api_v1_transactions_url,
params: transaction_params.deep_merge(transaction: { source: "other_import" }),
headers: api_headers(@api_key)
assert_response :created
end
@account.entries.find_by!(external_id: "shared-source-txn", source: "external_import")
@account.entries.find_by!(external_id: "shared-source-txn", source: "other_import")
end
test "should reject external idempotency key collision with non-transaction entry" do
@account.entries.create!(
name: "Existing valuation",
amount: 100,
currency: "USD",
date: Date.current,
external_id: "import-non-transaction",
source: "external_import",
entryable: Valuation.new
)
post api_v1_transactions_url,
params: {
transaction: {
account_id: @account.id,
name: "Imported Transaction",
amount: 25.00,
date: Date.current - 1.day,
currency: "USD",
nature: "expense",
external_id: "import-non-transaction",
source: "external_import"
}
},
headers: api_headers(@api_key)
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
end
test "should reject create with read-only API key" do
transaction_params = {
transaction: {
@@ -209,6 +514,31 @@ class Api::V1::TransactionsControllerTest < ActionDispatch::IntegrationTest
assert_response :unprocessable_entity
end
test "should reject invalid date on create" do
transaction_params = {
transaction: {
account_id: @account.id,
name: "Invalid Date Transaction",
amount: 25.00,
date: "not-a-date",
currency: "USD",
nature: "expense"
}
}
assert_no_difference("@account.entries.count") do
post api_v1_transactions_url,
params: transaction_params,
headers: api_headers(@api_key)
end
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
assert_equal "Transaction could not be created", response_data["message"]
assert response_data["errors"].any? { |error| error.match?(/Date/) }
end
test "should reject create without API key" do
post api_v1_transactions_url, params: { transaction: { name: "Test" } }
assert_response :unauthorized
@@ -450,4 +780,28 @@ end
"non-income transactions should have non-positive signed_amount_cents"
end
end
def create_disabled_account_transaction(name:, date: Date.current)
create_account_transaction(status: "disabled", name: name, date: date)
end
def create_account_transaction(status:, name:, date: Date.current)
account = @family.accounts.create!(
name: "#{status.titleize} Checking #{SecureRandom.hex(4)}",
balance: 0,
currency: "USD",
status: status,
accountable: Depository.new
)
entry = account.entries.create!(
name: name,
amount: 12.34,
currency: "USD",
date: date,
entryable: Transaction.new
)
entry.transaction
end
end

View File

@@ -111,26 +111,28 @@ class Api::V1::UsageControllerTest < ActionDispatch::IntegrationTest
end
test "should work correctly when approaching rate limit" do
# Make 98 requests to get close to the limit
98.times do
travel_to Time.zone.local(2026, 1, 1, 12, 15, 0) do
# Make 98 requests to get close to the limit
98.times do
get "/api/v1/test", headers: { "X-Api-Key" => @api_key.display_key }
assert_response :success
end
# Check usage - this should be request 99
get "/api/v1/usage", headers: { "X-Api-Key" => @api_key.display_key }
assert_response :success
response_body = JSON.parse(response.body)
assert_equal 99, response_body["rate_limit"]["current_count"]
assert_equal 1, response_body["rate_limit"]["remaining"]
# One more request should hit the limit
get "/api/v1/test", headers: { "X-Api-Key" => @api_key.display_key }
assert_response :success
# Now we should be rate limited
get "/api/v1/usage", headers: { "X-Api-Key" => @api_key.display_key }
assert_response :too_many_requests
end
# Check usage - this should be request 99
get "/api/v1/usage", headers: { "X-Api-Key" => @api_key.display_key }
assert_response :success
response_body = JSON.parse(response.body)
assert_equal 99, response_body["rate_limit"]["current_count"]
assert_equal 1, response_body["rate_limit"]["remaining"]
# One more request should hit the limit
get "/api/v1/test", headers: { "X-Api-Key" => @api_key.display_key }
assert_response :success
# Now we should be rate limited
get "/api/v1/usage", headers: { "X-Api-Key" => @api_key.display_key }
assert_response :too_many_requests
end
end

View File

@@ -0,0 +1,488 @@
# frozen_string_literal: true
require "test_helper"
class BrexItemsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in users(:family_admin)
SyncJob.stubs(:perform_later)
@family = families(:dylan_family)
clear_brex_cache_entries
@existing_item = brex_items(:one)
@second_item = BrexItem.create!(
family: @family,
name: "Business Brex",
token: "second_brex_token",
base_url: "https://api.brex.com"
)
end
teardown do
clear_brex_cache_entries
end
test "create adds a new brex connection without overwriting existing credentials" do
existing_token = @existing_item.token
assert_difference "BrexItem.count", 1 do
post brex_items_url, params: {
brex_item: {
name: "Joint Brex",
token: "joint_brex_token",
base_url: "https://api.brex.com"
}
}
end
assert_redirected_to accounts_path
assert_equal existing_token, @existing_item.reload.token
assert_equal "joint_brex_token", @family.brex_items.find_by!(name: "Joint Brex").token
end
test "create uses localized default name when submitted name is blank" do
assert_difference "BrexItem.count", 1 do
post brex_items_url, params: {
brex_item: {
name: " ",
token: "default_name_token",
base_url: "https://api.brex.com"
}
}
end
assert_redirected_to accounts_path
assert_equal I18n.t("brex_items.default_connection_name"), @family.brex_items.order(:created_at).last.name
end
test "update changes only the selected brex connection" do
existing_token = @existing_item.token
patch brex_item_url(@second_item), params: {
brex_item: {
name: "Renamed Business Brex",
token: "updated_second_token",
base_url: "https://api-staging.brex.com"
}
}
assert_redirected_to accounts_path
assert_equal existing_token, @existing_item.reload.token
assert_equal "Renamed Business Brex", @second_item.reload.name
assert_equal "updated_second_token", @second_item.token
assert_equal "https://api-staging.brex.com", @second_item.base_url
end
test "update rejects arbitrary brex base url" do
patch brex_item_url(@second_item), params: {
brex_item: {
name: "Renamed Business Brex",
token: "updated_second_token",
base_url: "https://evil.example.test"
}
}
assert_redirected_to settings_providers_path
assert_includes flash[:alert], "https://api.brex.com"
assert_equal "https://api.brex.com", @second_item.reload.base_url
assert_equal "second_brex_token", @second_item.token
end
test "blank token update preserves the selected brex token" do
original_token = @second_item.token
patch brex_item_url(@second_item), params: {
brex_item: {
name: "Renamed Business Brex",
token: "",
base_url: "https://api.brex.com"
}
}
assert_redirected_to accounts_path
assert_equal "Renamed Business Brex", @second_item.reload.name
assert_equal original_token, @second_item.token
end
test "update expires selected brex account cache when credentials change" do
Rails.cache.expects(:delete).with(brex_cache_key(@existing_item)).never
Rails.cache.expects(:delete).with(brex_cache_key(@second_item)).once
patch brex_item_url(@second_item), params: {
brex_item: {
name: "Renamed Business Brex",
token: "updated_second_token",
base_url: "https://api-staging.brex.com"
}
}
assert_redirected_to accounts_path
end
test "update does not expire selected brex account cache for name-only changes" do
Rails.cache.expects(:delete).never
patch brex_item_url(@second_item), params: {
brex_item: {
name: "Renamed Business Brex"
}
}
assert_redirected_to accounts_path
assert_equal "Renamed Business Brex", @second_item.reload.name
end
test "preload accounts uses selected brex item cache key" do
Rails.cache.expects(:read).with(brex_cache_key(@second_item)).returns(nil)
Rails.cache.expects(:write).with(brex_cache_key(@second_item), brex_accounts_payload, expires_in: 5.minutes)
provider = mock("brex_provider")
provider.expects(:get_accounts).returns(accounts: brex_accounts_payload)
Provider::Brex.expects(:new)
.with(@second_item.token, base_url: @second_item.effective_base_url)
.returns(provider)
get preload_accounts_brex_items_url, params: { brex_item_id: @second_item.id }, as: :json
assert_response :success
response = JSON.parse(@response.body)
assert_equal true, response["success"]
assert_equal true, response["has_accounts"]
end
test "select accounts requires an explicit connection when multiple brex items exist" do
get select_accounts_brex_items_url, params: { accountable_type: "Depository" }
assert_redirected_to settings_providers_path
assert_equal I18n.t("brex_items.select_accounts.select_connection"), flash[:alert]
end
test "select accounts renders the selected brex item id" do
Rails.cache.expects(:read).with(brex_cache_key(@second_item)).returns(nil)
Rails.cache.expects(:write).with(brex_cache_key(@second_item), brex_accounts_payload, expires_in: 5.minutes)
provider = mock("brex_provider")
provider.expects(:get_accounts).returns(accounts: brex_accounts_payload)
Provider::Brex.expects(:new)
.with(@second_item.token, base_url: @second_item.effective_base_url)
.returns(provider)
get select_accounts_brex_items_url, params: {
brex_item_id: @second_item.id,
accountable_type: "Depository"
}
assert_response :success
assert_includes @response.body, %(name="brex_item_id")
assert_includes @response.body, %(value="#{@second_item.id}")
end
test "select accounts rejects protocol relative return paths" do
Rails.cache.expects(:read).with(brex_cache_key(@second_item)).returns(brex_accounts_payload)
get select_accounts_brex_items_url, params: {
brex_item_id: @second_item.id,
accountable_type: "Depository",
return_to: "//evil.example/accounts"
}
assert_response :success
refute_includes @response.body, "//evil.example/accounts"
end
test "select accounts rejects backslash and unsafe local return paths" do
[
"/\\evil.example/accounts",
"/%2fevil.example/accounts",
"/%2Fevil.example/accounts",
"/%5cevil.example/accounts",
"/%5Cevil.example/accounts",
"/\naccounts",
"/ accounts",
"/"
].each do |return_to|
Rails.cache.expects(:read).with(brex_cache_key(@second_item)).returns(brex_accounts_payload)
get select_accounts_brex_items_url, params: {
brex_item_id: @second_item.id,
accountable_type: "Depository",
return_to: return_to
}
assert_response :success
assert_select %(input[name="return_to"]) do |fields|
assert fields.first["value"].blank?
end
end
end
test "select existing account rejects unsafe return paths" do
account = @family.accounts.create!(
name: "Manual Checking",
balance: 0,
currency: "USD",
accountable: Depository.new
)
[
"//evil.example/accounts",
"\\evil.example/accounts",
"/\\evil.example/accounts",
"/%2fevil.example/accounts",
"/%2Fevil.example/accounts",
"/%5cevil.example/accounts",
"/%5Cevil.example/accounts",
"/\naccounts",
"/ accounts",
" ",
"/"
].each do |return_to|
Rails.cache.expects(:read).with(brex_cache_key(@second_item)).returns(brex_accounts_payload)
get select_existing_account_brex_items_url, params: {
brex_item_id: @second_item.id,
account_id: account.id,
return_to: return_to
}
assert_response :success
assert_select %(input[name="return_to"]) do |fields|
assert fields.first["value"].blank?
end
end
end
test "select existing account preserves safe local return path" do
account = @family.accounts.create!(
name: "Manual Checking",
balance: 0,
currency: "USD",
accountable: Depository.new
)
return_to = "/accounts?tab=manual"
Rails.cache.expects(:read).with(brex_cache_key(@second_item)).returns(brex_accounts_payload)
get select_existing_account_brex_items_url, params: {
brex_item_id: @second_item.id,
account_id: account.id,
return_to: return_to
}
assert_response :success
assert_select %(input[name="return_to"][value="#{return_to}"])
end
test "select existing account redirects when account id is invalid" do
get select_existing_account_brex_items_url, params: {
brex_item_id: @second_item.id,
account_id: SecureRandom.uuid
}
assert_redirected_to accounts_path
assert_equal I18n.t("brex_items.select_existing_account.no_account_specified"), flash[:alert]
end
test "select existing account renders the selected brex item id" do
account = @family.accounts.create!(
name: "Manual Checking",
balance: 0,
currency: "USD",
accountable: Depository.new
)
Rails.cache.expects(:read).with(brex_cache_key(@second_item)).returns(nil)
Rails.cache.expects(:write).with(brex_cache_key(@second_item), brex_accounts_payload, expires_in: 5.minutes)
provider = mock("brex_provider")
provider.expects(:get_accounts).returns(accounts: brex_accounts_payload)
Provider::Brex.expects(:new)
.with(@second_item.token, base_url: @second_item.effective_base_url)
.returns(provider)
get select_existing_account_brex_items_url, params: {
brex_item_id: @second_item.id,
account_id: account.id
}
assert_response :success
assert_includes @response.body, %(name="brex_item_id")
assert_includes @response.body, %(value="#{@second_item.id}")
end
test "link accounts uses selected brex item and allows duplicate upstream ids across items" do
@existing_item.brex_accounts.create!(
account_id: "shared_brex_account",
name: "Shared Checking",
currency: "USD",
current_balance: 1000
)
provider = mock("brex_provider")
provider.expects(:get_accounts).returns(accounts: brex_accounts_payload)
Provider::Brex.expects(:new)
.with(@second_item.token, base_url: @second_item.effective_base_url)
.returns(provider)
assert_difference -> { @second_item.brex_accounts.where(account_id: "shared_brex_account").count }, 1 do
assert_difference "AccountProvider.count", 1 do
post link_accounts_brex_items_url, params: {
brex_item_id: @second_item.id,
account_ids: [ "shared_brex_account" ],
accountable_type: "Depository"
}
end
end
assert_redirected_to accounts_path
assert_equal 1, @existing_item.brex_accounts.where(account_id: "shared_brex_account").count
end
test "link accounts does not silently use the first connection when multiple items exist" do
assert_no_difference "BrexAccount.count" do
assert_no_difference "Account.count" do
post link_accounts_brex_items_url, params: {
account_ids: [ "shared_brex_account" ],
accountable_type: "Depository"
}
end
end
assert_redirected_to settings_providers_path
assert_equal I18n.t("brex_items.link_accounts.select_connection"), flash[:alert]
end
test "link existing account does not silently use the first connection when multiple items exist" do
account = @family.accounts.create!(
name: "Manual Checking",
balance: 0,
currency: "USD",
accountable: Depository.new
)
assert_no_difference "BrexAccount.count" do
assert_no_difference "AccountProvider.count" do
post link_existing_account_brex_items_url, params: {
account_id: account.id,
brex_account_id: "shared_brex_account"
}
end
end
assert_redirected_to settings_providers_path
assert_equal I18n.t("brex_items.link_existing_account.select_connection"), flash[:alert]
end
test "link existing account requires account id" do
assert_no_difference "AccountProvider.count" do
post link_existing_account_brex_items_url, params: {
brex_item_id: @second_item.id,
brex_account_id: "shared_brex_account"
}
end
assert_redirected_to accounts_path
assert_equal I18n.t("brex_items.link_existing_account.no_account_specified"), flash[:alert]
end
test "link existing account redirects when account id is invalid" do
assert_no_difference "AccountProvider.count" do
post link_existing_account_brex_items_url, params: {
brex_item_id: @second_item.id,
account_id: SecureRandom.uuid,
brex_account_id: "shared_brex_account"
}
end
assert_redirected_to accounts_path
assert_equal I18n.t("brex_items.link_existing_account.no_account_specified"), flash[:alert]
end
test "sync only queues a sync for the selected brex item" do
assert_difference -> { Sync.where(syncable: @second_item).count }, 1 do
assert_no_difference -> { Sync.where(syncable: @existing_item).count } do
post sync_brex_item_url(@second_item)
end
end
assert_response :redirect
end
test "complete account setup ignores unsupported account type and subtype params" do
valid_brex_account = @second_item.brex_accounts.create!(
account_id: "setup_valid",
account_kind: "cash",
name: "Setup Valid",
currency: "USD",
current_balance: 100
)
unsupported_brex_account = @second_item.brex_accounts.create!(
account_id: "setup_unsupported",
account_kind: "cash",
name: "Setup Unsupported",
currency: "USD",
current_balance: 100
)
assert_difference "AccountProvider.count", 1 do
post complete_account_setup_brex_item_url(@second_item), params: {
account_types: {
valid_brex_account.id => "Depository",
unsupported_brex_account.id => "Investment",
"not-a-brex-account" => "Depository"
},
account_subtypes: {
valid_brex_account.id => "savings",
unsupported_brex_account.id => "brokerage",
"not-a-brex-account" => "checking"
}
}
end
assert_redirected_to accounts_path
assert_equal "savings", valid_brex_account.reload.account.accountable.subtype
assert_nil unsupported_brex_account.reload.account_provider
assert_match(/skipped/i, flash[:notice])
end
test "complete account setup treats scalar setup params as empty" do
assert_no_difference "AccountProvider.count" do
post complete_account_setup_brex_item_url(@second_item), params: {
account_types: "not-a-hash",
account_subtypes: "also-not-a-hash"
}
end
assert_redirected_to accounts_path
assert_equal I18n.t("brex_items.complete_account_setup.no_accounts"), flash[:alert]
end
private
def brex_accounts_payload
[
{
id: "shared_brex_account",
name: "Shared Checking",
account_kind: "cash",
status: "active",
current_balance: { amount: 100_000, currency: "USD" },
available_balance: { amount: 95_000, currency: "USD" }
}
]
end
def brex_cache_key(brex_item)
BrexItem::AccountFlow.cache_key(@family, brex_item)
end
def clear_brex_cache_entries
return unless defined?(@family) && @family.present?
return unless Rails.cache.respond_to?(:delete_matched)
Rails.cache.delete_matched("brex_accounts_#{@family.id}_*")
rescue NotImplementedError
# Some test cache stores do not implement delete_matched; tests that depend
# on cache state stub exact Brex cache keys instead of relying on globals.
end
end

View File

@@ -0,0 +1,100 @@
require "test_helper"
class IbkrItemsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in @user = users(:family_admin)
@ibkr_item = ibkr_items(:configured_item)
end
test "select_existing_account renders available ibkr accounts" do
get select_existing_account_ibkr_items_url, params: { account_id: accounts(:investment).id }
assert_response :success
assert_includes response.body, ibkr_accounts(:main_account).name
end
test "create redirects to accounts on success" do
assert_difference "IbkrItem.count", 1 do
post ibkr_items_url, params: {
ibkr_item: {
query_id: "QUERYNEW",
token: "TOKENNEW"
}
}
end
assert_redirected_to accounts_path
end
test "update redirects to accounts on success" do
patch ibkr_item_url(@ibkr_item), params: {
ibkr_item: {
query_id: "",
token: ""
}
}
assert_redirected_to accounts_path
end
test "complete_account_setup creates investment account and provider link" do
assert_difference "Account.count", 1 do
assert_difference "AccountProvider.count", 1 do
post complete_account_setup_ibkr_item_url(@ibkr_item), params: {
account_ids: [ ibkr_accounts(:main_account).id ]
}
end
end
created_account = Account.order(created_at: :desc).first
assert_equal "Investment", created_account.accountable_type
assert_equal "brokerage", created_account.accountable.subtype
assert_redirected_to accounts_path
ibkr_accounts(:main_account).reload
assert_equal created_account, ibkr_accounts(:main_account).current_account
end
test "link_existing_account links manual investment account" do
account = accounts(:investment)
assert_difference "AccountProvider.count", 1 do
post link_existing_account_ibkr_items_url, params: {
account_id: account.id,
ibkr_account_id: ibkr_accounts(:main_account).id
}
end
assert_redirected_to account_path(account)
ibkr_accounts(:main_account).reload
assert_equal account, ibkr_accounts(:main_account).current_account
end
test "link_existing_account rejects already linked ibkr account" do
original_account = accounts(:investment)
ibkr_account = ibkr_accounts(:main_account)
AccountProvider.create!(account: original_account, provider: ibkr_account)
replacement_account = Account.create!(
family: @ibkr_item.family,
owner: @user,
name: "Replacement Brokerage Account",
balance: 2500,
cash_balance: 2500,
currency: "USD",
accountable: Investment.create!(subtype: "brokerage")
)
assert_no_difference "AccountProvider.count" do
post link_existing_account_ibkr_items_url, params: {
account_id: replacement_account.id,
ibkr_account_id: ibkr_account.id
}
end
assert_redirected_to account_path(replacement_account)
assert_equal "This Interactive Brokers account is already linked.", flash[:alert]
ibkr_account.reload
assert_equal original_account, ibkr_account.current_account
end
end

View File

@@ -0,0 +1,278 @@
# frozen_string_literal: true
require "test_helper"
class KrakenItemsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in users(:family_admin)
SyncJob.stubs(:perform_later)
@family = families(:dylan_family)
@existing_item = kraken_items(:one)
kraken_items(:requires_update).update!(scheduled_for_deletion: true)
@second_item = KrakenItem.create!(
family: @family,
name: "Business Kraken",
api_key: "second_kraken_key",
api_secret: "second_kraken_secret"
)
end
test "create adds a new kraken connection without overwriting existing credentials" do
existing_key = @existing_item.api_key
existing_secret = @existing_item.api_secret
assert_difference "KrakenItem.count", 1 do
post kraken_items_url, params: {
kraken_item: {
name: "Joint Kraken",
api_key: "joint_kraken_key",
api_secret: "joint_kraken_secret"
}
}
end
assert_redirected_to settings_providers_path
assert_equal existing_key, @existing_item.reload.api_key
assert_equal existing_secret, @existing_item.api_secret
assert_equal "joint_kraken_key", @family.kraken_items.find_by!(name: "Joint Kraken").api_key
end
test "update changes only the selected kraken connection" do
existing_key = @existing_item.api_key
patch kraken_item_url(@second_item), params: {
kraken_item: {
name: "Renamed Business Kraken",
api_key: "updated_second_key",
api_secret: "updated_second_secret"
}
}
assert_redirected_to settings_providers_path
assert_equal existing_key, @existing_item.reload.api_key
assert_equal "Renamed Business Kraken", @second_item.reload.name
assert_equal "updated_second_key", @second_item.api_key
assert_equal "updated_second_secret", @second_item.api_secret
end
test "blank secret update preserves the selected kraken credentials" do
original_key = @second_item.api_key
original_secret = @second_item.api_secret
patch kraken_item_url(@second_item), params: {
kraken_item: {
name: "Renamed Business Kraken",
api_key: "",
api_secret: ""
}
}
assert_redirected_to settings_providers_path
assert_equal "Renamed Business Kraken", @second_item.reload.name
assert_equal original_key, @second_item.api_key
assert_equal original_secret, @second_item.api_secret
end
test "create rejects whitespace-only credentials" do
assert_no_difference "KrakenItem.count" do
post kraken_items_url, params: {
kraken_item: {
name: "Blank Kraken",
api_key: " ",
api_secret: "\n"
}
}
end
assert_redirected_to settings_providers_path
assert_match(/API key can't be blank/i, flash[:alert])
end
test "select accounts requires an explicit connection when multiple kraken items exist" do
get select_accounts_kraken_items_url, params: { accountable_type: "Crypto" }
assert_redirected_to settings_providers_path
assert_equal "Choose a Kraken connection in Provider Settings.", flash[:alert]
end
test "select accounts targets selected kraken item" do
get select_accounts_kraken_items_url, params: {
kraken_item_id: @second_item.id,
accountable_type: "Crypto"
}
assert_redirected_to setup_accounts_kraken_item_path(@second_item, return_to: nil)
end
test "select accounts rejects protocol-relative return paths" do
get select_accounts_kraken_items_url, params: {
kraken_item_id: @second_item.id,
accountable_type: "Crypto",
return_to: "//evil.example/accounts"
}
assert_redirected_to setup_accounts_kraken_item_path(@second_item, return_to: nil)
end
test "sync only queues a sync for the selected kraken item" do
assert_difference -> { Sync.where(syncable: @second_item).count }, 1 do
assert_no_difference -> { Sync.where(syncable: @existing_item).count } do
post sync_kraken_item_url(@second_item)
end
end
assert_response :redirect
end
test "setup accounts creates crypto exchange account for selected item only" do
first_account = kraken_accounts(:one)
second_account = @second_item.kraken_accounts.create!(
name: "Second Kraken",
account_id: "combined",
account_type: "combined",
currency: "USD",
current_balance: 1000
)
KrakenAccount::Processor.any_instance.stubs(:process).returns(nil)
assert_difference "Account.count", 1 do
post complete_account_setup_kraken_item_url(@second_item), params: {
selected_accounts: [ second_account.id ]
}
end
assert_redirected_to accounts_path
assert_nil first_account.reload.current_account
assert_equal "Crypto", second_account.reload.current_account.accountable_type
assert_equal "exchange", second_account.current_account.accountable.subtype
end
test "link existing account links manual crypto exchange account to selected kraken account" do
manual_account = manual_crypto_exchange_account
kraken_account = @second_item.kraken_accounts.create!(
name: "Kraken",
account_id: "combined",
account_type: "combined",
currency: "USD",
current_balance: 1000
)
assert_difference "AccountProvider.count", 1 do
post link_existing_account_kraken_items_url, params: {
kraken_item_id: @second_item.id,
account_id: manual_account.id,
kraken_account_id: kraken_account.id
}
end
assert_redirected_to accounts_path
assert_equal manual_account, kraken_account.reload.current_account
end
test "link existing account requires explicit connection when multiple items exist" do
account = manual_crypto_exchange_account
assert_no_difference "AccountProvider.count" do
post link_existing_account_kraken_items_url, params: {
account_id: account.id,
kraken_account_id: "combined"
}
end
assert_redirected_to settings_providers_path
assert_equal "Choose a Kraken connection before linking accounts.", flash[:alert]
end
test "link existing account rejects non crypto accounts" do
account = @family.accounts.create!(
name: "Manual Checking",
balance: 0,
currency: "USD",
accountable: Depository.new
)
kraken_account = @second_item.kraken_accounts.create!(name: "Kraken", account_id: "combined", account_type: "combined", currency: "USD")
assert_no_difference "AccountProvider.count" do
post link_existing_account_kraken_items_url, params: {
kraken_item_id: @second_item.id,
account_id: account.id,
kraken_account_id: kraken_account.id
}
end
assert_redirected_to account_path(account)
end
test "link existing account rejects accounts with existing provider links" do
account = manual_crypto_exchange_account
linked_kraken_account = kraken_accounts(:one)
AccountProvider.create!(account: account, provider: linked_kraken_account)
kraken_account = @second_item.kraken_accounts.create!(name: "Kraken", account_id: "combined", account_type: "combined", currency: "USD")
assert_no_difference "AccountProvider.count" do
post link_existing_account_kraken_items_url, params: {
kraken_item_id: @second_item.id,
account_id: account.id,
kraken_account_id: kraken_account.id
}
end
assert_redirected_to account_path(account)
end
test "link existing account rejects kraken accounts already linked elsewhere" do
linked_account = manual_crypto_exchange_account
available_account = manual_crypto_exchange_account
kraken_account = @second_item.kraken_accounts.create!(name: "Kraken", account_id: "combined", account_type: "combined", currency: "USD")
AccountProvider.create!(account: linked_account, provider: kraken_account)
assert_no_difference "AccountProvider.count" do
post link_existing_account_kraken_items_url, params: {
kraken_item_id: @second_item.id,
account_id: available_account.id,
kraken_account_id: kraken_account.id
}
end
assert_redirected_to account_path(available_account)
end
test "select existing account renders selected kraken item id" do
account = manual_crypto_exchange_account
@second_item.kraken_accounts.create!(name: "Kraken", account_id: "combined", account_type: "combined", currency: "USD")
get select_existing_account_kraken_items_url, params: {
kraken_item_id: @second_item.id,
account_id: account.id
}
assert_response :success
assert_includes @response.body, %(name="kraken_item_id")
assert_includes @response.body, %(value="#{@second_item.id}")
end
test "cannot access another family's kraken item" do
other_item = KrakenItem.create!(
family: families(:empty),
name: "Other Kraken",
api_key: "other_key",
api_secret: "other_secret"
)
get setup_accounts_kraken_item_url(other_item)
assert_response :not_found
end
private
def manual_crypto_exchange_account
@family.accounts.create!(
name: "Manual Crypto",
balance: 0,
currency: "USD",
accountable: Crypto.create!(subtype: "exchange")
)
end
end

View File

@@ -32,6 +32,27 @@ class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest
end
end
test "shows configured Brex connections in bank sync settings" do
get settings_providers_url
assert_response :success
assert_includes response.body, "Brex"
assert_includes response.body, "Test Brex Connection"
assert_includes response.body, "brex-providers-panel"
end
test "shows Brex as available when family has no Brex connections" do
sign_in users(:empty)
get settings_providers_url
assert_response :success
assert_includes response.body, "Brex"
assert_includes response.body, I18n.t("settings.providers.taglines.brex")
assert_includes response.body, connect_form_settings_providers_path(provider_key: "brex")
refute_includes response.body, "Test Brex Connection"
end
test "correctly identifies declared vs dynamic fields" do
# All current provider fields are dynamic, but the logic should correctly
# distinguish between declared and dynamic fields
@@ -355,6 +376,52 @@ class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest
assert_match(/Sync started/i, response.body)
end
test "POST sync for brex without an active Brex sync enqueues SyncJob" do
item = brex_items(:one)
Sync.where(syncable_type: "BrexItem", syncable_id: item.id).delete_all
assert_enqueued_jobs 1, only: SyncJob do
post sync_provider_settings_providers_path(provider_key: "brex")
end
assert_redirected_to settings_providers_path
follow_redirect!
assert_response :success
assert_match(/Sync started/i, response.body)
end
test "GET show includes Interactive Brokers in bank sync providers" do
get settings_providers_url
assert_response :success
assert_match(/Interactive Brokers/i, response.body)
assert_match(/Flex Query/i, response.body)
end
test "GET connect_form renders Interactive Brokers panel" do
get connect_form_settings_providers_path(provider_key: "ibkr")
assert_response :success
assert_match(/Interactive Brokers/i, response.body)
assert_match(/Query ID/i, response.body)
end
test "POST sync for ibkr without an active Ibkr sync enqueues SyncJob" do
item = ibkr_items(:configured_item)
Sync.where(syncable_type: "IbkrItem", syncable_id: item.id).delete_all
assert_enqueued_jobs 1, only: SyncJob do
post sync_provider_settings_providers_path(provider_key: "ibkr")
end
assert_redirected_to settings_providers_path
follow_redirect!
assert_response :success
assert_match(/Sync started/i, response.body)
end
test "non-admin users cannot update providers" do
with_self_hosting do
sign_in users(:family_member)

View File

@@ -198,4 +198,48 @@ class TransfersControllerTest < ActionDispatch::IntegrationTest
transfer.reload
end
end
test "mark_as_recurring creates a recurring transfer" do
transfer = transfers(:one)
family = users(:family_admin).family
family.recurring_transactions.destroy_all
assert_difference -> { RecurringTransaction.where(family: family).count }, +1 do
post mark_as_recurring_transfer_url(transfer)
end
rt = RecurringTransaction.where(family: family).last
assert rt.transfer?
assert_equal transfer.outflow_transaction.entry.account, rt.account
assert_equal transfer.inflow_transaction.entry.account, rt.destination_account
assert rt.manual?
assert_equal I18n.t("recurring_transactions.transfer_marked_as_recurring"), flash[:notice]
assert_redirected_to transactions_path
end
test "mark_as_recurring is idempotent: second call flashes already-exists" do
transfer = transfers(:one)
family = users(:family_admin).family
family.recurring_transactions.destroy_all
post mark_as_recurring_transfer_url(transfer)
assert_equal I18n.t("recurring_transactions.transfer_marked_as_recurring"), flash[:notice]
assert_no_difference -> { RecurringTransaction.where(family: family).count } do
post mark_as_recurring_transfer_url(transfer)
end
assert_equal I18n.t("recurring_transactions.transfer_already_exists"), flash[:alert]
end
test "mark_as_recurring is rejected when recurring_transactions_disabled" do
transfer = transfers(:one)
family = users(:family_admin).family
family.update!(recurring_transactions_disabled: true)
family.recurring_transactions.destroy_all
assert_no_difference -> { RecurringTransaction.where(family: family).count } do
post mark_as_recurring_transfer_url(transfer)
end
assert_equal I18n.t("recurring_transactions.transfer_feature_disabled"), flash[:alert]
end
end