mirror of
https://github.com/we-promise/sure.git
synced 2026-05-30 07:49:01 +00:00
Merge branch 'main' into feat/savings-goals
Signed-off-by: Guillem Arias Fauste <accounts@gariasf.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
488
test/controllers/brex_items_controller_test.rb
Normal file
488
test/controllers/brex_items_controller_test.rb
Normal 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
|
||||
100
test/controllers/ibkr_items_controller_test.rb
Normal file
100
test/controllers/ibkr_items_controller_test.rb
Normal 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
|
||||
278
test/controllers/kraken_items_controller_test.rb
Normal file
278
test/controllers/kraken_items_controller_test.rb
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user