Merge branch 'main' into feature/retirement-planning

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
Juan José Mata
2026-05-24 12:14:14 +02:00
committed by GitHub
1630 changed files with 98596 additions and 7676 deletions

View File

@@ -0,0 +1,483 @@
require "test_helper"
class AccountStatementsControllerTest < ActionDispatch::IntegrationTest
setup do
ensure_tailwind_build
sign_in @user = users(:family_admin)
@account = accounts(:depository)
end
test "shows statement vault" do
get account_statements_url
assert_response :success
assert_select "h1", text: I18n.t("account_statements.index.title")
end
test "statement vault only lists linked statements for accessible accounts" do
accessible_statement = AccountStatement.create_from_upload!(
family: @account.family,
account: @account,
file: uploaded_file(filename: "accessible_statement.csv", content_type: "text/csv", content: "date,amount\n2024-01-01,1\n")
)
private_account = accounts(:other_asset)
private_statement = AccountStatement.create_from_upload!(
family: private_account.family,
account: private_account,
file: uploaded_file(filename: "private_statement.csv", content_type: "text/csv", content: "date,amount\n2024-01-02,2\n")
)
sign_in users(:family_member)
get account_statements_url
assert_response :success
assert_includes response.body, accessible_statement.filename
refute_includes response.body, private_statement.filename
refute_includes response.body, private_account.name
end
test "non manager cannot open statement vault" do
sign_in family_guest
get account_statements_url
assert_redirected_to accounts_url
assert_equal I18n.t("accounts.not_authorized"), flash[:alert]
end
test "non manager cannot view unmatched statement" do
statement = AccountStatement.create_from_upload!(
family: @account.family,
account: nil,
file: uploaded_file(filename: "statement.csv", content_type: "text/csv")
)
sign_in family_guest
get account_statement_url(statement)
assert_response :not_found
end
test "uploads statement to account without importing transactions" do
assert_difference "AccountStatement.count", 1 do
assert_no_difference [ "Import.count", "Entry.count", "Transaction.count" ] do
post account_statements_url, params: {
account_statement: {
account_id: @account.id,
files: [ uploaded_file(filename: "Checking_2024-01.csv", content_type: "text/csv") ]
}
}
end
end
statement = AccountStatement.order(:created_at).last
assert_equal @account, statement.account
assert statement.linked?
assert_redirected_to account_url(@account, tab: "statements")
end
test "member with writable account access can upload linked statement" do
sign_in users(:family_member)
assert_difference "AccountStatement.count", 1 do
post account_statements_url, params: {
account_statement: {
account_id: @account.id,
files: [ uploaded_file(filename: "member_statement.csv", content_type: "text/csv") ]
}
}
end
statement = AccountStatement.order(:created_at).last
assert_equal @account, statement.account
assert_redirected_to account_url(@account, tab: "statements")
end
test "uploads unmatched statement to inbox" do
assert_difference "AccountStatement.count", 1 do
post account_statements_url, params: {
account_statement: {
files: [ uploaded_file(filename: "Unknown_2024-01.csv", content_type: "text/csv") ]
}
}
end
statement = AccountStatement.order(:created_at).last
assert_nil statement.account
assert statement.unmatched?
assert_redirected_to account_statement_url(statement)
end
test "skips duplicate statement upload" do
AccountStatement.create_from_upload!(
family: @account.family,
account: @account,
file: uploaded_file(filename: "statement.csv", content_type: "text/csv", content: "date,amount\n2024-01-01,1\n")
)
assert_no_difference "AccountStatement.count" do
post account_statements_url, params: {
account_statement: {
account_id: @account.id,
files: [ uploaded_file(filename: "duplicate.csv", content_type: "text/csv", content: "date,amount\n2024-01-01,1\n") ]
}
}
end
assert_redirected_to account_url(@account, tab: "statements")
assert_equal I18n.t("account_statements.create.duplicates", count: 1), flash[:alert]
end
test "continues upload loop after a validation error" do
invalid_record = AccountStatement.new
invalid_record.errors.add(:filename, "is invalid")
assert_difference "AccountStatement.count", 1 do
created_statement = AccountStatement.create_from_upload!(
family: @account.family,
account: @account,
file: uploaded_file(filename: "valid-result.csv", content_type: "text/csv", content: "date,amount\n2024-01-02,2\n")
)
upload_sequence = sequence("statement upload processing")
AccountStatement.expects(:create_from_prepared_upload!).in_sequence(upload_sequence).raises(ActiveRecord::RecordInvalid.new(invalid_record))
AccountStatement.expects(:create_from_prepared_upload!).in_sequence(upload_sequence).returns(created_statement)
post account_statements_url, params: {
account_statement: {
account_id: @account.id,
files: [
uploaded_file(filename: "invalid.csv", content_type: "text/csv", content: "date,amount\n2024-01-01,1\n"),
uploaded_file(filename: "valid.csv", content_type: "text/csv", content: "date,amount\n2024-01-02,2\n")
]
}
}
end
assert_redirected_to account_url(@account, tab: "statements")
assert_equal I18n.t("account_statements.create.success", count: 1), flash[:notice]
assert_includes flash[:alert], invalid_record.errors.full_messages.to_sentence
end
test "rejects invalid statement file type" do
assert_no_difference "AccountStatement.count" do
post account_statements_url, params: {
account_statement: {
files: [ uploaded_file(filename: "statement.bin", content_type: "application/octet-stream", content: "\x00\x01\x02".b) ]
}
}
end
assert_redirected_to account_statements_url
assert_equal I18n.t("account_statements.create.invalid_file_type"), flash[:alert]
end
test "continues upload loop after an invalid file type" do
assert_difference "AccountStatement.count", 1 do
post account_statements_url, params: {
account_statement: {
files: [
uploaded_file(filename: "statement.bin", content_type: "application/octet-stream", content: "\x00\x01\x02".b),
uploaded_file(filename: "valid.csv", content_type: "text/csv", content: "date,amount\n2024-01-02,2\n")
]
}
}
end
statement = AccountStatement.order(:created_at).last
assert_redirected_to account_statement_url(statement)
assert_equal I18n.t("account_statements.create.success", count: 1), flash[:notice]
assert_includes flash[:alert], I18n.t("account_statements.create.invalid_file_type")
end
test "rejects txt and xls statement uploads" do
[
uploaded_file(filename: "statement.txt", content_type: "text/plain"),
uploaded_file(filename: "statement.xls", content_type: "application/vnd.ms-excel")
].each do |file|
assert_no_difference "AccountStatement.count" do
post account_statements_url, params: {
account_statement: {
files: [ file ]
}
}
end
assert_redirected_to account_statements_url
assert_equal I18n.t("account_statements.create.invalid_file_type"), flash[:alert]
end
end
test "rejects empty csv and xlsx statement uploads" do
[
uploaded_file(filename: "empty.csv", content_type: "text/csv", content: ""),
uploaded_file(
filename: "empty.xlsx",
content_type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
content: ""
)
].each do |file|
assert_no_difference "AccountStatement.count" do
post account_statements_url, params: {
account_statement: {
files: [ file ]
}
}
end
assert_redirected_to account_statements_url
assert_equal I18n.t("account_statements.create.invalid_file_type"), flash[:alert]
end
end
test "rejects oversized statement upload" do
original_max_file_size = AccountStatement::MAX_FILE_SIZE
silence_warnings { AccountStatement.const_set(:MAX_FILE_SIZE, 16) }
begin
assert_no_difference "AccountStatement.count" do
post account_statements_url, params: {
account_statement: {
files: [
uploaded_file(
filename: "oversized.csv",
content_type: "text/csv",
content: "x" * (AccountStatement::MAX_FILE_SIZE + 1)
)
]
}
}
end
ensure
silence_warnings { AccountStatement.const_set(:MAX_FILE_SIZE, original_max_file_size) }
end
assert_redirected_to account_statements_url
assert_equal I18n.t("account_statements.create.invalid_file_type"), flash[:alert]
end
test "rejects cross-family account id" do
other_account = Account.create!(
family: families(:empty),
owner: users(:empty),
name: "Other family account",
balance: 0,
currency: "USD",
accountable: Depository.new
)
assert_no_difference "AccountStatement.count" do
post account_statements_url, params: {
account_statement: {
account_id: other_account.id,
files: [ uploaded_file(filename: "statement.csv", content_type: "text/csv") ]
}
}
end
assert_response :not_found
end
test "read only shared user cannot upload to account" do
sign_in users(:family_member)
account = accounts(:credit_card)
assert_no_difference "AccountStatement.count" do
post account_statements_url, params: {
account_statement: {
account_id: account.id,
files: [ uploaded_file(filename: "statement.csv", content_type: "text/csv") ]
}
}
end
assert_redirected_to account_url(account)
assert_equal I18n.t("accounts.not_authorized"), flash[:alert]
end
test "read only shared user sees statement detail without edit controls" do
account = accounts(:credit_card)
statement = AccountStatement.create_from_upload!(
family: @account.family,
account: account,
file: uploaded_file(filename: "readonly_statement.csv", content_type: "text/csv")
)
sign_in users(:family_member)
get account_statement_url(statement)
assert_response :success
assert_select "input[name='account_statement[period_start_on]']", 0
assert_select "select[name='account_statement[account_id]']", 0
assert_select "button", text: I18n.t("account_statements.show.delete"), count: 0
assert_select "button", text: I18n.t("account_statements.show.save"), count: 0
assert_select "button", text: I18n.t("account_statements.show.unlink"), count: 0
end
test "metadata form does not expose account select for managers" do
statement = AccountStatement.create_from_upload!(
family: @account.family,
account: @account,
file: uploaded_file(filename: "manager_statement.csv", content_type: "text/csv")
)
get account_statement_url(statement)
assert_response :success
assert_select "input[name='account_statement[period_start_on]']", 1
assert_select "input[name='account_statement[currency]']", 0
assert_select "select[name='account_statement[currency]'] option[value='USD']"
assert_select "select[name='account_statement[account_id]']", 0
end
test "links suggested statement" do
statement = AccountStatement.create_from_upload!(
family: @account.family,
account: nil,
file: uploaded_file(filename: "statement.csv", content_type: "text/csv", content: "date,amount\n2024-01-01,1\n")
)
statement.update!(suggested_account: @account, match_confidence: 0.9)
patch link_account_statement_url(statement), params: { account_id: @account.id }
assert_redirected_to account_url(@account, tab: "statements")
statement.reload
assert_equal @account, statement.account
assert statement.linked?
end
test "read only shared user cannot relink linked statement to writable account" do
source_account = accounts(:credit_card)
target_account = accounts(:depository)
statement = AccountStatement.create_from_upload!(
family: source_account.family,
account: source_account,
file: uploaded_file(filename: "readonly_relink.csv", content_type: "text/csv", content: "date,amount\n2024-01-01,1\n")
)
sign_in users(:family_member)
patch link_account_statement_url(statement), params: { account_id: target_account.id }
assert_redirected_to account_url(source_account)
assert_equal I18n.t("accounts.not_authorized"), flash[:alert]
assert_equal source_account, statement.reload.account
end
test "link shows friendly error when no target account is available" do
statement = AccountStatement.create_from_upload!(
family: @account.family,
account: nil,
file: uploaded_file(filename: "statement.csv", content_type: "text/csv", content: "date,amount\n2024-01-01,1\n")
)
patch link_account_statement_url(statement)
assert_redirected_to account_statement_url(statement)
assert_equal I18n.t("account_statements.link.no_account"), flash[:alert]
statement.reload
assert_nil statement.account
assert statement.unmatched?
end
test "unlinks statement back to inbox" do
statement = AccountStatement.create_from_upload!(
family: @account.family,
account: @account,
file: uploaded_file(filename: "statement.csv", content_type: "text/csv", content: "date,amount\n2024-01-01,1\n")
)
patch unlink_account_statement_url(statement)
assert_redirected_to account_statement_url(statement)
statement.reload
assert_nil statement.account
assert statement.unmatched?
end
test "rejects suggestion" do
statement = AccountStatement.create_from_upload!(
family: @account.family,
account: nil,
file: uploaded_file(filename: "statement.csv", content_type: "text/csv", content: "date,amount\n2024-01-01,1\n")
)
statement.update!(suggested_account: @account, match_confidence: 0.9)
patch reject_account_statement_url(statement)
assert_redirected_to account_statements_url
statement.reload
assert statement.rejected?
assert_nil statement.suggested_account
end
test "updates metadata" do
statement = AccountStatement.create_from_upload!(
family: @account.family,
account: @account,
file: uploaded_file(filename: "statement.csv", content_type: "text/csv", content: "date,amount\n2024-01-01,1\n")
)
patch account_statement_url(statement), params: {
account_statement: {
period_start_on: "2024-01-01",
period_end_on: "2024-01-31",
closing_balance: "123.45",
currency: "usd"
}
}
assert_redirected_to account_statement_url(statement)
statement.reload
assert_equal Date.new(2024, 1, 31), statement.period_end_on
assert_equal 123.45.to_d, statement.closing_balance
assert_equal "USD", statement.currency
end
test "metadata update links selected account" do
statement = AccountStatement.create_from_upload!(
family: @account.family,
account: nil,
file: uploaded_file(filename: "statement.csv", content_type: "text/csv", content: "date,amount\n2024-01-01,1\n")
)
patch account_statement_url(statement), params: {
account_statement: {
account_id: @account.id,
period_start_on: "2024-01-01",
period_end_on: "2024-01-31"
}
}
assert_redirected_to account_statement_url(statement)
statement.reload
assert_equal @account, statement.account
assert statement.linked?
end
test "deletes statement" do
statement = AccountStatement.create_from_upload!(
family: @account.family,
account: @account,
file: uploaded_file(filename: "statement.csv", content_type: "text/csv", content: "date,amount\n2024-01-01,1\n")
)
assert_difference "AccountStatement.count", -1 do
delete account_statement_url(statement)
end
assert_redirected_to account_url(@account, tab: "statements")
end
test "destroy reports failure when statement cannot be deleted" do
statement = AccountStatement.create_from_upload!(
family: @account.family,
account: @account,
file: uploaded_file(filename: "statement.csv", content_type: "text/csv", content: "date,amount\n2024-01-01,1\n")
)
AccountStatement.any_instance.stubs(:destroy).returns(false)
assert_no_difference "AccountStatement.count" do
delete account_statement_url(statement)
end
assert_redirected_to account_url(@account, tab: "statements")
assert_equal I18n.t("account_statements.destroy.failure"), flash[:alert]
end
end

View File

@@ -1,6 +1,8 @@
require "test_helper"
class AccountsControllerTest < ActionDispatch::IntegrationTest
include ActionView::RecordIdentifier
setup do
sign_in @user = users(:family_admin)
@account = accounts(:depository)
@@ -9,6 +11,7 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
test "should get index" do
get accounts_url
assert_response :success
assert_select "p.ml-auto.privacy-sensitive"
end
test "should get show" do
@@ -16,6 +19,93 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
assert_response :success
end
test "show lazily loads statement tab data unless statements tab is active" do
AccountStatement::Coverage.expects(:for_year).never
AccountStatement.expects(:reconciliation_statuses_for).never
get account_url(@account)
assert_response :success
assert_select "select[name='statement_year']", count: 0
statements_path = account_path(@account, tab: "statements")
assert_select "turbo-frame[src='#{statements_path}']"
end
test "statements tab shows coverage and upload for statement managers with account write access" do
get account_url(@account, tab: "statements")
assert_response :success
assert_select "input[type=file][accept='.pdf,.csv,.xlsx']"
assert_select "select[name='statement_year']"
assert_select "p", text: I18n.l(Date.current.prev_month.beginning_of_month, format: "%b %Y")
end
test "statements tab lazy frame returns matching frame content" do
frame_id = dom_id(@account, :statements_tab)
get account_url(@account, tab: "statements"), headers: { "Turbo-Frame" => frame_id }
assert_response :success
assert_select "turbo-frame##{frame_id}", count: 1
assert_select "select[name='statement_year']"
assert_select "turbo-frame##{dom_id(@account, :container)}", count: 0
end
test "statements tab filters historical coverage by year" do
account = Account.create!(
family: @user.family,
owner: @user,
name: "Historical Checking",
balance: 0,
currency: "USD",
accountable: Depository.new
)
statement = AccountStatement.create_from_upload!(
family: @user.family,
account: account,
file: uploaded_file(filename: "historical.csv", content_type: "text/csv")
)
statement.update!(period_start_on: Date.new(2024, 2, 1), period_end_on: Date.new(2024, 2, 29))
travel_to Date.new(2026, 5, 6) do
get account_url(account, tab: "statements")
assert_response :success
assert_select "select[name='statement_year'] option[selected='selected']", text: "2026"
assert_select "p", text: "May 2026"
assert_select "p", text: "Not expected"
get account_url(account, tab: "statements", statement_year: 2024)
assert_response :success
assert_select "select[name='statement_year'] option[selected='selected']", text: "2024"
assert_select "p", text: "Jan 2024"
assert_select "p", text: "Feb 2024"
assert_select "p", text: "Covered"
assert_select "p", text: "Missing"
assert_select "p", text: "Not expected"
end
end
test "statements tab hides upload for read only account access" do
sign_in users(:family_member)
get account_url(accounts(:credit_card), tab: "statements")
assert_response :success
assert_select "input[type=file]", count: 0
end
test "account activity marks trade amounts as privacy-sensitive" do
trade_entry = entries(:trade)
expected_amount = ApplicationController.helpers.format_money(-trade_entry.amount_money)
get account_url(accounts(:investment))
assert_response :success
assert_select "turbo-frame##{dom_id(trade_entry)} p.privacy-sensitive", text: expected_amount, count: 1
end
test "activity pagination keeps activity tab when loaded from holdings tab" do
investment = accounts(:investment)
@@ -37,6 +127,31 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
assert_select "a[href*='page=2'][href*='tab=holdings']", count: 0
end
test "account activity constrains long category labels before the amount on wide screens" do
category = categories(:food_and_drink)
category.update!(name: "Super Long Category Name That Should Stop Before The Amount On Wide Screens Too")
entry = @account.entries.create!(
name: "Wide category verification",
date: Date.current,
amount: 187.65,
currency: @account.currency,
entryable: Transaction.new(category: category)
)
get account_url(@account, tab: "activity")
assert_response :success
assert_select "##{dom_id(entry.entryable, "category_menu_desktop")}"
assert_select "##{dom_id(entry.entryable, "category_menu_desktop")}.min-w-0"
assert_select "##{dom_id(entry.entryable, "category_menu_desktop")}.overflow-hidden"
assert_select "##{dom_id(entry.entryable, "category_menu_desktop")} button.block"
assert_select "##{dom_id(entry.entryable, "category_menu_desktop")} button.w-full"
assert_select "##{dom_id(entry.entryable, "category_menu_desktop")} button.overflow-hidden"
assert_select "##{dom_id(entry.entryable, "category_menu_desktop")} [data-testid='category-name']"
assert_select "div.hidden.md\\:flex.min-w-0"
end
test "should sync account" do
post sync_account_url(@account)
assert_redirected_to account_url(@account)

View File

@@ -8,10 +8,22 @@ class Api::V1::AccountsControllerTest < ActionDispatch::IntegrationTest
@other_family_user = users(:family_member)
@other_family_user.update!(family: families(:empty))
@oauth_app = Doorkeeper::Application.create!(
name: "Test API App",
redirect_uri: "https://example.com/callback",
scopes: "read read_write"
@user.api_keys.active.destroy_all
@api_key = ApiKey.create!(
user: @user,
name: "Test Read Key",
scopes: [ "read" ],
source: "web",
display_key: "test_read_#{SecureRandom.hex(8)}"
)
@other_family_user.api_keys.active.destroy_all
@other_family_api_key = ApiKey.create!(
user: @other_family_user,
name: "Other Family Read Key",
scopes: [ "read" ],
source: "web",
display_key: "other_family_read_#{SecureRandom.hex(8)}"
)
end
@@ -24,37 +36,28 @@ class Api::V1::AccountsControllerTest < ActionDispatch::IntegrationTest
end
test "should require read_accounts scope" do
# TODO: Re-enable this test after fixing scope checking
skip "Scope checking temporarily disabled - needs configuration fix"
api_key_without_read = ApiKey.new(
user: @user,
name: "No Read Key",
scopes: [],
source: "web",
display_key: "no_read_#{SecureRandom.hex(8)}"
)
# Valid persisted API keys can only be read/read_write; this intentionally
# bypasses validations to exercise the runtime insufficient-scope guard.
api_key_without_read.save!(validate: false)
# Create token with wrong scope - using a non-existent scope to test rejection
access_token = Doorkeeper::AccessToken.create!(
application: @oauth_app,
resource_owner_id: @user.id,
scopes: "invalid_scope" # Wrong scope
)
get "/api/v1/accounts", params: {}, headers: api_headers(api_key_without_read)
get "/api/v1/accounts", params: {}, headers: {
"Authorization" => "Bearer #{access_token.token}"
}
assert_response :forbidden
# Doorkeeper returns a standard OAuth error response
response_body = JSON.parse(response.body)
assert_equal "insufficient_scope", response_body["error"]
end
assert_response :forbidden
response_body = JSON.parse(response.body)
assert_equal "insufficient_scope", response_body["error"]
ensure
api_key_without_read&.destroy
end
test "should return user's family accounts successfully" do
access_token = Doorkeeper::AccessToken.create!(
application: @oauth_app,
resource_owner_id: @user.id,
scopes: "read"
)
get "/api/v1/accounts", params: {}, headers: {
"Authorization" => "Bearer #{access_token.token}"
}
get "/api/v1/accounts", params: {}, headers: api_headers(@api_key)
assert_response :success
response_body = JSON.parse(response.body)
@@ -83,15 +86,7 @@ end
inactive_account = accounts(:depository)
inactive_account.disable!
access_token = Doorkeeper::AccessToken.create!(
application: @oauth_app,
resource_owner_id: @user.id,
scopes: "read"
)
get "/api/v1/accounts", params: {}, headers: {
"Authorization" => "Bearer #{access_token.token}"
}
get "/api/v1/accounts", params: {}, headers: api_headers(@api_key)
assert_response :success
response_body = JSON.parse(response.body)
@@ -101,17 +96,140 @@ end
assert_not_includes account_names, inactive_account.name
end
test "should not return other family's accounts" do
access_token = Doorkeeper::AccessToken.create!(
application: @oauth_app,
resource_owner_id: @other_family_user.id, # User from different family
scopes: "read"
)
test "should include disabled accounts when requested" do
inactive_account = accounts(:depository)
inactive_account.disable!
get "/api/v1/accounts", params: {}, headers: {
"Authorization" => "Bearer #{access_token.token}"
get "/api/v1/accounts", params: { include_disabled: true }, headers: api_headers(@api_key)
assert_response :success
response_body = JSON.parse(response.body)
account = response_body["accounts"].find { |account_data| account_data["id"] == inactive_account.id }
assert_not_nil account
assert_equal "disabled", account["status"]
end
test "should show active account" do
account = accounts(:depository)
get "/api/v1/accounts/#{account.id}", headers: api_headers(@api_key)
assert_response :success
response_body = JSON.parse(response.body)
assert_equal account.id, response_body["id"]
assert_equal account.status, response_body["status"]
assert_equal account.balance_money.format, response_body["balance"]
assert_equal money_cents(account.balance_money), response_body["balance_cents"]
assert_equal account.cash_balance_money.format, response_body["cash_balance"]
assert_equal money_cents(account.cash_balance_money), response_body["cash_balance_cents"]
assert_nullable_equal account.subtype, response_body["subtype"]
assert response_body.key?("institution_name")
assert response_body.key?("institution_domain")
assert_nullable_equal account.institution_name, response_body["institution_name"]
assert_nullable_equal account.institution_domain, response_body["institution_domain"]
assert_equal account.created_at.iso8601, response_body["created_at"]
assert_equal account.updated_at.iso8601, response_body["updated_at"]
end
test "should return 404 for unknown account on show" do
get "/api/v1/accounts/#{SecureRandom.uuid}", headers: api_headers(@api_key)
assert_response :not_found
response_body = JSON.parse(response.body)
assert_equal "not_found", response_body["error"]
end
test "should return 404 for malformed account id on show" do
get "/api/v1/accounts/not-a-uuid", headers: api_headers(@api_key)
assert_response :not_found
response_body = JSON.parse(response.body)
assert_equal "not_found", response_body["error"]
assert_equal "Account not found", response_body["message"]
end
test "should require authentication on show" do
account = accounts(:depository)
get "/api/v1/accounts/#{account.id}"
assert_response :unauthorized
response_body = JSON.parse(response.body)
assert_equal "unauthorized", response_body["error"]
end
test "should require read scope on show" do
account = accounts(:depository)
api_key_without_read = ApiKey.new(
user: @user,
name: "No Read Show Key",
scopes: [],
source: "web",
display_key: "no_read_show_#{SecureRandom.hex(8)}"
)
# Valid persisted API keys can only be read/read_write; this intentionally
# bypasses validations to exercise the runtime insufficient-scope guard.
api_key_without_read.save!(validate: false)
get "/api/v1/accounts/#{account.id}", headers: api_headers(api_key_without_read)
assert_response :forbidden
response_body = JSON.parse(response.body)
assert_equal "insufficient_scope", response_body["error"]
ensure
api_key_without_read&.destroy
end
test "should hide disabled account by default on show" do
inactive_account = accounts(:depository)
inactive_account.disable!
get "/api/v1/accounts/#{inactive_account.id}", headers: api_headers(@api_key)
assert_response :not_found
end
test "should show disabled account when requested" do
inactive_account = accounts(:depository)
inactive_account.disable!
get "/api/v1/accounts/#{inactive_account.id}",
params: { include_disabled: true },
headers: api_headers(@api_key)
assert_response :success
response_body = JSON.parse(response.body)
assert_equal inactive_account.id, response_body["id"]
assert_equal "disabled", response_body["status"]
end
test "should expose subtype across account types" do
expected_subtypes = {
accounts(:depository) => "checking",
accounts(:credit_card) => "credit_card",
accounts(:investment) => "brokerage",
accounts(:loan) => "mortgage",
accounts(:property) => "single_family_home",
accounts(:vehicle) => "sedan",
accounts(:crypto) => "exchange",
accounts(:other_asset) => "collectible",
accounts(:other_liability) => "personal_debt"
}
expected_subtypes.each { |account, subtype| account.accountable.update!(subtype: subtype) }
expected_subtypes.each do |account, subtype|
get "/api/v1/accounts/#{account.id}", headers: api_headers(@api_key)
assert_response :success
assert_equal subtype, JSON.parse(response.body)["subtype"]
end
end
test "should not return other family's accounts" do
get "/api/v1/accounts", params: {}, headers: api_headers(@other_family_api_key)
assert_response :success
response_body = JSON.parse(response.body)
@@ -121,16 +239,8 @@ end
end
test "should handle pagination parameters" do
access_token = Doorkeeper::AccessToken.create!(
application: @oauth_app,
resource_owner_id: @user.id,
scopes: "read"
)
# Test with pagination params
get "/api/v1/accounts", params: { page: 1, per_page: 2 }, headers: {
"Authorization" => "Bearer #{access_token.token}"
}
get "/api/v1/accounts", params: { page: 1, per_page: 2 }, headers: api_headers(@api_key)
assert_response :success
response_body = JSON.parse(response.body)
@@ -142,15 +252,7 @@ end
end
test "should return proper account data structure" do
access_token = Doorkeeper::AccessToken.create!(
application: @oauth_app,
resource_owner_id: @user.id,
scopes: "read"
)
get "/api/v1/accounts", params: {}, headers: {
"Authorization" => "Bearer #{access_token.token}"
}
get "/api/v1/accounts", params: {}, headers: api_headers(@api_key)
assert_response :success
response_body = JSON.parse(response.body)
@@ -161,7 +263,7 @@ end
account = response_body["accounts"].first
# Check required fields are present
required_fields = %w[id name balance currency classification account_type]
required_fields = %w[id name balance balance_cents cash_balance cash_balance_cents currency classification account_type]
required_fields.each do |field|
assert account.key?(field), "Account should have #{field} field"
end
@@ -170,21 +272,15 @@ end
assert account["id"].is_a?(String), "ID should be string (UUID)"
assert account["name"].is_a?(String), "Name should be string"
assert account["balance"].is_a?(String), "Balance should be string (money)"
assert account["balance_cents"].is_a?(Integer), "Balance cents should be integer"
assert account["cash_balance_cents"].is_a?(Integer), "Cash balance cents should be integer"
assert account["currency"].is_a?(String), "Currency should be string"
assert %w[asset liability].include?(account["classification"]), "Classification should be asset or liability"
end
test "should handle invalid pagination parameters gracefully" do
access_token = Doorkeeper::AccessToken.create!(
application: @oauth_app,
resource_owner_id: @user.id,
scopes: "read"
)
# Test with invalid page number
get "/api/v1/accounts", params: { page: -1, per_page: "invalid" }, headers: {
"Authorization" => "Bearer #{access_token.token}"
}
get "/api/v1/accounts", params: { page: -1, per_page: "invalid" }, headers: api_headers(@api_key)
# Should still return success with default pagination
assert_response :success
@@ -197,15 +293,7 @@ end
end
test "should sort accounts alphabetically" do
access_token = Doorkeeper::AccessToken.create!(
application: @oauth_app,
resource_owner_id: @user.id,
scopes: "read"
)
get "/api/v1/accounts", params: {}, headers: {
"Authorization" => "Bearer #{access_token.token}"
}
get "/api/v1/accounts", params: {}, headers: api_headers(@api_key)
assert_response :success
response_body = JSON.parse(response.body)
@@ -214,4 +302,18 @@ end
account_names = response_body["accounts"].map { |a| a["name"] }
assert_equal account_names.sort, account_names
end
private
def api_headers(api_key)
{ "X-Api-Key" => api_key.plain_key }
end
def money_cents(money)
(money.amount * money.currency.minor_unit_conversion).round(0).to_i
end
def assert_nullable_equal(expected, actual)
expected.nil? ? assert_nil(actual) : assert_equal(expected, actual)
end
end

View File

@@ -94,6 +94,25 @@ class Api::V1::AuthControllerTest < ActionDispatch::IntegrationTest
assert_equal "Device information is required", response_data["error"]
end
test "should reject signup with invalid device_type before committing any state" do
# Pre-validation catches bad device_type and returns 400 without creating
# user/family/device/token. Guards against a partial-commit state where the
# account exists but the mobile session handoff fails.
assert_no_difference([ "User.count", "MobileDevice.count", "Doorkeeper::AccessToken.count" ]) do
post "/api/v1/auth/signup", params: {
user: {
email: "newuser@example.com",
password: "SecurePass123!",
first_name: "New",
last_name: "User"
},
device: @device_info.merge(device_type: "windows") # not in allowlist
}
end
assert_response :bad_request
end
test "should not signup with invalid password" do
assert_no_difference("User.count") do
post "/api/v1/auth/signup", params: {

View File

@@ -0,0 +1,197 @@
# frozen_string_literal: true
require "test_helper"
class Api::V1::BalancesControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:family_admin)
@family = @user.family
@user.api_keys.active.destroy_all
@api_key = ApiKey.create!(
user: @user,
name: "Test Read Key",
scopes: [ "read" ],
source: "web",
display_key: "test_read_#{SecureRandom.hex(8)}"
)
@account = @family.accounts.create!(
name: "Balance Checking",
accountable: Depository.new,
balance: 1234.56,
currency: "USD"
)
@balance = @account.balances.create!(
date: Date.parse("2024-01-15"),
balance: 1234.56,
cash_balance: 1234.56,
start_cash_balance: 1000,
start_non_cash_balance: 0,
cash_inflows: 234.56,
cash_outflows: 0,
currency: "USD"
)
other_family = families(:empty)
other_account = other_family.accounts.create!(
name: "Other Balance Checking",
accountable: Depository.new,
balance: 500,
currency: "USD"
)
@other_balance = other_account.balances.create!(
date: Date.parse("2024-01-15"),
balance: 500,
cash_balance: 500,
currency: "USD"
)
end
test "lists balances scoped to accessible family accounts" do
get api_v1_balances_url, headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert response_data.key?("balances")
assert response_data.key?("pagination")
assert_includes response_data["balances"].map { |balance| balance["id"] }, @balance.id
assert_not_includes response_data["balances"].map { |balance| balance["id"] }, @other_balance.id
end
test "shows a balance" do
get api_v1_balance_url(@balance), headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_equal @balance.id, response_data["id"]
assert_equal "2024-01-15", response_data["date"]
assert_equal @account.id, response_data.dig("account", "id")
assert_kind_of Integer, response_data["balance_cents"]
assert_kind_of Integer, response_data["end_balance_cents"]
end
test "renders nullable cash balance fields" do
balance_without_cash = @account.balances.create!(
date: Date.parse("2024-01-16"),
balance: 1234.56,
currency: "USD"
)
balance_without_cash.update_column(:cash_balance, nil)
get api_v1_balance_url(balance_without_cash), headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_nil response_data["cash_balance"]
assert_nil response_data["cash_balance_cents"]
end
test "renders nullable account type" do
@account.update_columns(accountable_type: nil, accountable_id: nil)
get api_v1_balance_url(@balance), headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_nil response_data.dig("account", "account_type")
end
test "returns not found for another family's balance" do
get api_v1_balance_url(@other_balance), headers: api_headers(@api_key)
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "record_not_found", response_data["error"]
end
test "returns not found for malformed balance id" do
get api_v1_balance_url("not-a-uuid"), headers: api_headers(@api_key)
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "record_not_found", response_data["error"]
end
test "filters balances by account_id" do
get api_v1_balances_url,
params: { account_id: @account.id },
headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_includes response_data["balances"].map { |balance| balance["id"] }, @balance.id
end
test "filters balances by currency" do
eur_balance = @account.balances.create!(
date: Date.parse("2024-01-16"),
balance: 100,
currency: "EUR"
)
get api_v1_balances_url,
params: { currency: "usd" },
headers: api_headers(@api_key)
assert_response :success
balance_ids = JSON.parse(response.body)["balances"].map { |balance| balance["id"] }
assert_includes balance_ids, @balance.id
assert_not_includes balance_ids, eur_balance.id
end
test "filters balances by date range" do
get api_v1_balances_url,
params: { start_date: "2024-01-15", end_date: "2024-01-15" },
headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_includes response_data["balances"].map { |balance| balance["id"] }, @balance.id
end
test "rejects malformed account_id filter" do
get api_v1_balances_url, params: { account_id: "not-a-uuid" }, headers: api_headers(@api_key)
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
end
test "rejects invalid date filters" do
get api_v1_balances_url, params: { start_date: "01/15/2024" }, headers: api_headers(@api_key)
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
end
test "requires authentication" do
get api_v1_balances_url
assert_response :unauthorized
end
test "requires read scope" do
api_key_without_read = ApiKey.new(
user: @user,
name: "No Read Key",
scopes: [],
source: "mobile",
display_key: "no_read_#{SecureRandom.hex(8)}"
)
api_key_without_read.save!(validate: false)
get api_v1_balances_url, headers: api_headers(api_key_without_read)
assert_response :forbidden
ensure
api_key_without_read&.destroy
end
private
def api_headers(api_key)
{ "X-Api-Key" => api_key.display_key }
end
end

View File

@@ -60,6 +60,23 @@ class Api::V1::BaseControllerTest < ActionDispatch::IntegrationTest
assert_equal @user.email, response_body["user"]
end
test "should reject revoked access token" do
access_token = Doorkeeper::AccessToken.create!(
application: @oauth_app,
resource_owner_id: @user.id,
scopes: "read"
)
access_token.revoke
get "/api/v1/test", params: {}, headers: {
"Authorization" => "Bearer #{access_token.token}"
}
assert_response :unauthorized
response_body = JSON.parse(response.body)
assert_equal "unauthorized", response_body["error"]
end
test "should reject invalid access token" do
get "/api/v1/test", params: {}, headers: {
"Authorization" => "Bearer invalid_token"

View File

@@ -0,0 +1,154 @@
# frozen_string_literal: true
require "test_helper"
class Api::V1::BudgetCategoriesControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:family_admin)
@family = @user.family
@user.api_keys.active.destroy_all
@api_key = ApiKey.create!(
user: @user,
name: "Test Read Key",
scopes: [ "read" ],
source: "web",
display_key: "test_read_#{SecureRandom.hex(8)}"
)
@budget = @family.budgets.create!(
start_date: 5.months.ago.beginning_of_month.to_date,
end_date: 5.months.ago.end_of_month.to_date,
budgeted_spending: 3000,
expected_income: 5000,
currency: "USD"
)
@category = categories(:food_and_drink)
@budget_category = @budget.budget_categories.create!(
category: @category,
budgeted_spending: 500,
currency: "USD"
)
other_family = families(:empty)
other_category = other_family.categories.create!(name: "Other Food", color: "#123456")
other_budget = other_family.budgets.create!(
start_date: 6.months.ago.beginning_of_month.to_date,
end_date: 6.months.ago.end_of_month.to_date,
budgeted_spending: 1000,
expected_income: 2000,
currency: "USD"
)
@other_budget_category = other_budget.budget_categories.create!(
category: other_category,
budgeted_spending: 100,
currency: "USD"
)
end
test "lists budget categories scoped to the current family" do
get api_v1_budget_categories_url, headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert response_data.key?("budget_categories")
assert response_data.key?("pagination")
assert_includes response_data["budget_categories"].map { |budget_category| budget_category["id"] }, @budget_category.id
assert_not_includes response_data["budget_categories"].map { |budget_category| budget_category["id"] }, @other_budget_category.id
budget_category = response_data["budget_categories"].find { |category| category["id"] == @budget_category.id }
assert_kind_of Integer, budget_category["budgeted_spending_cents"]
assert_not budget_category.key?("actual_spending")
assert_not budget_category.key?("actual_spending_cents")
assert_not budget_category.key?("available_to_spend")
assert_not budget_category.key?("available_to_spend_cents")
end
test "shows a budget category" do
get api_v1_budget_category_url(@budget_category), headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_equal @budget_category.id, response_data["id"]
assert_equal @budget.id, response_data["budget_id"]
assert_equal @category.id, response_data.dig("category", "id")
assert_kind_of Integer, response_data["budgeted_spending_cents"]
assert_kind_of Integer, response_data["actual_spending_cents"]
assert_kind_of Integer, response_data["available_to_spend_cents"]
end
test "returns not found for another family's budget category" do
get api_v1_budget_category_url(@other_budget_category), headers: api_headers(@api_key)
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "record_not_found", response_data["error"]
end
test "returns not found for malformed budget category id" do
get api_v1_budget_category_url("not-a-uuid"), headers: api_headers(@api_key)
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "record_not_found", response_data["error"]
end
test "filters budget categories by budget_id" do
get api_v1_budget_categories_url,
params: { budget_id: @budget.id },
headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_includes response_data["budget_categories"].map { |budget_category| budget_category["id"] }, @budget_category.id
end
test "filters budget categories by category_id" do
get api_v1_budget_categories_url,
params: { category_id: @category.id },
headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_includes response_data["budget_categories"].map { |budget_category| budget_category["id"] }, @budget_category.id
end
test "rejects malformed budget_id filter" do
get api_v1_budget_categories_url, params: { budget_id: "not-a-uuid" }, headers: api_headers(@api_key)
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
end
test "rejects invalid date filters" do
get api_v1_budget_categories_url, params: { start_date: "03/01/2024" }, headers: api_headers(@api_key)
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
end
test "requires authentication" do
get api_v1_budget_categories_url
assert_response :unauthorized
end
test "requires read scope" do
api_key_without_read = ApiKey.new(
user: @user,
name: "No Read Key",
scopes: [],
source: "mobile",
display_key: "no_read_#{SecureRandom.hex(8)}"
)
api_key_without_read.save!(validate: false)
get api_v1_budget_categories_url, headers: api_headers(api_key_without_read)
assert_response :forbidden
ensure
api_key_without_read&.destroy
end
end

View File

@@ -0,0 +1,141 @@
# frozen_string_literal: true
require "test_helper"
class Api::V1::BudgetsControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:family_admin)
@family = @user.family
@user.api_keys.active.destroy_all
@api_key = ApiKey.create!(
user: @user,
name: "Test Read Key",
scopes: [ "read" ],
source: "web",
display_key: "test_read_#{SecureRandom.hex(8)}"
)
@budget = @family.budgets.create!(
start_date: 3.months.ago.beginning_of_month.to_date,
end_date: 3.months.ago.end_of_month.to_date,
budgeted_spending: 3000,
expected_income: 5000,
currency: "USD"
)
category = categories(:food_and_drink)
@budget_category = @budget.budget_categories.create!(
category: category,
budgeted_spending: 500,
currency: "USD"
)
other_family = families(:empty)
@other_budget = other_family.budgets.create!(
start_date: 4.months.ago.beginning_of_month.to_date,
end_date: 4.months.ago.end_of_month.to_date,
budgeted_spending: 1000,
expected_income: 2000,
currency: "USD"
)
end
test "lists budgets scoped to the current family" do
get api_v1_budgets_url, headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert response_data.key?("budgets")
assert response_data.key?("pagination")
assert_includes response_data["budgets"].map { |budget| budget["id"] }, @budget.id
assert_not_includes response_data["budgets"].map { |budget| budget["id"] }, @other_budget.id
budget_response = response_data["budgets"].find { |budget| budget["id"] == @budget.id }
%w[
actual_spending
actual_spending_cents
actual_income
actual_income_cents
available_to_spend
available_to_spend_cents
available_to_allocate
available_to_allocate_cents
].each do |derived_field|
assert_not budget_response.key?(derived_field), "Expected budget index to omit #{derived_field}"
end
end
test "shows a budget" do
get api_v1_budget_url(@budget.id), headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_equal @budget.id, response_data["id"]
assert_equal @budget.start_date.to_s, response_data["start_date"]
assert_equal "USD", response_data["currency"]
assert_equal true, response_data["initialized"]
assert_kind_of Integer, response_data["budgeted_spending_cents"]
assert_kind_of Integer, response_data["actual_spending_cents"]
assert_kind_of Integer, response_data["actual_income_cents"]
assert_kind_of Integer, response_data["available_to_spend_cents"]
assert_kind_of Integer, response_data["available_to_allocate_cents"]
end
test "returns not found for another family's budget" do
get api_v1_budget_url(@other_budget.id), headers: api_headers(@api_key)
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "record_not_found", response_data["error"]
end
test "returns not found for malformed budget id" do
get api_v1_budget_url("not-a-uuid"), headers: api_headers(@api_key)
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "record_not_found", response_data["error"]
end
test "filters budgets by date range" do
get api_v1_budgets_url,
params: { start_date: @budget.start_date.to_s, end_date: @budget.end_date.to_s },
headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_includes response_data["budgets"].map { |budget| budget["id"] }, @budget.id
end
test "rejects invalid date filters" do
get api_v1_budgets_url, params: { start_date: "03/01/2024" }, headers: api_headers(@api_key)
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
end
test "requires authentication" do
get api_v1_budgets_url
assert_response :unauthorized
end
test "requires read scope" do
api_key_without_read = ApiKey.new(
user: @user,
name: "No Read Key",
scopes: [],
source: "mobile",
display_key: "no_read_#{SecureRandom.hex(8)}"
)
api_key_without_read.save!(validate: false)
get api_v1_budgets_url, headers: api_headers(api_key_without_read)
assert_response :forbidden
ensure
api_key_without_read&.destroy
end
end

View File

@@ -8,17 +8,10 @@ class Api::V1::CategoriesControllerTest < ActionDispatch::IntegrationTest
@other_family_user = users(:family_member)
@other_family_user.update!(family: families(:empty))
@oauth_app = Doorkeeper::Application.create!(
name: "Test API App",
redirect_uri: "https://example.com/callback",
scopes: "read read_write"
)
@access_token = Doorkeeper::AccessToken.create!(
application: @oauth_app,
resource_owner_id: @user.id,
scopes: "read"
)
# Fixtures pre-create active keys for family_admin; clear them so we can
# create scoped keys per-test without tripping the one-active-key-per-source
# validation.
@user.api_keys.active.destroy_all
@category = categories(:food_and_drink)
@subcategory = categories(:subcategory)
@@ -35,9 +28,7 @@ class Api::V1::CategoriesControllerTest < ActionDispatch::IntegrationTest
end
test "should return user's family categories successfully" do
get "/api/v1/categories", params: {}, headers: {
"Authorization" => "Bearer #{@access_token.token}"
}
get "/api/v1/categories", params: {}, headers: api_headers(read_only_api_key)
assert_response :success
response_body = JSON.parse(response.body)
@@ -53,15 +44,15 @@ class Api::V1::CategoriesControllerTest < ActionDispatch::IntegrationTest
end
test "should not return other family's categories" do
access_token = Doorkeeper::AccessToken.create!(
application: @oauth_app,
resource_owner_id: @other_family_user.id,
scopes: "read"
other_family_api_key = ApiKey.create!(
user: @other_family_user,
name: "Other Family Read Key",
key: ApiKey.generate_secure_key,
scopes: %w[read],
source: "web"
)
get "/api/v1/categories", params: {}, headers: {
"Authorization" => "Bearer #{access_token.token}"
}
get "/api/v1/categories", params: {}, headers: api_headers(other_family_api_key)
assert_response :success
response_body = JSON.parse(response.body)
@@ -72,9 +63,7 @@ class Api::V1::CategoriesControllerTest < ActionDispatch::IntegrationTest
end
test "should return proper category data structure" do
get "/api/v1/categories", params: {}, headers: {
"Authorization" => "Bearer #{@access_token.token}"
}
get "/api/v1/categories", params: {}, headers: api_headers(read_only_api_key)
assert_response :success
response_body = JSON.parse(response.body)
@@ -96,9 +85,7 @@ class Api::V1::CategoriesControllerTest < ActionDispatch::IntegrationTest
end
test "should include parent information for subcategories" do
get "/api/v1/categories", params: {}, headers: {
"Authorization" => "Bearer #{@access_token.token}"
}
get "/api/v1/categories", params: {}, headers: api_headers(read_only_api_key)
assert_response :success
response_body = JSON.parse(response.body)
@@ -112,9 +99,7 @@ class Api::V1::CategoriesControllerTest < ActionDispatch::IntegrationTest
end
test "should handle pagination parameters" do
get "/api/v1/categories", params: { page: 1, per_page: 2 }, headers: {
"Authorization" => "Bearer #{@access_token.token}"
}
get "/api/v1/categories", params: { page: 1, per_page: 2 }, headers: api_headers(read_only_api_key)
assert_response :success
response_body = JSON.parse(response.body)
@@ -125,9 +110,7 @@ class Api::V1::CategoriesControllerTest < ActionDispatch::IntegrationTest
end
test "should filter for roots only" do
get "/api/v1/categories", params: { roots_only: true }, headers: {
"Authorization" => "Bearer #{@access_token.token}"
}
get "/api/v1/categories", params: { roots_only: true }, headers: api_headers(read_only_api_key)
assert_response :success
response_body = JSON.parse(response.body)
@@ -138,9 +121,7 @@ class Api::V1::CategoriesControllerTest < ActionDispatch::IntegrationTest
end
test "should sort categories alphabetically" do
get "/api/v1/categories", params: {}, headers: {
"Authorization" => "Bearer #{@access_token.token}"
}
get "/api/v1/categories", params: {}, headers: api_headers(read_only_api_key)
assert_response :success
response_body = JSON.parse(response.body)
@@ -152,9 +133,7 @@ class Api::V1::CategoriesControllerTest < ActionDispatch::IntegrationTest
# Show action tests
test "should return a single category" do
get "/api/v1/categories/#{@category.id}", params: {}, headers: {
"Authorization" => "Bearer #{@access_token.token}"
}
get "/api/v1/categories/#{@category.id}", params: {}, headers: api_headers(read_only_api_key)
assert_response :success
response_body = JSON.parse(response.body)
@@ -166,9 +145,7 @@ class Api::V1::CategoriesControllerTest < ActionDispatch::IntegrationTest
end
test "should return 404 for non-existent category" do
get "/api/v1/categories/00000000-0000-0000-0000-000000000000", params: {}, headers: {
"Authorization" => "Bearer #{@access_token.token}"
}
get "/api/v1/categories/00000000-0000-0000-0000-000000000000", params: {}, headers: api_headers(read_only_api_key)
assert_response :not_found
response_body = JSON.parse(response.body)
@@ -182,10 +159,156 @@ class Api::V1::CategoriesControllerTest < ActionDispatch::IntegrationTest
classification_unused: "expense"
)
get "/api/v1/categories/#{other_family_category.id}", params: {}, headers: {
"Authorization" => "Bearer #{@access_token.token}"
}
get "/api/v1/categories/#{other_family_category.id}", params: {}, headers: api_headers(read_only_api_key)
assert_response :not_found
end
# Create action tests
test "create requires authentication" do
post "/api/v1/categories", params: { category: { name: "Anything" } }
assert_response :unauthorized
end
test "create rejects api key without read_write scope" do
post "/api/v1/categories",
params: { category: { name: "Coffee Runs", color: "#22c55e", icon: "coffee" } },
headers: api_headers(read_only_api_key)
assert_response :forbidden
end
test "create returns 201 with full attributes" do
post "/api/v1/categories",
params: { category: { name: "Coffee Runs", color: "#22c55e", icon: "coffee" } },
headers: api_headers(read_write_api_key)
assert_response :created
body = JSON.parse(response.body)
assert body["id"].present?
assert_equal "Coffee Runs", body["name"]
assert_equal "#22c55e", body["color"]
assert_equal "coffee", body["icon"]
assert_nil body["parent"]
assert_equal 0, body["subcategories_count"]
persisted = @user.family.categories.find(body["id"])
assert_equal "coffee", persisted.lucide_icon
end
test "create auto-suggests icon when omitted" do
post "/api/v1/categories",
params: { category: { name: "Groceries Imported", color: "#407706" } },
headers: api_headers(read_write_api_key)
assert_response :created
body = JSON.parse(response.body)
assert body["icon"].present?
assert_not_equal "", body["icon"]
end
test "create attaches parent when provided" do
post "/api/v1/categories",
params: { category: { name: "Imported Subcategory", color: "#22c55e", icon: "shapes", parent_id: @category.id } },
headers: api_headers(read_write_api_key)
assert_response :created
body = JSON.parse(response.body)
assert_equal @category.id, body.dig("parent", "id")
assert_equal @category.name, body.dig("parent", "name")
end
test "create returns 422 on duplicate name within family" do
post "/api/v1/categories",
params: { category: { name: @category.name, color: "#22c55e", icon: "shapes" } },
headers: api_headers(read_write_api_key)
assert_response :unprocessable_entity
body = JSON.parse(response.body)
assert_equal "unprocessable_entity", body["error"]
assert body["message"].present?
end
test "create returns 422 on invalid color" do
post "/api/v1/categories",
params: { category: { name: "Bad Color", color: "not-a-hex" } },
headers: api_headers(read_write_api_key)
assert_response :unprocessable_entity
body = JSON.parse(response.body)
assert_equal "unprocessable_entity", body["error"]
assert body["message"].present?
end
test "create returns 422 when parent_id belongs to another family" do
other_family_category = families(:empty).categories.create!(
name: "External Parent",
color: "#FF0000",
classification_unused: "expense"
)
post "/api/v1/categories",
params: { category: { name: "Should Fail", color: "#22c55e", icon: "shapes", parent_id: other_family_category.id } },
headers: api_headers(read_write_api_key)
assert_response :unprocessable_entity
body = JSON.parse(response.body)
assert_equal "unprocessable_entity", body["error"]
assert body["message"].present?
end
test "create returns 422 when nesting exceeds two levels" do
child = @user.family.categories.create!(
name: "Existing Child",
color: "#22c55e",
lucide_icon: "shapes",
parent: @category
)
post "/api/v1/categories",
params: { category: { name: "Grandchild", color: "#22c55e", icon: "shapes", parent_id: child.id } },
headers: api_headers(read_write_api_key)
assert_response :unprocessable_entity
body = JSON.parse(response.body)
assert_equal "unprocessable_entity", body["error"]
assert body["message"].present?
end
test "create returns 400 when category payload is missing" do
post "/api/v1/categories",
params: {},
headers: api_headers(read_write_api_key)
assert_response :bad_request
body = JSON.parse(response.body)
assert_equal "bad_request", body["error"]
end
private
def read_write_api_key
@read_write_api_key ||= ApiKey.create!(
user: @user,
name: "Test RW Key",
key: ApiKey.generate_secure_key,
scopes: %w[read_write],
source: "web"
)
end
def read_only_api_key
@read_only_api_key ||= ApiKey.create!(
user: @user,
name: "Test RO Key",
key: ApiKey.generate_secure_key,
scopes: %w[read],
source: "mobile"
)
end
def api_headers(api_key)
{ "X-Api-Key" => api_key.plain_key }
end
end

View File

@@ -0,0 +1,201 @@
# frozen_string_literal: true
require "test_helper"
class Api::V1::FamilyExportsControllerTest < ActionDispatch::IntegrationTest
setup do
@admin = users(:family_admin)
@member = users(:family_member)
@family = @admin.family
@admin.api_keys.active.destroy_all
@member.api_keys.active.destroy_all
@api_key = ApiKey.create!(
user: @admin,
name: "Test Read-Write Key",
scopes: [ "read_write" ],
display_key: "test_rw_#{SecureRandom.hex(8)}",
source: "web"
)
@read_only_api_key = ApiKey.create!(
user: @admin,
name: "Test Read Key",
scopes: [ "read" ],
display_key: "test_ro_#{SecureRandom.hex(8)}",
source: "mobile"
)
@member_api_key = ApiKey.create!(
user: @member,
name: "Member Read-Write Key",
scopes: [ "read_write" ],
display_key: "test_member_#{SecureRandom.hex(8)}",
source: "web"
)
redis = Redis.new
redis.del("api_rate_limit:#{@api_key.id}")
redis.del("api_rate_limit:#{@read_only_api_key.id}")
redis.del("api_rate_limit:#{@member_api_key.id}")
redis.close
end
test "lists family exports" do
completed_export = @family.family_exports.create!(status: "completed")
processing_export = @family.family_exports.create!(status: "processing")
get api_v1_family_exports_url, headers: api_headers(@read_only_api_key)
assert_response :success
json_response = JSON.parse(response.body)
export_ids = json_response["data"].map { |export| export["id"] }
assert_includes export_ids, completed_export.id
assert_includes export_ids, processing_export.id
assert_equal @family.family_exports.count, json_response["meta"]["total_count"]
end
test "shows a family export" do
export = @family.family_exports.create!(status: "completed")
export.export_file.attach(
io: StringIO.new("test zip content"),
filename: "test.zip",
content_type: "application/zip"
)
get api_v1_family_export_url(export), headers: api_headers(@read_only_api_key)
assert_response :success
json_response = JSON.parse(response.body)
assert_equal export.id, json_response["data"]["id"]
assert_equal "completed", json_response["data"]["status"]
assert_equal true, json_response["data"]["downloadable"]
assert_equal download_api_v1_family_export_path(export), json_response["data"]["download_path"]
assert_equal true, json_response["data"]["file"]["attached"]
assert_equal "application/zip", json_response["data"]["file"]["content_type"]
end
test "creates a family export job" do
assert_enqueued_with(job: FamilyDataExportJob) do
assert_difference("@family.family_exports.count") do
post api_v1_family_exports_url, headers: api_headers(@api_key)
end
end
assert_response :accepted
json_response = JSON.parse(response.body)
export = FamilyExport.find(json_response["data"]["id"])
assert_equal "pending", export.status
assert_equal @family.id, export.family_id
end
test "read-only key cannot create a family export" do
assert_no_difference("@family.family_exports.count") do
post api_v1_family_exports_url, headers: api_headers(@read_only_api_key)
end
assert_response :forbidden
assert_equal "insufficient_scope", JSON.parse(response.body)["error"]
end
test "create rejects unsupported params" do
assert_no_difference("@family.family_exports.count") do
post api_v1_family_exports_url,
params: { family_export: { status: "completed" } },
headers: api_headers(@api_key)
end
assert_response :unprocessable_entity
assert_equal "invalid_params", JSON.parse(response.body)["error"]
end
test "non-admin cannot access family exports" do
get api_v1_family_exports_url, headers: api_headers(@member_api_key)
assert_response :forbidden
assert_equal "forbidden", JSON.parse(response.body)["error"]
end
test "returns not found for another family's export" do
other_family = families(:empty)
other_export = other_family.family_exports.create!(status: "completed")
get api_v1_family_export_url(other_export), headers: api_headers(@read_only_api_key)
assert_response :not_found
assert_equal "record_not_found", JSON.parse(response.body)["error"]
end
test "returns not found for malformed export id" do
get api_v1_family_export_url("not-a-uuid"), headers: api_headers(@read_only_api_key)
assert_response :not_found
assert_equal "record_not_found", JSON.parse(response.body)["error"]
end
test "download returns not found for malformed export id" do
get download_api_v1_family_export_url("not-a-uuid"), headers: api_headers(@read_only_api_key)
assert_response :not_found
assert_equal "record_not_found", JSON.parse(response.body)["error"]
end
test "redirects completed export downloads to the attached file" do
export = @family.family_exports.create!(status: "completed")
export.export_file.attach(
io: StringIO.new("test zip content"),
filename: "test.zip",
content_type: "application/zip"
)
get download_api_v1_family_export_url(export), headers: api_headers(@read_only_api_key)
assert_response :redirect
assert_includes response.location, "/rails/active_storage/blobs/redirect/"
assert_includes response.location, "test.zip"
end
test "download returns conflict when export is not ready" do
export = @family.family_exports.create!(status: "processing")
get download_api_v1_family_export_url(export), headers: api_headers(@read_only_api_key)
assert_response :conflict
json_response = JSON.parse(response.body)
assert_equal "export_not_ready", json_response["error"]
end
test "download handles storage URL failures without leaking details" do
export = @family.family_exports.create!(status: "completed")
export.export_file.attach(
io: StringIO.new("test zip content"),
filename: "test.zip",
content_type: "application/zip"
)
Api::V1::FamilyExportsController.any_instance
.stubs(:rails_blob_url)
.raises(StandardError, "storage down")
get download_api_v1_family_export_url(export), 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 "An unexpected error occurred", json_response["message"]
assert_not_includes response.body, "storage down"
end
test "requires authentication" do
get api_v1_family_exports_url
assert_response :unauthorized
end
private
def api_headers(api_key)
{ "X-Api-Key" => api_key.plain_key }
end
end

View File

@@ -0,0 +1,84 @@
# frozen_string_literal: true
require "test_helper"
class Api::V1::FamilySettingsControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:family_admin)
@family = @user.family
@family.update!(
currency: "SGD",
enabled_currencies: [ "USD" ],
locale: "en",
date_format: "%Y-%m-%d",
country: "SG",
timezone: "Asia/Singapore",
month_start_day: 15,
moniker: "Family",
default_account_sharing: "private"
)
@user.api_keys.active.destroy_all
@api_key = ApiKey.create!(
user: @user,
name: "Test Read Key",
scopes: [ "read" ],
source: "web",
display_key: "test_read_#{SecureRandom.hex(8)}"
)
Redis.new.del("api_rate_limit:#{@api_key.id}")
end
test "shows current family settings snapshot" do
get api_v1_family_settings_url, headers: api_headers(@api_key)
assert_response :success
response_body = JSON.parse(response.body)
assert_equal @family.id, response_body["id"]
assert_equal @family.name, response_body["name"]
assert_equal "SGD", response_body["currency"]
assert_equal "en", response_body["locale"]
assert_equal "%Y-%m-%d", response_body["date_format"]
assert_equal "SG", response_body["country"]
assert_equal "Asia/Singapore", response_body["timezone"]
assert_equal 15, response_body["month_start_day"]
assert_equal "Family", response_body["moniker"]
assert_equal "private", response_body["default_account_sharing"]
assert_equal true, response_body["custom_enabled_currencies"]
assert_equal @family.enabled_currency_codes, response_body["enabled_currencies"]
assert_equal @family.created_at.iso8601, response_body["created_at"]
assert_equal @family.updated_at.iso8601, response_body["updated_at"]
assert_not response_body.key?("stripe_customer_id")
assert_not response_body.key?("vector_store_id")
end
test "requires authentication" do
get api_v1_family_settings_url
assert_response :unauthorized
end
test "requires read scope" do
api_key_without_read = ApiKey.new(
user: @user,
name: "No Read Key",
scopes: [],
source: "web",
display_key: "no_read_#{SecureRandom.hex(8)}"
)
api_key_without_read.save!(validate: false)
get api_v1_family_settings_url, headers: api_headers(api_key_without_read)
assert_response :forbidden
ensure
api_key_without_read&.destroy
end
private
def api_headers(api_key)
{ "X-Api-Key" => api_key.plain_key }
end
end

File diff suppressed because it is too large Load Diff

View File

@@ -37,7 +37,7 @@ class Api::V1::MessagesControllerTest < ActionDispatch::IntegrationTest
end
test "should create message with write scope" do
assert_difference "Message.count" do
assert_difference "UserMessage.count" do
post "/api/v1/chats/#{@chat.id}/messages",
params: { content: "Test message", model: "gpt-4" },
headers: bearer_auth_header(@write_token)

View File

@@ -0,0 +1,221 @@
# frozen_string_literal: true
require "test_helper"
class Api::V1::ProviderConnectionsControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:family_admin)
@family = @user.family
@mercury_item = mercury_items(:one)
@user.api_keys.active.destroy_all
@api_key = ApiKey.create!(
user: @user,
name: "Test Read Key",
scopes: [ "read" ],
display_key: "test_read_#{SecureRandom.hex(8)}",
source: "web"
)
@read_write_key = ApiKey.create!(
user: @user,
name: "Test Read-Write Key",
scopes: [ "read_write" ],
display_key: "test_rw_#{SecureRandom.hex(8)}",
source: "mobile"
)
redis = Redis.new
redis.del("api_rate_limit:#{@api_key.id}")
redis.del("api_rate_limit:#{@read_write_key.id}")
end
test "lists provider connection status for current family" do
failed_sync = @mercury_item.syncs.create!(
status: "failed",
failed_at: Time.current,
error: "secret token failed"
)
get api_v1_provider_connections_url, headers: api_headers(@api_key)
assert_response :success
json_response = JSON.parse(response.body)
mercury_connection = json_response["data"].detect do |connection|
connection["id"] == @mercury_item.id && connection["provider"] == "mercury"
end
assert_not_nil mercury_connection
assert_equal "mercury", mercury_connection["provider"]
assert_equal "MercuryItem", mercury_connection["provider_type"]
assert_equal @mercury_item.name, mercury_connection["name"]
assert_equal @mercury_item.status, mercury_connection["status"]
assert_includes [ true, false ], mercury_connection["requires_update"]
assert_equal true, mercury_connection["credentials_configured"]
assert_includes [ true, false ], mercury_connection["scheduled_for_deletion"]
assert_includes [ true, false ], mercury_connection["pending_account_setup"]
assert_equal @mercury_item.mercury_accounts.count, mercury_connection["accounts"]["total_count"]
assert_equal failed_sync.id, mercury_connection["sync"]["latest"]["id"]
assert_equal true, mercury_connection["sync"]["latest"]["error"]["present"]
assert_equal "Sync failed", mercury_connection["sync"]["latest"]["error"]["message"]
end
test "reports failed sync errors as present without exposing raw messages" do
failed_sync = @mercury_item.syncs.create!(
status: "failed",
failed_at: Time.current,
error: nil
)
get api_v1_provider_connections_url, headers: api_headers(@api_key)
assert_response :success
mercury_connection = JSON.parse(response.body)["data"].detect do |connection|
connection["id"] == @mercury_item.id && connection["provider"] == "mercury"
end
assert_equal failed_sync.id, mercury_connection["sync"]["latest"]["id"]
assert_equal true, mercury_connection["sync"]["latest"]["error"]["present"]
assert_equal "Sync failed", mercury_connection["sync"]["latest"]["error"]["message"]
end
test "reports stale sync errors as present" do
stale_sync = @mercury_item.syncs.create!(
status: "stale",
syncing_at: 2.days.ago
)
get api_v1_provider_connections_url, headers: api_headers(@api_key)
assert_response :success
mercury_connection = JSON.parse(response.body)["data"].detect do |connection|
connection["id"] == @mercury_item.id && connection["provider"] == "mercury"
end
assert_equal stale_sync.id, mercury_connection["sync"]["latest"]["id"]
assert_equal true, mercury_connection["sync"]["latest"]["error"]["present"]
assert_equal "Sync became stale before completion", mercury_connection["sync"]["latest"]["error"]["message"]
end
test "does not expose provider secrets or raw sync errors" do
@mercury_item.syncs.create!(
status: "failed",
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
get api_v1_provider_connections_url, headers: api_headers(@api_key)
assert_response :success
plaid_connection = JSON.parse(response.body)["data"].detect do |connection|
connection["provider"] == "plaid"
end
assert_not_nil plaid_connection
assert_includes [ true, false ], plaid_connection["requires_update"]
assert_equal false, plaid_connection["credentials_configured"]
assert_includes [ true, false ], plaid_connection["scheduled_for_deletion"]
assert_includes [ true, false ], plaid_connection["pending_account_setup"]
end
test "excludes another family's provider connections" do
other_item = snaptrade_items(:pending_registration_item)
get api_v1_provider_connections_url, headers: api_headers(@api_key)
assert_response :success
ids = JSON.parse(response.body)["data"].map { |connection| connection["id"] }
assert_not_includes ids, other_item.id
end
test "read_write key can list provider connection status" do
get api_v1_provider_connections_url, headers: api_headers(@read_write_key)
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)
end
assert_response :success
assert_equal [], JSON.parse(response.body)["data"]
end
test "requires authentication" do
get api_v1_provider_connections_url
assert_response :unauthorized
end
test "rejects api keys without read scope" do
write_only_key = ApiKey.new(
user: @user,
name: "Test Write Key",
scopes: [ "write" ],
display_key: "test_write_#{SecureRandom.hex(8)}",
source: "monitoring"
).tap { |api_key| api_key.save!(validate: false) }
get api_v1_provider_connections_url, headers: api_headers(write_only_key)
assert_response :forbidden
end
test "does not leak internal provider status errors" do
ProviderConnectionStatus.stub(:for_family, ->(_family) { raise StandardError, "secret provider failure" }) do
get api_v1_provider_connections_url, headers: api_headers(@api_key)
end
assert_response :internal_server_error
assert_equal "internal_server_error", JSON.parse(response.body)["error"]
refute_includes response.body, "secret provider failure"
end
private
def api_headers(api_key)
{ "X-Api-Key" => api_key.plain_key }
end
end

View File

@@ -0,0 +1,509 @@
# frozen_string_literal: true
require "test_helper"
class Api::V1::RecurringTransactionsControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:family_admin)
@family = @user.family
@account = accounts(:depository)
@merchant = @family.merchants.create!(name: "Streaming Service")
@user.api_keys.active.destroy_all
@api_key = ApiKey.create!(
user: @user,
name: "Test Read-Write Key",
scopes: [ "read_write" ],
source: "web",
display_key: "test_rw_#{SecureRandom.hex(8)}"
)
@read_only_api_key = ApiKey.create!(
user: @user,
name: "Test Read Key",
scopes: [ "read" ],
display_key: "test_read_#{SecureRandom.hex(8)}",
source: "mobile"
)
@recurring_transaction = @family.recurring_transactions.create!(
account: @account,
merchant: @merchant,
amount: 19.99,
currency: "USD",
expected_day_of_month: 15,
last_occurrence_date: Date.new(2026, 4, 15),
next_expected_date: Date.new(2026, 5, 15),
status: "active",
occurrence_count: 3,
manual: true
)
end
test "should list recurring transactions" do
get api_v1_recurring_transactions_url, headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert response_data.key?("recurring_transactions")
assert response_data.key?("pagination")
assert_includes response_data["recurring_transactions"].map { |item| item["id"] }, @recurring_transaction.id
end
test "should require authentication when listing recurring transactions" do
get api_v1_recurring_transactions_url
assert_response :unauthorized
end
test "should show recurring transaction" do
get api_v1_recurring_transaction_url(@recurring_transaction), headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_equal @recurring_transaction.id, response_data["id"]
assert_equal 1999, response_data["amount_cents"]
assert response_data.key?("expected_amount_min_cents")
assert response_data.key?("expected_amount_max_cents")
assert response_data.key?("expected_amount_avg_cents")
assert_equal @account.id, response_data["account"]["id"]
assert_equal @merchant.id, response_data["merchant"]["id"]
end
test "should not mutate recurring transaction on read only shared account" do
member = users(:family_member)
member.api_keys.active.destroy_all
member_api_key = ApiKey.create!(
user: member,
name: "Member Read-Write Key",
scopes: [ "read_write" ],
source: "web",
display_key: "test_member_rw_#{SecureRandom.hex(8)}"
)
read_only_account = accounts(:credit_card)
recurring_transaction = @family.recurring_transactions.create!(
account: read_only_account,
name: "Read Only Shared Subscription",
amount: 9.99,
currency: "USD",
expected_day_of_month: 5,
last_occurrence_date: Date.new(2026, 4, 5),
next_expected_date: Date.new(2026, 5, 5),
status: "active",
occurrence_count: 2,
manual: true
)
get api_v1_recurring_transaction_url(recurring_transaction), headers: api_headers(member_api_key)
assert_response :success
patch api_v1_recurring_transaction_url(recurring_transaction),
params: { recurring_transaction: { status: "inactive" } },
headers: api_headers(member_api_key)
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "record_not_found", response_data["error"]
assert_no_difference("@family.recurring_transactions.count") do
delete api_v1_recurring_transaction_url(recurring_transaction), headers: api_headers(member_api_key)
end
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "record_not_found", response_data["error"]
end
test "should return not found for missing recurring transaction" do
get api_v1_recurring_transaction_url(SecureRandom.uuid), headers: api_headers(@api_key)
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "record_not_found", response_data["error"]
end
test "should return not found for malformed recurring transaction id" do
get api_v1_recurring_transaction_url("not-a-uuid"), headers: api_headers(@api_key)
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "record_not_found", response_data["error"]
end
test "should reject malformed account filter" do
get api_v1_recurring_transactions_url,
params: { account_id: "not-a-uuid" },
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 require authentication when showing recurring transaction" do
get api_v1_recurring_transaction_url(@recurring_transaction)
assert_response :unauthorized
end
test "should create recurring transaction" do
assert_difference("@family.recurring_transactions.count", 1) do
post api_v1_recurring_transactions_url,
params: valid_recurring_transaction_params,
headers: api_headers(@api_key)
end
assert_response :created
response_data = JSON.parse(response.body)
assert_equal "Gym Membership", response_data["name"]
assert_equal 4999, response_data["amount_cents"]
assert_equal true, response_data["manual"]
end
test "should default null manual to true when creating recurring transaction" do
params = valid_recurring_transaction_params.deep_dup
params[:recurring_transaction][:manual] = nil
assert_difference("@family.recurring_transactions.count", 1) do
post api_v1_recurring_transactions_url,
params: params,
headers: api_headers(@api_key)
end
assert_response :created
response_data = JSON.parse(response.body)
assert_equal true, response_data["manual"]
end
test "should require authentication when creating recurring transaction" do
post api_v1_recurring_transactions_url, params: valid_recurring_transaction_params
assert_response :unauthorized
end
test "should reject create with read-only API key" do
post api_v1_recurring_transactions_url,
params: valid_recurring_transaction_params,
headers: api_headers(@read_only_api_key)
assert_response :forbidden
end
test "should reject create without recurring transaction wrapper" do
assert_no_difference("@family.recurring_transactions.count") do
post api_v1_recurring_transactions_url,
params: { name: "Gym Membership" },
headers: api_headers(@api_key)
end
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
end
test "should reject create with malformed account id" do
params = valid_recurring_transaction_params.deep_dup
params[:recurring_transaction][:account_id] = "not-a-uuid"
assert_no_difference("@family.recurring_transactions.count") do
post api_v1_recurring_transactions_url,
params: params,
headers: api_headers(@api_key)
end
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "record_not_found", response_data["error"]
end
test "should reject create with malformed merchant id" do
params = valid_recurring_transaction_params.deep_dup
params[:recurring_transaction].delete(:name)
params[:recurring_transaction][:merchant_id] = "not-a-uuid"
assert_no_difference("@family.recurring_transactions.count") do
post api_v1_recurring_transactions_url,
params: params,
headers: api_headers(@api_key)
end
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "record_not_found", response_data["error"]
end
test "should reject create without name or merchant" do
params = valid_recurring_transaction_params.deep_dup
params[:recurring_transaction].delete(:name)
post api_v1_recurring_transactions_url,
params: params,
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 without required dates" do
params = valid_recurring_transaction_params.deep_dup
params[:recurring_transaction].delete(:last_occurrence_date)
params[:recurring_transaction].delete(:next_expected_date)
assert_no_difference("@family.recurring_transactions.count") do
post api_v1_recurring_transactions_url,
params: 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_includes response_data["errors"], "Last occurrence date can't be blank"
assert_includes response_data["errors"], "Next expected date can't be blank"
end
test "should reject create with nil status" do
params = valid_recurring_transaction_params.deep_dup
params[:recurring_transaction][:status] = nil
assert_no_difference("@family.recurring_transactions.count") do
post api_v1_recurring_transactions_url,
params: 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_includes response_data["errors"], "Status can't be blank"
end
test "should reject create with negative occurrence count" do
params = valid_recurring_transaction_params.deep_dup
params[:recurring_transaction][:occurrence_count] = -1
assert_no_difference("@family.recurring_transactions.count") do
post api_v1_recurring_transactions_url,
params: 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_includes response_data["errors"], "Occurrence count must be greater than or equal to 0"
end
test "should return conflict when creating duplicate recurring transaction" do
params = {
recurring_transaction: {
account_id: @account.id,
merchant_id: @merchant.id,
amount: @recurring_transaction.amount.to_s,
currency: @recurring_transaction.currency,
expected_day_of_month: 15,
last_occurrence_date: "2026-04-15",
next_expected_date: "2026-05-15"
}
}
# The unique index intentionally ignores recurrence dates; matching family,
# account, merchant, amount, and currency is enough to conflict.
assert_no_difference("@family.recurring_transactions.count") do
post api_v1_recurring_transactions_url,
params: params,
headers: api_headers(@api_key)
end
assert_response :conflict
response_data = JSON.parse(response.body)
assert_equal "conflict", response_data["error"]
assert_equal "Recurring transaction already exists", response_data["message"]
end
test "should update recurring transaction" do
patch api_v1_recurring_transaction_url(@recurring_transaction),
params: { recurring_transaction: { status: "inactive", expected_day_of_month: 16 } },
headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_equal "inactive", response_data["status"]
assert_equal 16, response_data["expected_day_of_month"]
end
test "should require authentication when updating recurring transaction" do
patch api_v1_recurring_transaction_url(@recurring_transaction),
params: { recurring_transaction: { status: "inactive" } }
assert_response :unauthorized
end
test "should reject update with read-only API key" do
patch api_v1_recurring_transaction_url(@recurring_transaction),
params: { recurring_transaction: { status: "inactive" } },
headers: api_headers(@read_only_api_key)
assert_response :forbidden
end
test "should reject update without recurring transaction wrapper" do
patch api_v1_recurring_transaction_url(@recurring_transaction),
params: { status: "inactive" },
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 update with invalid status" do
patch api_v1_recurring_transaction_url(@recurring_transaction),
params: { recurring_transaction: { status: "paused" } },
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 update with nil status" do
patch api_v1_recurring_transaction_url(@recurring_transaction),
params: { recurring_transaction: { status: nil } },
headers: api_headers(@api_key)
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
assert_includes response_data["errors"], "Status can't be blank"
end
test "should reject update with nil next expected date" do
patch api_v1_recurring_transaction_url(@recurring_transaction),
params: { recurring_transaction: { next_expected_date: nil } },
headers: api_headers(@api_key)
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
assert_includes response_data["errors"], "Next expected date can't be blank"
end
test "should ignore internal fields on update" do
patch api_v1_recurring_transaction_url(@recurring_transaction),
params: {
recurring_transaction: {
status: "inactive",
occurrence_count: 99,
manual: false,
amount: 1.23
}
},
headers: api_headers(@api_key)
assert_response :success
@recurring_transaction.reload
assert_equal "inactive", @recurring_transaction.status
assert_equal 3, @recurring_transaction.occurrence_count
assert_equal true, @recurring_transaction.manual
assert_equal 19.99, @recurring_transaction.amount.to_f
end
test "should return not found when updating missing recurring transaction" do
patch api_v1_recurring_transaction_url(SecureRandom.uuid),
params: { recurring_transaction: { status: "inactive" } },
headers: api_headers(@api_key)
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "record_not_found", response_data["error"]
end
test "should reject invalid recurring transaction update" do
patch api_v1_recurring_transaction_url(@recurring_transaction),
params: { recurring_transaction: { expected_day_of_month: 32 } },
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 destroy recurring transaction" do
assert_difference("@family.recurring_transactions.count", -1) do
delete api_v1_recurring_transaction_url(@recurring_transaction), headers: api_headers(@api_key)
end
assert_response :ok
end
test "should require authentication when destroying recurring transaction" do
delete api_v1_recurring_transaction_url(@recurring_transaction)
assert_response :unauthorized
end
test "should reject destroy with read-only API key" do
delete api_v1_recurring_transaction_url(@recurring_transaction), headers: api_headers(@read_only_api_key)
assert_response :forbidden
end
test "should return not found when destroying missing recurring transaction" do
delete api_v1_recurring_transaction_url(SecureRandom.uuid), headers: api_headers(@api_key)
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "record_not_found", response_data["error"]
end
test "should not create recurring transaction for another family account" do
other_family = Family.create!(name: "Other Family", currency: "USD", locale: "en")
other_account = Account.create!(
family: other_family,
name: "Other Checking",
currency: "USD",
classification: "asset",
accountable: Depository.create!,
balance: 0
)
post api_v1_recurring_transactions_url,
params: {
recurring_transaction: {
account_id: other_account.id,
name: "Gym Membership",
amount: 49.99,
currency: "USD",
expected_day_of_month: 1,
last_occurrence_date: "2026-04-01",
next_expected_date: "2026-05-01"
}
},
headers: api_headers(@api_key)
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "record_not_found", response_data["error"]
end
private
def valid_recurring_transaction_params
{
recurring_transaction: {
account_id: @account.id,
name: "Gym Membership",
amount: 49.99,
currency: "USD",
expected_day_of_month: 1,
last_occurrence_date: "2026-04-01",
next_expected_date: "2026-05-01",
status: "active",
occurrence_count: 1
}
}
end
def api_headers(api_key)
{ "X-Api-Key" => api_key.plain_key }
end
end

View File

@@ -0,0 +1,182 @@
# frozen_string_literal: true
require "test_helper"
class Api::V1::RejectedTransfersControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:family_admin)
@family = @user.family
@user.api_keys.active.destroy_all
@api_key = ApiKey.create!(
user: @user,
name: "Test Read Key",
scopes: [ "read" ],
source: "web",
display_key: "test_read_#{SecureRandom.hex(8)}"
)
@account = @family.accounts.create!(
name: "Rejected Checking",
accountable: Depository.new,
balance: 500,
currency: "USD"
)
@destination_account = @family.accounts.create!(
name: "Rejected Savings",
accountable: Depository.new,
balance: 1000,
currency: "USD"
)
outflow = create_transaction(@account, amount: 25, date: Date.parse("2024-01-15"), name: "Rejected outflow")
inflow = create_transaction(@destination_account, amount: -25, date: Date.parse("2024-01-15"), name: "Rejected inflow")
@rejected_transfer = RejectedTransfer.create!(
outflow_transaction: outflow,
inflow_transaction: inflow
)
other_family = families(:empty)
other_account = other_family.accounts.create!(name: "Other Rejected Checking", accountable: Depository.new, balance: 0, currency: "USD")
other_destination = other_family.accounts.create!(name: "Other Rejected Savings", accountable: Depository.new, balance: 0, currency: "USD")
other_outflow = create_transaction(other_account, amount: 50, date: Date.parse("2024-01-15"), name: "Other rejected outflow")
other_inflow = create_transaction(other_destination, amount: -50, date: Date.parse("2024-01-15"), name: "Other rejected inflow")
@other_rejected_transfer = RejectedTransfer.create!(outflow_transaction: other_outflow, inflow_transaction: other_inflow)
end
test "lists rejected transfers scoped to the current family" do
get api_v1_rejected_transfers_url, headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert response_data.key?("rejected_transfers")
assert response_data.key?("pagination")
assert_includes response_data["rejected_transfers"].map { |transfer| transfer["id"] }, @rejected_transfer.id
assert_not_includes response_data["rejected_transfers"].map { |transfer| transfer["id"] }, @other_rejected_transfer.id
end
test "permits read write scope" do
read_write_key = ApiKey.create!(
user: @user,
name: "Test Read Write Key",
scopes: [ "read_write" ],
source: "mobile",
display_key: "test_read_write_#{SecureRandom.hex(8)}"
)
get api_v1_rejected_transfers_url, headers: api_headers(read_write_key)
assert_response :success
end
test "shows a rejected transfer" do
get api_v1_rejected_transfer_url(@rejected_transfer), headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_equal @rejected_transfer.id, response_data["id"]
assert_equal "Rejected Savings", response_data.dig("inflow_transaction", "account", "name")
assert_equal "Rejected Checking", response_data.dig("outflow_transaction", "account", "name")
end
test "returns not found for another family's rejected transfer" do
get api_v1_rejected_transfer_url(@other_rejected_transfer), headers: api_headers(@api_key)
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "record_not_found", response_data["error"]
end
test "returns not found for malformed rejected transfer id" do
get api_v1_rejected_transfer_url("not-a-uuid"), headers: api_headers(@api_key)
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "record_not_found", response_data["error"]
end
test "filters rejected transfers by account_id" do
get api_v1_rejected_transfers_url, params: { account_id: @account.id }, headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_includes response_data["rejected_transfers"].map { |transfer| transfer["id"] }, @rejected_transfer.id
end
test "rejects malformed account_id filter" do
get api_v1_rejected_transfers_url, params: { account_id: "not-a-uuid" }, headers: api_headers(@api_key)
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
end
test "rejects invalid date filter" do
get api_v1_rejected_transfers_url, params: { start_date: "01/15/2024" }, headers: api_headers(@api_key)
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
end
test "filters rejected transfers when either transaction side date matches" do
matched_outflow = create_transaction(@account, amount: 35, date: Date.parse("2024-02-10"), name: "Rejected dated outflow")
matched_inflow = create_transaction(@destination_account, amount: -35, date: Date.parse("2024-02-10"), name: "Rejected dated inflow")
date_matched_transfer = RejectedTransfer.create!(outflow_transaction: matched_outflow, inflow_transaction: matched_inflow)
partial_outflow = create_transaction(@account, amount: 45, date: Date.parse("2024-02-10"), name: "Rejected partial outflow")
partial_inflow = create_transaction(@destination_account, amount: -45, date: Date.parse("2024-02-12"), name: "Rejected partial inflow")
partial_date_transfer = RejectedTransfer.create!(outflow_transaction: partial_outflow, inflow_transaction: partial_inflow)
get api_v1_rejected_transfers_url,
params: { start_date: "2024-02-10", end_date: "2024-02-10" },
headers: api_headers(@api_key)
assert_response :success
transfer_ids = JSON.parse(response.body)["rejected_transfers"].map { |transfer| transfer["id"] }
assert_includes transfer_ids, date_matched_transfer.id
assert_includes transfer_ids, partial_date_transfer.id
assert_not_includes transfer_ids, @rejected_transfer.id
end
test "requires authentication" do
get api_v1_rejected_transfers_url
assert_response :unauthorized
end
test "requires read scope" do
# ApiKey.create! rejects empty scopes; bypass validation to exercise runtime authorization.
api_key_without_read = ApiKey.new(
user: @user,
name: "No Read Key",
scopes: [],
source: "mobile",
display_key: "no_read_#{SecureRandom.hex(8)}"
)
api_key_without_read.save!(validate: false)
get api_v1_rejected_transfers_url, headers: api_headers(api_key_without_read)
assert_response :forbidden
ensure
api_key_without_read&.destroy
end
private
def create_transaction(account, amount:, date:, name:)
entry = account.entries.create!(
date: date,
amount: amount,
name: name,
currency: account.currency,
entryable: Transaction.new(kind: "standard")
)
entry.entryable
end
def api_headers(api_key)
{ "X-Api-Key" => api_key.display_key }
end
end

View File

@@ -0,0 +1,207 @@
# frozen_string_literal: true
require "test_helper"
class Api::V1::RuleRunsControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:family_admin)
@family = @user.family
@user.api_keys.active.destroy_all
@api_key = ApiKey.create!(
user: @user,
name: "Test Read Key",
scopes: [ "read" ],
source: "web",
display_key: "test_read_#{SecureRandom.hex(8)}"
)
@redis = Redis.new
@redis.del("api_rate_limit:#{@api_key.id}")
@rule = @family.rules.build(
name: "Coffee cleanup",
resource_type: "transaction",
active: true,
effective_date: Date.new(2024, 1, 1)
)
@rule.conditions.build(condition_type: "transaction_name", operator: "like", value: "coffee")
@rule.actions.build(action_type: "set_transaction_name", value: "Coffee")
@rule.save!
@rule_run = @rule.rule_runs.create!(
rule_name: @rule.name,
execution_type: "manual",
status: "success",
transactions_queued: 10,
transactions_processed: 10,
transactions_modified: 4,
pending_jobs_count: 0,
executed_at: Time.zone.parse("2024-01-15 12:00:00")
)
@failed_rule_run = @rule.rule_runs.create!(
rule_name: @rule.name,
execution_type: "scheduled",
status: "failed",
transactions_queued: 5,
transactions_processed: 2,
transactions_modified: 0,
pending_jobs_count: 0,
executed_at: Time.zone.parse("2024-01-16 12:00:00"),
error_message: "Rule failed"
)
end
test "lists rule runs scoped to family rules" do
other_rule_run = create_other_family_rule_run
get api_v1_rule_runs_url, headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
rule_run_ids = response_data["data"].map { |rule_run| rule_run["id"] }
assert_includes rule_run_ids, @rule_run.id
assert_includes rule_run_ids, @failed_rule_run.id
assert_not_includes rule_run_ids, other_rule_run.id
expected_count = RuleRun.joins(:rule).where(rules: { family_id: @family.id }).count
assert_equal expected_count, response_data["meta"]["total_count"]
end
test "shows a rule run" do
get api_v1_rule_run_url(@rule_run), headers: api_headers(@api_key)
assert_response :success
rule_run = JSON.parse(response.body)["data"]
assert_equal @rule_run.id, rule_run["id"]
assert_equal @rule.id, rule_run["rule_id"]
assert_equal "manual", rule_run["execution_type"]
assert_equal "success", rule_run["status"]
assert_equal 10, rule_run["transactions_queued"]
assert_equal 4, rule_run["transactions_modified"]
assert_equal @rule.id, rule_run.dig("rule", "id")
end
test "does not show another family's rule run" do
other_rule_run = create_other_family_rule_run
get api_v1_rule_run_url(other_rule_run), headers: api_headers(@api_key)
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "record_not_found", response_data["error"]
end
test "returns not found for malformed rule run id" do
get api_v1_rule_run_url("not-a-uuid"), headers: api_headers(@api_key)
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "record_not_found", response_data["error"]
end
test "filters rule runs" do
get api_v1_rule_runs_url,
params: {
rule_id: @rule.id,
status: "failed",
execution_type: "scheduled",
start_executed_at: "2024-01-16T00:00:00Z",
end_executed_at: "2024-01-17T00:00:00Z"
},
headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_equal [ @failed_rule_run.id ], response_data["data"].map { |rule_run| rule_run["id"] }
end
test "rejects invalid filters" do
get api_v1_rule_runs_url, params: { status: "unknown" }, headers: api_headers(@api_key)
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
end
test "clamps oversized per_page values to the documented maximum" do
get api_v1_rule_runs_url, params: { per_page: 500 }, headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_equal 100, response_data["meta"]["per_page"]
end
test "rejects malformed rule_id filter" do
get api_v1_rule_runs_url, params: { rule_id: "not-a-uuid" }, headers: api_headers(@api_key)
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
end
test "rejects invalid timestamp filters" do
get api_v1_rule_runs_url, params: { start_executed_at: "not-a-date" }, headers: api_headers(@api_key)
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
end
test "requires authentication" do
get api_v1_rule_runs_url
assert_response :unauthorized
end
test "show requires authentication" do
get api_v1_rule_run_url(@rule_run)
assert_response :unauthorized
end
test "requires read scope" do
api_key_without_read = ApiKey.new(
user: @user,
name: "No Read Key",
scopes: [],
source: "web",
display_key: "no_read_#{SecureRandom.hex(8)}"
)
api_key_without_read.save!(validate: false)
get api_v1_rule_runs_url, headers: api_headers(api_key_without_read)
assert_response :forbidden
ensure
api_key_without_read&.destroy
end
teardown do
@redis&.close
end
private
def api_headers(api_key)
{ "X-Api-Key" => api_key.display_key }
end
def create_other_family_rule_run
other_family = Family.create!(name: "Other Family", currency: "USD", locale: "en")
other_rule = other_family.rules.build(name: "Other", resource_type: "transaction", active: true)
other_rule.conditions.build(condition_type: "transaction_name", operator: "like", value: "other")
other_rule.actions.build(action_type: "set_transaction_name", value: "Other")
other_rule.save!
other_rule.rule_runs.create!(
rule_name: other_rule.name,
execution_type: "manual",
status: "success",
transactions_queued: 1,
transactions_processed: 1,
transactions_modified: 1,
pending_jobs_count: 0,
executed_at: Time.current
)
end
end

View File

@@ -0,0 +1,174 @@
# frozen_string_literal: true
require "test_helper"
class Api::V1::RulesControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:family_admin)
@family = @user.family
@user.api_keys.active.destroy_all
@api_key = ApiKey.create!(
user: @user,
name: "Test Read Key",
scopes: [ "read" ],
source: "web",
display_key: "test_read_#{SecureRandom.hex(8)}"
)
Redis.new.del("api_rate_limit:#{@api_key.id}")
@rule = @family.rules.build(
name: "Coffee cleanup",
resource_type: "transaction",
active: true,
effective_date: Date.new(2024, 1, 1)
)
@rule.conditions.build(condition_type: "transaction_name", operator: "like", value: "coffee")
@rule.actions.build(action_type: "set_transaction_name", value: "Coffee")
@rule.save!
end
test "should list rules" do
get api_v1_rules_url, headers: api_headers(@api_key)
assert_response :success
json_response = JSON.parse(response.body)
assert json_response["data"].any? { |rule| rule["id"] == @rule.id }
assert_equal @family.rules.count, json_response["meta"]["total_count"]
end
test "should not list another family's rules" do
other_family = Family.create!(name: "Other Family", currency: "USD", locale: "en")
other_rule = other_family.rules.build(name: "Other", resource_type: "transaction", active: true)
other_rule.conditions.build(condition_type: "transaction_name", operator: "like", value: "other")
other_rule.actions.build(action_type: "set_transaction_name", value: "Other")
other_rule.save!
get api_v1_rules_url, headers: api_headers(@api_key)
assert_response :success
rule_ids = JSON.parse(response.body)["data"].map { |rule| rule["id"] }
assert_includes rule_ids, @rule.id
assert_not_includes rule_ids, other_rule.id
end
test "should require authentication when listing rules" do
get api_v1_rules_url
assert_response :unauthorized
end
test "should require read scope when listing rules" do
api_key_without_read = api_key_without_read_scope
get api_v1_rules_url, headers: api_headers(api_key_without_read)
assert_response :forbidden
json_response = JSON.parse(response.body)
assert_equal "insufficient_scope", json_response["error"]
ensure
api_key_without_read&.destroy
end
test "should filter rules by active status" do
inactive_rule = @family.rules.build(name: "Inactive", resource_type: "transaction", active: false)
inactive_rule.conditions.build(condition_type: "transaction_name", operator: "like", value: "ignore")
inactive_rule.actions.build(action_type: "set_transaction_name", value: "Ignore")
inactive_rule.save!
get api_v1_rules_url, params: { active: true }, headers: api_headers(@api_key)
assert_response :success
json_response = JSON.parse(response.body)
rule_ids = json_response["data"].map { |rule| rule["id"] }
assert_includes rule_ids, @rule.id
assert_not_includes rule_ids, inactive_rule.id
end
test "should reject invalid active filter" do
get api_v1_rules_url, params: { active: "not_boolean" }, headers: api_headers(@api_key)
assert_response :unprocessable_entity
json_response = JSON.parse(response.body)
assert_equal "validation_failed", json_response["error"]
end
test "should reject unsupported resource type filter" do
get api_v1_rules_url, params: { resource_type: "account" }, headers: api_headers(@api_key)
assert_response :unprocessable_entity
json_response = JSON.parse(response.body)
assert_equal "validation_failed", json_response["error"]
end
test "should show rule with conditions and actions" do
get api_v1_rule_url(@rule), headers: api_headers(@api_key)
assert_response :success
rule = JSON.parse(response.body)["data"]
assert_equal @rule.id, rule["id"]
assert_equal "Coffee cleanup", rule["name"]
assert_equal "transaction", rule["resource_type"]
assert_equal true, rule["active"]
assert_equal "2024-01-01", rule["effective_date"]
assert_equal 1, rule["conditions"].length
assert_equal "transaction_name", rule["conditions"].first["condition_type"]
assert_equal "like", rule["conditions"].first["operator"]
assert_equal "coffee", rule["conditions"].first["value"]
assert_equal 1, rule["actions"].length
assert_equal "set_transaction_name", rule["actions"].first["action_type"]
assert_equal "Coffee", rule["actions"].first["value"]
end
test "should require authentication when showing a rule" do
get api_v1_rule_url(@rule)
assert_response :unauthorized
end
test "should require read scope when showing a rule" do
api_key_without_read = api_key_without_read_scope
get api_v1_rule_url(@rule), headers: api_headers(api_key_without_read)
assert_response :forbidden
json_response = JSON.parse(response.body)
assert_equal "insufficient_scope", json_response["error"]
ensure
api_key_without_read&.destroy
end
test "should not show another family's rule" do
other_family = Family.create!(name: "Other Family", currency: "USD", locale: "en")
other_rule = other_family.rules.build(name: "Other", resource_type: "transaction", active: true)
other_rule.conditions.build(condition_type: "transaction_name", operator: "like", value: "other")
other_rule.actions.build(action_type: "set_transaction_name", value: "Other")
other_rule.save!
get api_v1_rule_url(other_rule), headers: api_headers(@api_key)
assert_response :not_found
json_response = JSON.parse(response.body)
assert_equal "record_not_found", json_response["error"]
end
private
def api_key_without_read_scope
# Valid persisted API keys can only be read/read_write; this intentionally
# bypasses validations to exercise the runtime insufficient-scope guard.
ApiKey.new(
user: @user,
name: "No Read Key",
scopes: [],
display_key: "test_no_read_#{SecureRandom.hex(8)}",
source: "mobile"
).tap { |api_key| api_key.save!(validate: false) }
end
def api_headers(api_key)
{ "X-Api-Key" => api_key.plain_key }
end
end

View File

@@ -0,0 +1,182 @@
# frozen_string_literal: true
require "test_helper"
class Api::V1::SecuritiesControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:family_admin)
@family = @user.family
@user.api_keys.active.destroy_all
@api_key = ApiKey.create!(
user: @user,
name: "Test Read Key",
scopes: [ "read" ],
source: "web",
display_key: "test_read_#{SecureRandom.hex(8)}"
)
@account = accounts(:investment)
@holding_security = securities(:aapl)
@holding_ticker = @holding_security.ticker
@trade_ticker = "AAPL#{SecureRandom.hex(4).upcase}"
@trade_security = Security.create!(
ticker: @trade_ticker,
name: "Apple Inc.",
country_code: "US",
exchange_operating_mic: "XNAS"
)
@account.entries.create!(
name: "Buy AAPL",
date: Date.parse("2024-01-16"),
amount: 1800,
currency: "USD",
entryable: Trade.new(
security: @trade_security,
qty: 10,
price: 180,
currency: "USD"
)
)
@unreferenced_security = Security.create!(ticker: "MSFT#{SecureRandom.hex(4).upcase}", name: "Microsoft Corp.", country_code: "US")
other_account = families(:empty).accounts.create!(
name: "Other Investment Account",
accountable: Investment.new,
balance: 1000,
currency: "USD"
)
@other_security = Security.create!(ticker: "GOOG#{SecureRandom.hex(4).upcase}", name: "Alphabet Inc.", country_code: "US")
other_account.holdings.create!(
security: @other_security,
date: Date.parse("2024-01-15"),
qty: 1,
price: 100,
amount: 100,
currency: "USD"
)
end
test "lists securities referenced by accessible family investment data" do
get api_v1_securities_url, headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
security_ids = response_data["securities"].map { |security| security["id"] }
assert_includes security_ids, @holding_security.id
assert_includes security_ids, @trade_security.id
assert_not_includes security_ids, @unreferenced_security.id
assert_not_includes security_ids, @other_security.id
assert response_data.key?("pagination")
end
test "shows a scoped security" do
get api_v1_security_url(@holding_security), headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_equal @holding_security.id, response_data["id"]
assert_equal @holding_ticker, response_data["ticker"]
assert_equal @holding_security.exchange_operating_mic, response_data["exchange_operating_mic"]
assert_equal "standard", response_data["kind"]
assert_not response_data.key?("price_provider")
end
test "returns not found for another family's security" do
get api_v1_security_url(@other_security), headers: api_headers(@api_key)
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "record_not_found", response_data["error"]
end
test "returns not found for malformed security id" do
get api_v1_security_url("not-a-uuid"), headers: api_headers(@api_key)
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "record_not_found", response_data["error"]
end
test "filters securities by ticker" do
get api_v1_securities_url, params: { ticker: @trade_ticker.downcase }, headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_equal [ @trade_security.id ], response_data["securities"].map { |security| security["id"] }
end
test "filters securities by exchange operating mic" do
get api_v1_securities_url, params: { exchange_operating_mic: " xnas " }, headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_equal [ @holding_security.id, @trade_security.id ], response_data["securities"].map { |security| security["id"] }
end
test "caps per_page at documented maximum" do
get api_v1_securities_url, params: { per_page: 250 }, headers: api_headers(@api_key)
assert_response :success
assert_equal 100, JSON.parse(response.body).dig("pagination", "per_page")
end
test "rejects invalid kind filter" do
get api_v1_securities_url, params: { kind: "unsupported" }, headers: api_headers(@api_key)
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
end
test "rejects malformed offline filter" do
get api_v1_securities_url, params: { offline: "maybe" }, headers: api_headers(@api_key)
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
assert_includes response_data["errors"], "offline must be true or false"
end
test "rejects blank offline filter" do
get api_v1_securities_url, params: { offline: "" }, headers: api_headers(@api_key)
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
assert_includes response_data["errors"], "offline must be true or false"
end
test "requires authentication" do
get api_v1_securities_url
assert_response :unauthorized
end
test "requires read scope" do
api_key_without_read = ApiKey.new(
user: @user,
name: "No Read Key",
scopes: [],
source: "web",
display_key: "no_read_#{SecureRandom.hex(8)}"
)
api_key_without_read.save!(validate: false)
get api_v1_securities_url, headers: api_headers(api_key_without_read)
assert_response :forbidden
ensure
api_key_without_read&.destroy
end
private
def api_headers(api_key)
{ "X-Api-Key" => api_key.plain_key }
end
end

View File

@@ -0,0 +1,196 @@
# frozen_string_literal: true
require "test_helper"
class Api::V1::SecurityPricesControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:family_admin)
@family = @user.family
@user.api_keys.active.destroy_all
@api_key = ApiKey.create!(
user: @user,
name: "Test Read Key",
scopes: [ "read" ],
source: "web",
display_key: "test_read_#{SecureRandom.hex(8)}"
)
@account = accounts(:investment)
@security = securities(:aapl)
@ticker = @security.ticker
@security_price = security_prices(:one)
@eur_price = Security::Price.create!(
security: @security,
date: @security_price.date,
price: BigDecimal("250.5000"),
currency: "EUR"
)
other_account = families(:empty).accounts.create!(
name: "Other Investment Account",
accountable: Investment.new,
balance: 1000,
currency: "USD"
)
@other_security = Security.create!(ticker: "GOOG#{SecureRandom.hex(4).upcase}", name: "Alphabet Inc.", country_code: "US")
other_account.holdings.create!(
security: @other_security,
date: Date.parse("2024-01-15"),
qty: 1,
price: 100,
amount: 100,
currency: "USD"
)
@other_price = Security::Price.create!(
security: @other_security,
date: Date.parse("2024-01-15"),
price: 100,
currency: "USD"
)
end
test "lists prices for securities referenced by accessible family investment data" do
get api_v1_security_prices_url, headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
price_ids = response_data["security_prices"].map { |price| price["id"] }
assert_includes price_ids, @security_price.id
assert_not_includes price_ids, @other_price.id
assert response_data.key?("pagination")
end
test "shows a scoped security price" do
get api_v1_security_price_url(@security_price), headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_equal @security_price.id, response_data["id"]
assert_equal @security_price.date.iso8601, response_data["date"]
assert_equal "215.0000", response_data["price_amount"]
assert_equal @security.id, response_data.dig("security", "id")
end
test "returns not found for another family's security price" do
get api_v1_security_price_url(@other_price), headers: api_headers(@api_key)
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "record_not_found", response_data["error"]
end
test "returns not found for malformed security price id" do
get api_v1_security_price_url("not-a-uuid"), headers: api_headers(@api_key)
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "record_not_found", response_data["error"]
end
test "filters security prices by security_id" do
get api_v1_security_prices_url, params: { security_id: @security.id }, headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_includes response_data["security_prices"].map { |price| price["id"] }, @security_price.id
assert response_data["security_prices"].all? { |price| price.dig("security", "id") == @security.id }
end
test "filters security prices by date range and provisional status" do
get api_v1_security_prices_url,
params: { start_date: @security_price.date.iso8601, end_date: @security_price.date.iso8601, currency: "USD", provisional: false },
headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_equal [ @security_price.id ], response_data["security_prices"].map { |price| price["id"] }
end
test "rejects blank provisional filter" do
get api_v1_security_prices_url,
params: { provisional: "" },
headers: api_headers(@api_key)
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
assert_includes response_data["errors"], "provisional must be true or false"
end
test "filters security prices by currency" do
get api_v1_security_prices_url,
params: { currency: " usd " },
headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_includes response_data["security_prices"].map { |price| price["id"] }, @security_price.id
assert_not_includes response_data["security_prices"].map { |price| price["id"] }, @eur_price.id
end
test "rejects malformed provisional filter" do
get api_v1_security_prices_url,
params: { provisional: "maybe" },
headers: api_headers(@api_key)
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
assert_includes response_data["errors"], "provisional must be true or false"
end
test "caps per_page at documented maximum" do
get api_v1_security_prices_url, params: { per_page: 250 }, headers: api_headers(@api_key)
assert_response :success
assert_equal 100, JSON.parse(response.body).dig("pagination", "per_page")
end
test "rejects malformed security_id filter" do
get api_v1_security_prices_url, params: { security_id: "not-a-uuid" }, headers: api_headers(@api_key)
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
end
test "rejects invalid date filters" do
get api_v1_security_prices_url, params: { start_date: "01/15/2024" }, headers: api_headers(@api_key)
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
end
test "requires authentication" do
get api_v1_security_prices_url
assert_response :unauthorized
end
test "requires read scope" do
api_key_without_read = ApiKey.new(
user: @user,
name: "No Read Key",
scopes: [],
source: "web",
display_key: "no_read_#{SecureRandom.hex(8)}"
)
api_key_without_read.save!(validate: false)
get api_v1_security_prices_url, headers: api_headers(api_key_without_read)
assert_response :forbidden
ensure
api_key_without_read&.destroy
end
private
def api_headers(api_key)
{ "X-Api-Key" => api_key.plain_key }
end
end

View File

@@ -33,7 +33,7 @@ class Api::V1::SyncControllerTest < ActionDispatch::IntegrationTest
test "should trigger sync with valid write API key" do
assert_enqueued_with(job: SyncJob) do
post api_v1_sync_url, headers: api_headers(@api_key)
post api_v1_sync_job_url, headers: api_headers(@api_key)
end
assert_response :accepted
@@ -48,7 +48,7 @@ class Api::V1::SyncControllerTest < ActionDispatch::IntegrationTest
end
test "should reject sync with read-only API key" do
post api_v1_sync_url, headers: api_headers(@read_only_api_key)
post api_v1_sync_job_url, headers: api_headers(@read_only_api_key)
assert_response :forbidden
response_data = JSON.parse(response.body)
@@ -56,7 +56,7 @@ class Api::V1::SyncControllerTest < ActionDispatch::IntegrationTest
end
test "should reject sync without API key" do
post api_v1_sync_url
post api_v1_sync_job_url
assert_response :unauthorized
response_data = JSON.parse(response.body)
@@ -64,7 +64,7 @@ class Api::V1::SyncControllerTest < ActionDispatch::IntegrationTest
end
test "should return proper sync details in response" do
post api_v1_sync_url, headers: api_headers(@api_key)
post api_v1_sync_job_url, headers: api_headers(@api_key)
assert_response :accepted
response_data = JSON.parse(response.body)

View File

@@ -0,0 +1,211 @@
# frozen_string_literal: true
require "test_helper"
class Api::V1::SyncsControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:family_admin)
@family = @user.family
@account = @family.accounts.first
Sync.for_family(@family).destroy_all
@user.api_keys.active.destroy_all
@api_key = ApiKey.create!(
user: @user,
name: "Test Read-Write Key",
scopes: [ "read_write" ],
display_key: "test_rw_#{SecureRandom.hex(8)}",
source: "web"
)
@read_only_api_key = ApiKey.create!(
user: @user,
name: "Test Read Key",
scopes: [ "read" ],
display_key: "test_ro_#{SecureRandom.hex(8)}",
source: "mobile"
)
redis = Redis.new
redis.del("api_rate_limit:#{@api_key.id}")
redis.del("api_rate_limit:#{@read_only_api_key.id}")
redis.close
end
test "lists family scoped syncs" do
family_sync = Sync.create!(syncable: @family, status: "completed", completed_at: 1.hour.ago)
account_sync = Sync.create!(syncable: @account, status: "syncing", syncing_at: Time.current)
other_sync = Sync.create!(syncable: families(:empty), status: "completed", completed_at: 1.hour.ago)
get api_v1_syncs_url, headers: api_headers(@read_only_api_key)
assert_response :success
json_response = JSON.parse(response.body)
sync_ids = json_response["data"].map { |sync| sync["id"] }
assert_includes sync_ids, family_sync.id
assert_includes sync_ids, account_sync.id
assert_not_includes sync_ids, other_sync.id
assert_equal 2, json_response["meta"]["total_count"]
end
test "does not list account syncs outside caller account access" do
private_account = @family.accounts.create!(
owner: @user,
name: "Private Sync Account",
balance: 0,
currency: "USD",
accountable: Depository.new
)
inaccessible_sync = Sync.create!(syncable: private_account, status: "completed", completed_at: 1.hour.ago)
@read_only_api_key.update_column(:user_id, users(:family_member).id)
get api_v1_syncs_url, headers: api_headers(@read_only_api_key)
assert_response :success
sync_ids = JSON.parse(response.body)["data"].map { |sync| sync["id"] }
assert_not_includes sync_ids, inaccessible_sync.id
end
test "shows a sync" do
sync = Sync.create!(
syncable: @family,
status: "completed",
completed_at: 1.hour.ago,
window_start_date: Date.current - 7.days,
window_end_date: Date.current
)
get api_v1_sync_url(sync), headers: api_headers(@read_only_api_key)
assert_response :success
data = JSON.parse(response.body)["data"]
assert_equal sync.id, data["id"]
assert_equal "completed", data["status"]
assert_equal false, data["in_progress"]
assert_equal true, data["terminal"]
assert_equal "Family", data["syncable"]["type"]
assert_equal @family.id, data["syncable"]["id"]
assert_nil data["error"]
end
test "returns latest sync" do
Sync.create!(syncable: @family, status: "completed", created_at: 2.hours.ago, completed_at: 2.hours.ago)
latest_sync = Sync.create!(syncable: @account, status: "pending", created_at: 1.minute.ago)
get latest_api_v1_syncs_url, headers: api_headers(@read_only_api_key)
assert_response :success
assert_equal latest_sync.id, JSON.parse(response.body)["data"]["id"]
end
test "latest returns null data when no sync exists" do
get latest_api_v1_syncs_url, headers: api_headers(@read_only_api_key)
assert_response :success
assert_nil JSON.parse(response.body)["data"]
end
test "does not expose raw sync errors" do
sync = Sync.create!(
syncable: @family,
status: "failed",
failed_at: Time.current,
error: "provider token secret leaked"
)
get api_v1_sync_url(sync), headers: api_headers(@read_only_api_key)
assert_response :success
data = JSON.parse(response.body)["data"]
assert data["error"].present?
assert_equal "Sync failed", data["error"]["message"]
refute_includes response.body, "provider token secret leaked"
end
test "reports failed sync errors as present without raw error text" do
sync = Sync.create!(
syncable: @family,
status: "failed",
failed_at: Time.current,
error: nil
)
get api_v1_sync_url(sync), headers: api_headers(@read_only_api_key)
assert_response :success
assert JSON.parse(response.body).dig("data", "error").present?
assert_equal "Sync failed", JSON.parse(response.body).dig("data", "error", "message")
end
test "omits stale sync error payload when no error is present" do
sync = Sync.create!(
syncable: @family,
status: "stale"
)
get api_v1_sync_url(sync), headers: api_headers(@read_only_api_key)
assert_response :success
assert_nil JSON.parse(response.body).dig("data", "error")
end
test "returns not found for another family sync" do
sync = Sync.create!(syncable: families(:empty), status: "completed")
get api_v1_sync_url(sync), headers: api_headers(@read_only_api_key)
assert_response :not_found
assert_equal "record_not_found", JSON.parse(response.body)["error"]
end
test "returns not found for malformed sync id" do
get api_v1_sync_url("not-a-uuid"), headers: api_headers(@read_only_api_key)
assert_response :not_found
assert_equal "record_not_found", JSON.parse(response.body)["error"]
end
test "index requires authentication" do
get api_v1_syncs_url
assert_response :unauthorized
end
test "latest requires authentication" do
get latest_api_v1_syncs_url
assert_response :unauthorized
end
test "show requires authentication" do
sync = Sync.create!(syncable: @family, status: "completed", completed_at: 1.hour.ago)
get api_v1_sync_url(sync)
assert_response :unauthorized
end
test "index requires read scope" do
api_key_without_read = ApiKey.new(
user: @user,
name: "No Read Key",
scopes: [],
source: "monitoring",
display_key: "no_read_#{SecureRandom.hex(8)}"
)
api_key_without_read.save!(validate: false)
get api_v1_syncs_url, headers: api_headers(api_key_without_read)
assert_response :forbidden
ensure
api_key_without_read&.destroy
end
private
def api_headers(api_key)
{ "X-Api-Key" => api_key.plain_key }
end
end

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

@@ -0,0 +1,203 @@
# frozen_string_literal: true
require "test_helper"
class Api::V1::TransfersControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:family_admin)
@family = @user.family
@user.api_keys.active.destroy_all
@api_key = ApiKey.create!(
user: @user,
name: "Test Read Key",
scopes: [ "read" ],
source: "web",
display_key: "test_read_#{SecureRandom.hex(8)}"
)
@account = @family.accounts.create!(
name: "Transfer Checking",
accountable: Depository.new,
balance: 500,
currency: "USD"
)
@destination_account = @family.accounts.create!(
name: "Transfer Savings",
accountable: Depository.new,
balance: 1000,
currency: "USD"
)
outflow = create_transaction(@account, amount: 100, date: Date.parse("2024-01-15"), name: "Transfer to savings")
inflow = create_transaction(@destination_account, amount: -100, date: Date.parse("2024-01-15"), name: "Transfer from checking")
@transfer = Transfer.create!(
outflow_transaction: outflow,
inflow_transaction: inflow,
status: "confirmed",
notes: "Confirmed by user"
)
other_family = families(:empty)
other_account = other_family.accounts.create!(name: "Other Checking", accountable: Depository.new, balance: 0, currency: "USD")
other_destination = other_family.accounts.create!(name: "Other Savings", accountable: Depository.new, balance: 0, currency: "USD")
other_outflow = create_transaction(other_account, amount: 50, date: Date.parse("2024-01-15"), name: "Other outflow")
other_inflow = create_transaction(other_destination, amount: -50, date: Date.parse("2024-01-15"), name: "Other inflow")
@other_transfer = Transfer.create!(outflow_transaction: other_outflow, inflow_transaction: other_inflow)
end
test "lists transfers scoped to the current family" do
get api_v1_transfers_url, headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert response_data.key?("transfers")
assert response_data.key?("pagination")
assert_includes response_data["transfers"].map { |transfer| transfer["id"] }, @transfer.id
assert_not_includes response_data["transfers"].map { |transfer| transfer["id"] }, @other_transfer.id
end
test "permits read write scope" do
read_write_key = ApiKey.create!(
user: @user,
name: "Test Read Write Key",
scopes: [ "read_write" ],
source: "mobile",
display_key: "test_read_write_#{SecureRandom.hex(8)}"
)
get api_v1_transfers_url, headers: api_headers(read_write_key)
assert_response :success
end
test "shows a transfer" do
get api_v1_transfer_url(@transfer), headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_equal @transfer.id, response_data["id"]
assert_equal "confirmed", response_data["status"]
assert_equal "Confirmed by user", response_data["notes"]
assert_equal "Transfer Savings", response_data.dig("inflow_transaction", "account", "name")
assert_equal "Transfer Checking", response_data.dig("outflow_transaction", "account", "name")
assert response_data.key?("amount_cents")
end
test "returns not found for another family's transfer" do
get api_v1_transfer_url(@other_transfer), headers: api_headers(@api_key)
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "record_not_found", response_data["error"]
end
test "returns not found for malformed transfer id" do
get api_v1_transfer_url("not-a-uuid"), headers: api_headers(@api_key)
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "record_not_found", response_data["error"]
end
test "filters transfers by status" do
get api_v1_transfers_url, params: { status: "confirmed" }, headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_equal [ @transfer.id ], response_data["transfers"].map { |transfer| transfer["id"] }
end
test "filters transfers by account_id" do
get api_v1_transfers_url, params: { account_id: @account.id }, headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_includes response_data["transfers"].map { |transfer| transfer["id"] }, @transfer.id
end
test "rejects malformed account_id filter" do
get api_v1_transfers_url, params: { account_id: "not-a-uuid" }, headers: api_headers(@api_key)
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
end
test "rejects invalid status filter" do
get api_v1_transfers_url, params: { status: "settled" }, headers: api_headers(@api_key)
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
end
test "rejects invalid date filter" do
get api_v1_transfers_url, params: { start_date: "01/15/2024" }, headers: api_headers(@api_key)
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
end
test "filters transfers when either transaction side date matches" do
matched_outflow = create_transaction(@account, amount: 75, date: Date.parse("2024-02-10"), name: "Dated outflow")
matched_inflow = create_transaction(@destination_account, amount: -75, date: Date.parse("2024-02-10"), name: "Dated inflow")
date_matched_transfer = Transfer.create!(outflow_transaction: matched_outflow, inflow_transaction: matched_inflow)
partial_outflow = create_transaction(@account, amount: 80, date: Date.parse("2024-02-10"), name: "Partial outflow")
partial_inflow = create_transaction(@destination_account, amount: -80, date: Date.parse("2024-02-12"), name: "Partial inflow")
partial_date_transfer = Transfer.create!(outflow_transaction: partial_outflow, inflow_transaction: partial_inflow)
get api_v1_transfers_url,
params: { start_date: "2024-02-10", end_date: "2024-02-10" },
headers: api_headers(@api_key)
assert_response :success
transfer_ids = JSON.parse(response.body)["transfers"].map { |transfer| transfer["id"] }
assert_includes transfer_ids, date_matched_transfer.id
assert_includes transfer_ids, partial_date_transfer.id
assert_not_includes transfer_ids, @transfer.id
end
test "requires authentication" do
get api_v1_transfers_url
assert_response :unauthorized
end
test "requires read scope" do
# ApiKey.create! rejects empty scopes; bypass validation to exercise runtime authorization.
api_key_without_read = ApiKey.new(
user: @user,
name: "No Read Key",
scopes: [],
source: "mobile",
display_key: "no_read_#{SecureRandom.hex(8)}"
)
api_key_without_read.save!(validate: false)
get api_v1_transfers_url, headers: api_headers(api_key_without_read)
assert_response :forbidden
ensure
api_key_without_read&.destroy
end
private
def create_transaction(account, amount:, date:, name:)
entry = account.entries.create!(
date: date,
amount: amount,
name: name,
currency: account.currency,
entryable: Transaction.new(kind: "funds_movement")
)
entry.entryable
end
def api_headers(api_key)
{ "X-Api-Key" => api_key.display_key }
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

@@ -12,6 +12,7 @@ class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest
user: @user,
name: "Test Read-Write Key",
scopes: [ "read_write" ],
source: "web",
display_key: "test_rw_#{SecureRandom.hex(8)}"
)
@@ -56,6 +57,7 @@ class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest
user: users(:family_member),
name: "Member Read-Write Key",
scopes: [ "read_write" ],
source: "web",
display_key: "test_member_#{SecureRandom.hex(8)}"
)
@@ -69,13 +71,65 @@ class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest
end
test "reset enqueues FamilyResetJob and returns 200" do
assert_enqueued_with(job: FamilyResetJob) do
assert_enqueued_with(job: FamilyResetJob, args: [ @user.family ]) do
delete "/api/v1/users/reset", headers: api_headers(@api_key)
end
assert_response :ok
body = JSON.parse(response.body)
assert_equal "Account reset has been initiated", body["message"]
assert_equal "queued", body["status"]
assert_equal @user.family.id, body["family_id"]
assert body["job_id"].present?
assert_equal "/api/v1/users/reset/status", body["status_url"]
end
test "reset returns controlled error when enqueue fails" do
FamilyResetJob.stub(:perform_later, ->(_family) { raise StandardError, "queue down" }) do
delete "/api/v1/users/reset", headers: api_headers(@api_key)
end
assert_response :internal_server_error
body = JSON.parse(response.body)
assert_equal "reset_enqueue_failed", body["error"]
assert_equal "Account reset could not be queued", body["message"]
assert_not_includes response.body, "queue down"
end
test "reset status requires authentication" do
get "/api/v1/users/reset/status"
assert_response :unauthorized
end
test "reset status requires admin role" do
non_admin_api_key = ApiKey.create!(
user: users(:family_member),
name: "Member Read Key",
scopes: [ "read_write" ],
source: "web",
display_key: "test_member_read_#{SecureRandom.hex(8)}"
)
get "/api/v1/users/reset/status", headers: api_headers(non_admin_api_key)
assert_response :forbidden
end
test "reset status returns family data counts" do
get "/api/v1/users/reset/status", headers: api_headers(@read_only_api_key)
assert_response :ok
body = JSON.parse(response.body)
assert_equal @user.family.id, body["family_id"]
assert_includes %w[complete data_remaining], body["status"]
assert_equal body["counts"].values.sum.zero?, body["reset_complete"]
assert body["counts"].key?("accounts")
assert body["counts"].key?("categories")
assert body["counts"].key?("tags")
assert body["counts"].key?("merchants")
assert body["counts"].key?("plaid_items")
assert body["counts"].key?("imports")
assert body["counts"].key?("budgets")
end
# -- Delete account --------------------------------------------------------
@@ -92,6 +146,7 @@ class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest
user: solo_user,
name: "Solo Key",
scopes: [ "read_write" ],
source: "web",
display_key: "test_solo_#{SecureRandom.hex(8)}"
)
@@ -129,6 +184,6 @@ class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest
private
def api_headers(api_key)
{ "X-Api-Key" => api_key.display_key }
{ "X-Api-Key" => api_key.plain_key }
end
end

View File

@@ -17,6 +17,7 @@ class Api::V1::ValuationsControllerTest < ActionDispatch::IntegrationTest
user: @user,
name: "Test Read-Write Key",
scopes: [ "read_write" ],
source: "web",
display_key: "test_rw_#{SecureRandom.hex(8)}"
)
@@ -33,6 +34,92 @@ class Api::V1::ValuationsControllerTest < ActionDispatch::IntegrationTest
Redis.new.del("api_rate_limit:#{@read_only_api_key.id}")
end
# INDEX action tests
test "should get index with valid API key" do
get api_v1_valuations_url, headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert response_data.key?("valuations")
assert response_data.key?("pagination")
assert response_data["valuations"].is_a?(Array)
assert response_data["pagination"].key?("page")
assert response_data["pagination"].key?("per_page")
assert response_data["pagination"].key?("total_count")
assert response_data["pagination"].key?("total_pages")
end
test "should get index with read-only API key" do
get api_v1_valuations_url, headers: api_headers(@read_only_api_key)
assert_response :success
end
test "should filter index by account_id" do
get api_v1_valuations_url,
params: { account_id: @account.id },
headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
response_data["valuations"].each do |valuation|
assert_equal @account.id, valuation["account"]["id"]
end
end
test "should filter index by date range" do
entry = @valuation.entry
get api_v1_valuations_url,
params: { start_date: entry.date, end_date: entry.date },
headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_includes response_data["valuations"].map { |valuation| valuation["id"] }, entry.id
response_data["valuations"].each do |valuation|
valuation_date = Date.iso8601(valuation["date"])
assert_equal entry.date, valuation_date
end
end
test "should reject index with invalid date filter" do
get api_v1_valuations_url,
params: { start_date: "04/30/2026" },
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 index with malformed account_id filter" do
get api_v1_valuations_url,
params: { account_id: "not-a-uuid" },
headers: api_headers(@api_key)
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
assert_equal "account_id must be a valid UUID", response_data["message"]
end
test "should not expose internal index errors" do
Api::V1::ValuationsController.any_instance.stubs(:safe_page_param).raises(StandardError, "database password leaked")
get api_v1_valuations_url, headers: api_headers(@api_key)
assert_response :internal_server_error
response_data = JSON.parse(response.body)
assert_equal "internal_server_error", response_data["error"]
assert_equal "An unexpected error occurred", response_data["message"]
assert_not_includes response.body, "database password leaked"
end
test "should reject index without API key" do
get api_v1_valuations_url
assert_response :unauthorized
end
# CREATE action tests
test "should create valuation with valid parameters" do
valuation_params = {
@@ -56,6 +143,81 @@ class Api::V1::ValuationsControllerTest < ActionDispatch::IntegrationTest
assert_equal @account.id, response_data["account"]["id"]
end
test "should upsert valuation for same account and date when requested" do
existing_entry = @valuation.entry
valuation_params = {
upsert: "true",
valuation: {
account_id: existing_entry.account.id,
amount: 12_345.67,
date: existing_entry.date,
notes: "API correction"
}
}
assert_no_difference("@family.entries.valuations.count") do
post api_v1_valuations_url,
params: valuation_params,
headers: api_headers(@api_key)
end
assert_response :ok
response_data = JSON.parse(response.body)
assert_equal existing_entry.id, response_data["id"]
assert_equal existing_entry.date.to_s, response_data["date"]
assert_equal "API correction", response_data["notes"]
assert_equal BigDecimal("12345.67"), existing_entry.reload.amount
end
test "should create valuation when upsert is requested without an existing same-date valuation" do
valuation_date = Date.current + 3.days
valuation_params = {
upsert: "true",
valuation: {
account_id: @account.id,
amount: 9876.54,
date: valuation_date,
notes: "New API valuation"
}
}
assert_difference("@family.entries.valuations.count", 1) do
post api_v1_valuations_url,
params: valuation_params,
headers: api_headers(@api_key)
end
assert_response :created
response_data = JSON.parse(response.body)
assert_equal valuation_date.to_s, response_data["date"]
assert_equal "New API valuation", response_data["notes"]
end
test "should accept nested upsert flag for same-date valuation writes" do
existing_entry = @valuation.entry
valuation_params = {
valuation: {
account_id: existing_entry.account.id,
amount: 22_222.22,
date: existing_entry.date,
notes: "Nested upsert correction",
upsert: "true"
}
}
assert_no_difference("@family.entries.valuations.count") do
post api_v1_valuations_url,
params: valuation_params,
headers: api_headers(@api_key)
end
assert_response :ok
response_data = JSON.parse(response.body)
assert_equal existing_entry.id, response_data["id"]
assert_equal "Nested upsert correction", response_data["notes"]
assert_equal BigDecimal("22222.22"), existing_entry.reload.amount
end
test "should reject create with read-only API key" do
valuation_params = {
valuation: {
@@ -207,6 +369,6 @@ class Api::V1::ValuationsControllerTest < ActionDispatch::IntegrationTest
private
def api_headers(api_key)
{ "X-Api-Key" => api_key.display_key }
{ "X-Api-Key" => api_key.plain_key }
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

@@ -2,6 +2,7 @@ require "test_helper"
class BudgetCategoriesControllerTest < ActionDispatch::IntegrationTest
include ActionView::RecordIdentifier
include EntriesTestHelper
setup do
sign_in users(:family_admin)
@@ -50,6 +51,19 @@ class BudgetCategoriesControllerTest < ActionDispatch::IntegrationTest
)
end
test "index marks budget form values as privacy-sensitive" do
parent_form_selector = "##{dom_id(@parent_budget_category, :form)}"
uncategorized_form_selector = "##{dom_id(@budget, :uncategorized_budget_category_form)}"
get budget_budget_categories_path(@budget)
assert_response :success
assert_select "#{parent_form_selector} .privacy-sensitive.privacy-sensitive-interactive input##{dom_id(@parent_budget_category, :budgeted_spending)}"
assert_select "#{parent_form_selector} p.text-secondary.privacy-sensitive", text: /\/m avg/
assert_select "#{uncategorized_form_selector} .privacy-sensitive input[name='uncategorized']"
assert_select "#{uncategorized_form_selector} p.text-secondary.privacy-sensitive", text: /\/m avg/
end
test "updating a subcategory adjusts the parent budget by the same delta" do
assert_changes -> { @parent_budget_category.reload.budgeted_spending.to_f }, from: 500.0, to: 550.0 do
patch budget_budget_category_path(@budget, @electric_budget_category),
@@ -94,4 +108,58 @@ class BudgetCategoriesControllerTest < ActionDispatch::IntegrationTest
assert_equal 0.0, @electric_budget_category.reload.budgeted_spending.to_f
end
test "show drilldown excludes BUDGET_EXCLUDED_KINDS transfers from recent transactions" do
# Issue #1059: a matched depository <-> CC pair becomes
# (cc_payment outflow + funds_movement inflow). Both kinds are in
# BUDGET_EXCLUDED_KINDS so the budget aggregate excludes them, but
# the per-category drilldown previously listed them anyway --
# appearing under whatever category they retained (or under
# Uncategorized once the matcher cleared the category). Filter
# them out so the drilldown matches the aggregate.
create_transaction(
date: @budget.start_date,
account: accounts(:depository),
amount: 500,
name: "BUG_1059_REPRO_OUTFLOW"
)
create_transaction(
date: @budget.start_date,
account: accounts(:credit_card),
amount: -500,
name: "BUG_1059_REPRO_INFLOW"
)
@family.auto_match_transfers!
get budget_budget_category_path(@budget, BudgetCategory.uncategorized.id)
assert_response :success
refute_includes @response.body, "BUG_1059_REPRO_OUTFLOW",
"matched cc_payment outflow must not appear in Uncategorized drilldown"
refute_includes @response.body, "BUG_1059_REPRO_INFLOW",
"matched funds_movement inflow must not appear in Uncategorized drilldown"
end
test "show drilldown still lists loan_payment transfers (intentionally budget-tracked)" do
# loan_payment is NOT in BUDGET_EXCLUDED_KINDS. The drilldown should
# keep showing loan_payment transfers so the user can see what's
# under Uncategorized (or whichever category they manually set).
create_transaction(
date: @budget.start_date,
account: accounts(:depository),
amount: 500,
name: "MORTGAGE_REPRO_OUTFLOW"
)
create_transaction(
date: @budget.start_date,
account: accounts(:loan),
amount: -500,
name: "MORTGAGE_REPRO_INFLOW"
)
@family.auto_match_transfers!
get budget_budget_category_path(@budget, BudgetCategory.uncategorized.id)
assert_response :success
assert_includes @response.body, "MORTGAGE_REPRO_OUTFLOW",
"loan_payment outflow remains visible (kind is not BUDGET_EXCLUDED)"
end
end

View File

@@ -4,11 +4,15 @@ class CategoriesControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in users(:family_admin)
@transaction = transactions :one
ensure_tailwind_build
end
test "index" do
get categories_url
assert_response :success
assert_select "#category_#{categories(:food_and_drink).id} > [data-testid='category-content']", count: 1
assert_select "#category_#{categories(:food_and_drink).id} > [data-testid='category-actions']", count: 1
assert_select "#category_#{categories(:food_and_drink).id} [data-testid='category-name']", text: categories(:food_and_drink).name
end
test "new" do

View File

@@ -4,11 +4,14 @@ class Category::DeletionsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in users(:family_admin)
@category = categories(:food_and_drink)
ensure_tailwind_build
end
test "new" do
get new_category_deletion_url(@category)
assert_response :success
assert_select "turbo-frame#modal"
assert_select "turbo-frame#modal button span.min-w-0.truncate", text: /Delete "Food & Drink" and leave uncategorized/
end
test "create with replacement" do

View File

@@ -9,9 +9,7 @@ class CoinstatsItemsControllerTest < ActionDispatch::IntegrationTest
name: "Test CoinStats Connection",
api_key: "test_api_key_123"
)
tailwind_build = Rails.root.join("app/assets/builds/tailwind.css")
FileUtils.mkdir_p(tailwind_build.dirname)
File.write(tailwind_build, "/* test */") unless tailwind_build.exist?
ensure_tailwind_build
end
# Helper to wrap data in Provider::Response

View File

@@ -0,0 +1,173 @@
require "test_helper"
class ExchangeRatesControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:family_admin)
sign_in @user
end
test "returns rate for different currencies" do
ExchangeRate.create!(
from_currency: "EUR",
to_currency: "USD",
date: Date.current,
rate: 1.2
)
get exchange_rate_url, params: {
from: "EUR",
to: "USD",
date: Date.current
}
assert_response :success
json_response = JSON.parse(response.body)
assert_equal 1.2, json_response["rate"]
end
test "returns same_currency flag for matching currencies" do
get exchange_rate_url, params: {
from: "USD",
to: "USD"
}
assert_response :success
json_response = JSON.parse(response.body)
assert json_response["same_currency"]
assert_equal 1.0, json_response["rate"]
end
test "uses provided date for rate lookup" do
custom_date = 3.days.ago.to_date
ExchangeRate.create!(
from_currency: "EUR",
to_currency: "USD",
date: custom_date,
rate: 1.25
)
get exchange_rate_url, params: {
from: "EUR",
to: "USD",
date: custom_date
}
assert_response :success
json_response = JSON.parse(response.body)
assert_equal 1.25, json_response["rate"]
end
test "defaults to current date when not provided" do
ExchangeRate.create!(
from_currency: "EUR",
to_currency: "USD",
date: Date.current,
rate: 1.2
)
get exchange_rate_url, params: {
from: "EUR",
to: "USD"
}
assert_response :success
json_response = JSON.parse(response.body)
assert_equal 1.2, json_response["rate"]
end
test "returns 400 when from currency is missing" do
get exchange_rate_url, params: {
to: "USD"
}
assert_response :bad_request
json_response = JSON.parse(response.body)
assert_equal "from and to currencies are required", json_response["error"]
end
test "returns 400 when to currency is missing" do
get exchange_rate_url, params: {
from: "EUR"
}
assert_response :bad_request
json_response = JSON.parse(response.body)
assert_equal "from and to currencies are required", json_response["error"]
end
test "returns 400 on invalid date format" do
get exchange_rate_url, params: {
from: "EUR",
to: "USD",
date: "not-a-date"
}
assert_response :bad_request
json_response = JSON.parse(response.body)
assert_equal "Invalid date format", json_response["error"]
end
test "returns 404 when rate not found" do
get exchange_rate_url, params: {
from: "EUR",
to: "USD",
date: Date.current
}
assert_response :not_found
json_response = JSON.parse(response.body)
assert_equal "Exchange rate not found", json_response["error"]
end
test "handles uppercase and lowercase currency codes" do
ExchangeRate.create!(
from_currency: "EUR",
to_currency: "USD",
date: Date.current,
rate: 1.2
)
get exchange_rate_url, params: {
from: "eur",
to: "usd"
}
assert_response :success
json_response = JSON.parse(response.body)
assert_equal 1.2, json_response["rate"]
end
test "returns numeric rate even when object has rate method" do
# Create mock object that returns a rate
rate_obj = OpenStruct.new(rate: 1.2)
ExchangeRate.expects(:find_or_fetch_rate)
.with(from: "EUR", to: "USD", date: Date.current)
.returns(rate_obj)
get exchange_rate_url, params: {
from: "EUR",
to: "USD"
}
assert_response :success
json_response = JSON.parse(response.body)
assert_equal 1.2, json_response["rate"]
assert_instance_of Float, json_response["rate"]
end
test "returns error when find_or_fetch_rate raises exception" do
ExchangeRate.expects(:find_or_fetch_rate)
.with(from: "EUR", to: "USD", date: Date.current)
.raises(StandardError, "Rate fetch failed")
get exchange_rate_url, params: {
from: "EUR",
to: "USD"
}
assert_response :bad_request
json_response = JSON.parse(response.body)
assert_equal "Failed to fetch exchange rate", json_response["error"]
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

@@ -18,7 +18,7 @@ class Import::RowsControllerTest < ActionDispatch::IntegrationTest
test "show trade row" do
import = @user.family.imports.create!(type: "TradeImport")
row = import.rows.create!(date: "01/01/2024", currency: "USD", qty: 10, price: 100, ticker: "AAPL")
row = import.rows.create!(source_row_number: 1, date: "01/01/2024", currency: "USD", qty: 10, price: 100, ticker: "AAPL")
get import_row_path(import, row)
@@ -29,7 +29,7 @@ class Import::RowsControllerTest < ActionDispatch::IntegrationTest
test "show account row" do
import = @user.family.imports.create!(type: "AccountImport")
row = import.rows.create!(name: "Test Account", amount: 10000, currency: "USD")
row = import.rows.create!(source_row_number: 1, name: "Test Account", amount: 10000, currency: "USD")
get import_row_path(import, row)
@@ -40,7 +40,7 @@ class Import::RowsControllerTest < ActionDispatch::IntegrationTest
test "show mint row" do
import = @user.family.imports.create!(type: "MintImport")
row = import.rows.create!(date: "01/01/2024", amount: 100, currency: "USD")
row = import.rows.create!(source_row_number: 1, date: "01/01/2024", amount: 100, currency: "USD")
get import_row_path(import, row)

View File

@@ -3,6 +3,7 @@ require "test_helper"
class ImportsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in @user = users(:family_admin)
ensure_tailwind_build
end
test "gets index" do
@@ -33,6 +34,7 @@ class ImportsControllerTest < ActionDispatch::IntegrationTest
assert_select "button", text: "Import transactions", count: 0
assert_select "button", text: "Import investments", count: 0
assert_select "button", text: "Import from Mint", count: 1
assert_select "button", text: "Import from Actual Budget", count: 1
assert_select "button", text: "Import from Quicken (QIF)", count: 1
assert_select "span", text: "Import accounts first to unlock this option.", count: 2
assert_select "div[aria-disabled=true]", count: 3

View File

@@ -56,9 +56,7 @@ class IndexaCapitalItemsControllerTest < ActionDispatch::IntegrationTest
assert_difference "Account.count", 1 do
post complete_account_setup_indexa_capital_item_url(@item), params: {
accounts: {
ica.id => { account_type: "investment", subtype: "brokerage" }
}
account_ids: [ ica.id ]
}
end
@@ -80,21 +78,15 @@ class IndexaCapitalItemsControllerTest < ActionDispatch::IntegrationTest
assert_no_difference "Account.count" do
post complete_account_setup_indexa_capital_item_url(@item), params: {
accounts: {
ica.id => { account_type: "investment" }
}
account_ids: [ ica.id ]
}
end
end
test "complete_account_setup with all skipped redirects to setup" do
ica = indexa_capital_accounts(:mutual_fund)
test "complete_account_setup with no selected accounts redirects to setup" do
assert_no_difference "Account.count" do
post complete_account_setup_indexa_capital_item_url(@item), params: {
accounts: {
ica.id => { account_type: "skip" }
}
account_ids: []
}
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

@@ -23,6 +23,7 @@ class LoansControllerTest < ActionDispatch::IntegrationTest
notes: "Mortgage notes",
accountable_type: "Loan",
accountable_attributes: {
subtype: "mortgage",
interest_rate: 5.5,
term_months: 60,
rate_type: "fixed",
@@ -40,6 +41,7 @@ class LoansControllerTest < ActionDispatch::IntegrationTest
assert_equal "Local Bank", created_account[:institution_name]
assert_equal "localbank.example", created_account[:institution_domain]
assert_equal "Mortgage notes", created_account[:notes]
assert_equal "mortgage", created_account.accountable.subtype
assert_equal 5.5, created_account.accountable.interest_rate
assert_equal 60, created_account.accountable.term_months
assert_equal "fixed", created_account.accountable.rate_type
@@ -63,6 +65,7 @@ class LoansControllerTest < ActionDispatch::IntegrationTest
accountable_type: "Loan",
accountable_attributes: {
id: @account.accountable_id,
subtype: "auto",
interest_rate: 4.5,
term_months: 48,
rate_type: "fixed",
@@ -79,6 +82,7 @@ class LoansControllerTest < ActionDispatch::IntegrationTest
assert_equal "Updated Bank", @account[:institution_name]
assert_equal "updatedbank.example", @account[:institution_domain]
assert_equal "Updated loan notes", @account[:notes]
assert_equal "auto", @account.accountable.subtype
assert_equal 4.5, @account.accountable.interest_rate
assert_equal 48, @account.accountable.term_months
assert_equal "fixed", @account.accountable.rate_type

View File

@@ -0,0 +1,268 @@
# frozen_string_literal: true
require "test_helper"
class MercuryItemsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in users(:family_admin)
Rails.cache.clear
SyncJob.stubs(:perform_later)
@family = families(:dylan_family)
@existing_item = mercury_items(:one)
@second_item = MercuryItem.create!(
family: @family,
name: "Business Mercury",
token: "second_mercury_token",
base_url: "https://api.mercury.com/api/v1"
)
end
teardown do
Rails.cache.clear
end
test "create adds a new mercury connection without overwriting existing credentials" do
existing_token = @existing_item.token
assert_difference "MercuryItem.count", 1 do
post mercury_items_url, params: {
mercury_item: {
name: "Joint Mercury",
token: "joint_mercury_token",
base_url: "https://api.mercury.com/api/v1"
}
}
end
assert_redirected_to accounts_path
assert_equal existing_token, @existing_item.reload.token
assert_equal "joint_mercury_token", @family.mercury_items.find_by!(name: "Joint Mercury").token
end
test "update changes only the selected mercury connection" do
existing_token = @existing_item.token
patch mercury_item_url(@second_item), params: {
mercury_item: {
name: "Renamed Business Mercury",
token: "updated_second_token",
base_url: "https://api-sandbox.mercury.com/api/v1"
}
}
assert_redirected_to accounts_path
assert_equal existing_token, @existing_item.reload.token
assert_equal "Renamed Business Mercury", @second_item.reload.name
assert_equal "updated_second_token", @second_item.token
assert_equal "https://api-sandbox.mercury.com/api/v1", @second_item.base_url
end
test "blank token update preserves the selected mercury token" do
original_token = @second_item.token
patch mercury_item_url(@second_item), params: {
mercury_item: {
name: "Renamed Business Mercury",
token: "",
base_url: "https://api.mercury.com/api/v1"
}
}
assert_redirected_to accounts_path
assert_equal "Renamed Business Mercury", @second_item.reload.name
assert_equal original_token, @second_item.token
end
test "update expires selected mercury account cache when credentials change" do
Rails.cache.expects(:delete).with(mercury_cache_key(@existing_item)).never
Rails.cache.expects(:delete).with(mercury_cache_key(@second_item)).once
patch mercury_item_url(@second_item), params: {
mercury_item: {
name: "Renamed Business Mercury",
token: "updated_second_token",
base_url: "https://api-sandbox.mercury.com/api/v1"
}
}
assert_redirected_to accounts_path
end
test "update does not expire selected mercury account cache for name-only changes" do
Rails.cache.expects(:delete).never
patch mercury_item_url(@second_item), params: {
mercury_item: {
name: "Renamed Business Mercury"
}
}
assert_redirected_to accounts_path
assert_equal "Renamed Business Mercury", @second_item.reload.name
end
test "preload accounts uses selected mercury item cache key" do
Rails.cache.expects(:read).with(mercury_cache_key(@second_item)).returns(nil)
Rails.cache.expects(:write).with(mercury_cache_key(@second_item), mercury_accounts_payload, expires_in: 5.minutes)
provider = mock("mercury_provider")
provider.expects(:get_accounts).returns(accounts: mercury_accounts_payload)
Provider::Mercury.expects(:new)
.with(@second_item.token, base_url: @second_item.effective_base_url)
.returns(provider)
get preload_accounts_mercury_items_url, params: { mercury_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 mercury items exist" do
get select_accounts_mercury_items_url, params: { accountable_type: "Depository" }
assert_redirected_to settings_providers_path
assert_equal "Choose a Mercury connection in Provider Settings.", flash[:alert]
end
test "select accounts renders the selected mercury item id" do
Rails.cache.expects(:read).with(mercury_cache_key(@second_item)).returns(nil)
Rails.cache.expects(:write).with(mercury_cache_key(@second_item), mercury_accounts_payload, expires_in: 5.minutes)
provider = mock("mercury_provider")
provider.expects(:get_accounts).returns(accounts: mercury_accounts_payload)
Provider::Mercury.expects(:new)
.with(@second_item.token, base_url: @second_item.effective_base_url)
.returns(provider)
get select_accounts_mercury_items_url, params: {
mercury_item_id: @second_item.id,
accountable_type: "Depository"
}
assert_response :success
assert_includes @response.body, %(name="mercury_item_id")
assert_includes @response.body, %(value="#{@second_item.id}")
end
test "select existing account renders the selected mercury item id" do
account = @family.accounts.create!(
name: "Manual Checking",
balance: 0,
currency: "USD",
accountable: Depository.new
)
Rails.cache.expects(:read).with(mercury_cache_key(@second_item)).returns(nil)
Rails.cache.expects(:write).with(mercury_cache_key(@second_item), mercury_accounts_payload, expires_in: 5.minutes)
provider = mock("mercury_provider")
provider.expects(:get_accounts).returns(accounts: mercury_accounts_payload)
Provider::Mercury.expects(:new)
.with(@second_item.token, base_url: @second_item.effective_base_url)
.returns(provider)
get select_existing_account_mercury_items_url, params: {
mercury_item_id: @second_item.id,
account_id: account.id
}
assert_response :success
assert_includes @response.body, %(name="mercury_item_id")
assert_includes @response.body, %(value="#{@second_item.id}")
end
test "link accounts uses selected mercury item and allows duplicate upstream ids across items" do
@existing_item.mercury_accounts.create!(
account_id: "shared_mercury_account",
name: "Shared Checking",
currency: "USD",
current_balance: 1000
)
provider = mock("mercury_provider")
provider.expects(:get_accounts).returns(accounts: mercury_accounts_payload)
Provider::Mercury.expects(:new)
.with(@second_item.token, base_url: @second_item.effective_base_url)
.returns(provider)
assert_difference -> { @second_item.mercury_accounts.where(account_id: "shared_mercury_account").count }, 1 do
assert_difference "AccountProvider.count", 1 do
post link_accounts_mercury_items_url, params: {
mercury_item_id: @second_item.id,
account_ids: [ "shared_mercury_account" ],
accountable_type: "Depository"
}
end
end
assert_redirected_to accounts_path
assert_equal 1, @existing_item.mercury_accounts.where(account_id: "shared_mercury_account").count
end
test "link accounts does not silently use the first connection when multiple items exist" do
assert_no_difference "MercuryAccount.count" do
assert_no_difference "Account.count" do
post link_accounts_mercury_items_url, params: {
account_ids: [ "shared_mercury_account" ],
accountable_type: "Depository"
}
end
end
assert_redirected_to settings_providers_path
assert_equal "Choose a Mercury connection before linking accounts.", 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 "MercuryAccount.count" do
assert_no_difference "AccountProvider.count" do
post link_existing_account_mercury_items_url, params: {
account_id: account.id,
mercury_account_id: "shared_mercury_account"
}
end
end
assert_redirected_to settings_providers_path
assert_equal "Choose a Mercury connection before linking accounts.", flash[:alert]
end
test "sync only queues a sync for the selected mercury item" do
assert_difference -> { Sync.where(syncable: @second_item).count }, 1 do
assert_no_difference -> { Sync.where(syncable: @existing_item).count } do
post sync_mercury_item_url(@second_item)
end
end
assert_response :redirect
end
private
def mercury_accounts_payload
[
{
id: "shared_mercury_account",
nickname: "Shared Checking",
name: "Shared Checking",
status: "active",
type: "checking",
currentBalance: 1000
}
]
end
def mercury_cache_key(mercury_item)
"mercury_accounts_#{@family.id}_#{mercury_item.id}"
end
end

View File

@@ -1,4 +1,5 @@
require "test_helper"
require "webauthn/fake_client"
class MfaControllerTest < ActionDispatch::IntegrationTest
setup do
@@ -38,7 +39,12 @@ class MfaControllerTest < ActionDispatch::IntegrationTest
assert_response :success
assert @user.reload.otp_required?
assert_equal 8, @user.otp_backup_codes.length
assert @user.otp_backup_codes.all? { |code| code.start_with?("$2") }
assert_select "div.grid-cols-2" # Check for backup codes grid
rendered_codes = css_select("div.grid-cols-2 div").map { |node| node.text.strip }
assert_equal 8, rendered_codes.length
assert rendered_codes.all? { |code| code.match?(/\A[0-9a-f]{16}\z/) }
assert_empty rendered_codes & @user.otp_backup_codes
end
test "does not enable MFA with invalid code" do
@@ -64,6 +70,21 @@ class MfaControllerTest < ActionDispatch::IntegrationTest
assert_select "form[action=?]", verify_mfa_path
end
test "verify shows WebAuthn option when credentials are registered" do
@user.setup_mfa!
@user.enable_mfa!
register_webauthn_credential
sign_out
post sessions_path, params: { email: @user.email, password: user_password_test }
get verify_mfa_path
assert_response :success
assert_select "button", text: I18n.t("mfa.verify.webauthn_button")
assert_select "[data-webauthn-authentication-error-fallback-value=?]", I18n.t("mfa.verify_webauthn.invalid_credential")
assert_select "p[data-webauthn-authentication-target='error'][aria-live='assertive'][aria-atomic='true'][aria-hidden='true']"
end
test "verify_code authenticates with valid TOTP" do
@user.setup_mfa!
@user.enable_mfa!
@@ -80,17 +101,19 @@ class MfaControllerTest < ActionDispatch::IntegrationTest
test "verify_code authenticates with valid backup code" do
@user.setup_mfa!
@user.enable_mfa!
backup_code = @user.enable_mfa!.first
matching_digest = @user.otp_backup_codes.find { |digest| BCrypt::Password.new(digest).is_password?(backup_code) }
assert_not_nil matching_digest
sign_out
post sessions_path, params: { email: @user.email, password: user_password_test }
backup_code = @user.otp_backup_codes.first
post verify_mfa_path, params: { code: backup_code }
assert_redirected_to root_path
assert Session.exists?(user_id: @user.id)
assert_not @user.reload.otp_backup_codes.include?(backup_code)
assert_equal 7, @user.reload.otp_backup_codes.size
assert_not_includes @user.otp_backup_codes, matching_digest
end
test "verify_code rejects invalid codes" do
@@ -105,9 +128,114 @@ class MfaControllerTest < ActionDispatch::IntegrationTest
assert_not Session.exists?(user_id: @user.id)
end
test "webauthn_options require a pending MFA session" do
post webauthn_options_mfa_path, as: :json
assert_response :unprocessable_entity
end
test "verify_webauthn authenticates with a registered credential" do
@user.setup_mfa!
@user.enable_mfa!
client = register_webauthn_credential
stored_credential = @user.webauthn_credentials.first
sign_out
post sessions_path, params: { email: @user.email, password: user_password_test }
post webauthn_options_mfa_path, as: :json
assert_response :success
options = JSON.parse(response.body)
assertion = client.get(
challenge: options.fetch("challenge"),
rp_id: "www.example.com",
allow_credentials: [ stored_credential.credential_id ]
)
post verify_webauthn_mfa_path, params: { credential: assertion }, as: :json
assert_response :success
assert_equal root_path, JSON.parse(response.body).fetch("redirect_url")
assert Session.exists?(user_id: @user.id)
assert stored_credential.reload.last_used_at.present?
assert_operator stored_credential.sign_count, :>, 0
end
test "verify_webauthn authenticates with configured relying party id" do
with_webauthn_config(rp_id: "example.test", allowed_origins: [ "https://app.example.test" ]) do
@user.setup_mfa!
@user.enable_mfa!
client = register_webauthn_credential(origin: "https://app.example.test", rp_id: "example.test")
stored_credential = @user.webauthn_credentials.first
sign_out
post sessions_path, params: { email: @user.email, password: user_password_test }
post webauthn_options_mfa_path, as: :json
assert_response :success
options = JSON.parse(response.body)
assert_equal "example.test", options.fetch("rpId")
assertion = client.get(
challenge: options.fetch("challenge"),
rp_id: "example.test",
allow_credentials: [ stored_credential.credential_id ]
)
post verify_webauthn_mfa_path, params: { credential: assertion }, as: :json
assert_response :success
assert_equal root_path, JSON.parse(response.body).fetch("redirect_url")
end
end
test "verify_webauthn rejects invalid credentials" do
@user.setup_mfa!
@user.enable_mfa!
client = register_webauthn_credential
stored_credential = @user.webauthn_credentials.first
sign_out
post sessions_path, params: { email: @user.email, password: user_password_test }
post webauthn_options_mfa_path, as: :json
options = JSON.parse(response.body)
assertion = client.get(
challenge: options.fetch("challenge"),
rp_id: "www.example.com",
allow_credentials: [ stored_credential.credential_id ]
)
assertion["id"] = "invalid"
post verify_webauthn_mfa_path, params: { credential: assertion }, as: :json
assert_response :unprocessable_entity
assert_not Session.exists?(user_id: @user.id)
end
test "verify_webauthn rejects malformed credential payloads" do
@user.setup_mfa!
@user.enable_mfa!
register_webauthn_credential
sign_out
post sessions_path, params: { email: @user.email, password: user_password_test }
post webauthn_options_mfa_path, as: :json
assert_response :success
post verify_webauthn_mfa_path, params: { credential: [] }, as: :json
assert_response :unprocessable_entity
assert_equal I18n.t("mfa.verify_webauthn.invalid_credential"), JSON.parse(response.body).fetch("error")
assert_not Session.exists?(user_id: @user.id)
end
test "disable removes MFA" do
@user.setup_mfa!
@user.enable_mfa!
@user.webauthn_credentials.create!(
nickname: "YubiKey",
credential_id: "disable-mfa-credential",
public_key: "public-key"
)
delete disable_mfa_path
@@ -115,5 +243,35 @@ class MfaControllerTest < ActionDispatch::IntegrationTest
assert_not @user.reload.otp_required?
assert_nil @user.otp_secret
assert_empty @user.otp_backup_codes
assert_empty @user.webauthn_credentials
end
private
def register_webauthn_credential(origin: "http://www.example.com", rp_id: "www.example.com")
client = WebAuthn::FakeClient.new(origin)
post options_settings_webauthn_credentials_path, as: :json
options = JSON.parse(response.body)
credential = client.create(challenge: options.fetch("challenge"), rp_id: rp_id)
post settings_webauthn_credentials_path, params: {
webauthn_credential: { nickname: "MacBook Touch ID" },
credential: credential
}, as: :json
assert_response :success
client
end
def with_webauthn_config(rp_id:, allowed_origins:)
config = Rails.application.config.x.webauthn
previous_rp_id = config.rp_id
previous_allowed_origins = config.allowed_origins
config.rp_id = rp_id
config.allowed_origins = allowed_origins
yield
ensure
config.rp_id = previous_rp_id
config.allowed_origins = previous_allowed_origins
end
end

View File

@@ -115,7 +115,7 @@ class OidcAccountsControllerTest < ActionController::TestCase
get :link
assert_response :success
assert_select "h3", text: "Create New Account"
assert_select "p", text: /Create New Account/
assert_select "strong", text: new_user_auth["email"]
end
@@ -128,7 +128,7 @@ class OidcAccountsControllerTest < ActionController::TestCase
get :link
assert_response :success
assert_select "h3", text: "Create New Account"
assert_select "p", text: /Create New Account/
# No create account button rendered
assert_select "button", text: "Create Account", count: 0
assert_select "p", text: /New account creation via single sign-on is disabled/

View File

@@ -43,6 +43,25 @@ class PagesControllerTest < ActionDispatch::IntegrationTest
assert_select "[data-controller='sankey-chart']"
end
test "dashboard renders sankey chart zoom controls and stable node ids" do
parent_category = @family.categories.create!(name: "Shopping", color: "#FF5733")
subcategory = @family.categories.create!(name: "Groceries", parent: parent_category, color: "#33FF57")
create_transaction(account: @family.accounts.first, name: "General shopping", amount: 100, category: parent_category)
create_transaction(account: @family.accounts.first, name: "Grocery store", amount: 50, category: subcategory)
get root_path
assert_response :ok
assert_select "[data-sankey-chart-target='zoomOutButton'][hidden]", count: 2
chart = css_select("[data-controller='sankey-chart']").first
sankey_data = JSON.parse(chart["data-sankey-chart-data-value"])
assert_includes sankey_data.fetch("nodes").map { |node| node.fetch("id") }, "cash_flow_node"
assert sankey_data.fetch("nodes").any? { |node| node.fetch("id").start_with?("expense_") }
end
test "changelog" do
VCR.use_cassette("git_repository_provider/fetch_latest_release_notes") do
get changelog_path

View File

@@ -14,6 +14,7 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest
account: {
name: "New Property",
subtype: "house",
currency: "EUR",
institution_name: "Property Lender",
institution_domain: "propertylender.example",
notes: "Property notes",
@@ -31,6 +32,7 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest
assert created_account.accountable.is_a?(Property)
assert_equal "draft", created_account.status
assert_equal 0, created_account.balance
assert_equal "EUR", created_account.currency
assert_equal "Property Lender", created_account[:institution_name]
assert_equal "propertylender.example", created_account[:institution_domain]
assert_equal "Property notes", created_account[:notes]
@@ -93,8 +95,12 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest
}
}
@account.reload
assert_equal 600000, @account.balance
assert_equal "EUR", @account.currency
# If account is active, it renders balances view; otherwise redirects to address
if @account.reload.active?
if @account.active?
assert_response :success
else
assert_redirected_to address_property_path(@account)

View File

@@ -0,0 +1,11 @@
require "test_helper"
class PwaControllerTest < ActionDispatch::IntegrationTest
test "manifest responds successfully for html accept headers" do
get "/manifest", headers: { "Accept" => "text/html" }
assert_response :success
assert_equal "application/manifest+json", response.media_type
assert_includes response.body, '"start_url": "/"'
end
end

View File

@@ -59,15 +59,46 @@ class RegistrationsControllerTest < ActionDispatch::IntegrationTest
end
assert_difference "User.count", +1 do
invite_code = InviteCode.generate!
post registration_url, params: { user: {
email: "john@example.com",
password: "Password1!",
invite_code: InviteCode.generate! } }
invite_code: invite_code } }
assert_redirected_to root_url
assert_not InviteCode.exists?(token: invite_code)
end
end
end
test "invite code is not consumed when signup fails validation" do
with_env_overrides REQUIRE_INVITE_CODE: "true" do
invite_code = InviteCode.generate!
assert_no_difference "User.count" do
post registration_url, params: { user: {
email: "validationfail@example.com",
password: "weak",
invite_code: invite_code } }
end
assert_response :unprocessable_entity
assert InviteCode.exists?(token: invite_code)
end
end
test "invalid invite code does not create a user" do
with_env_overrides REQUIRE_INVITE_CODE: "true" do
assert_no_difference "User.count" do
post registration_url, params: { user: {
email: "valid@example.com",
password: "Password1!",
invite_code: "invalid-token-that-does-not-exist" } }
end
assert_redirected_to new_registration_url
end
end
test "creating account from guest invitation assigns guest role and intro layout" do
invitation = invitations(:one)
invitation.update!(role: "guest", email: "guest-signup@example.com")

View File

@@ -107,12 +107,9 @@ class ReportsControllerTest < ActionDispatch::IntegrationTest
)
assert_response :ok
# Should show flash message about invalid date range
assert flash[:alert].present?, "Flash alert should be present"
assert_match /End date cannot be before start date/, flash[:alert]
# Verify the response body contains the swapped date range in the correct order
assert_includes @response.body, end_date.strftime("%b %-d, %Y")
assert_includes @response.body, start_date.strftime("%b %-d, %Y")
assert_equal I18n.t("reports.invalid_date_range"), flash[:alert]
assert_includes @response.body, end_date.strftime("%b %Y")
assert_includes @response.body, start_date.strftime("%b %Y")
end
test "spending patterns returns data when expense transactions exist" do
@@ -245,4 +242,99 @@ class ReportsControllerTest < ActionDispatch::IntegrationTest
assert_select "tr[data-category='category-#{subcategory_movies.id}']", text: /^Movies/
assert_select "tr[data-category='category-#{subcategory_games.id}']", text: /^Games/
end
test "monthly period navigation shows previous month link" do
get reports_path(period_type: :monthly)
assert_response :ok
prev_start = Date.current.beginning_of_month - 1.month
prev_end = prev_start.end_of_month
assert_select "a[href=?]", reports_path(period_type: :monthly, start_date: prev_start, end_date: prev_end)
end
test "monthly period navigation disables next arrow on current month" do
get reports_path(period_type: :monthly)
assert_response :ok
assert_select "button[disabled][aria-label=?]", I18n.t("reports.index.next_period")
end
test "monthly period navigation shows next month link on past month" do
past_start = Date.current.beginning_of_month - 2.months
past_end = past_start.end_of_month
get reports_path(period_type: :monthly, start_date: past_start, end_date: past_end)
assert_response :ok
next_start = past_start + 1.month
next_end = next_start.end_of_month
assert_select "a[href=?]", reports_path(period_type: :monthly, start_date: next_start, end_date: next_end)
end
test "last 6 months next window extends to current month end when crossing boundary" do
start_date = Date.current.beginning_of_month - 12.months
end_date = start_date + 6.months - 1.day
get reports_path(period_type: :last_6_months, start_date: start_date, end_date: end_date)
assert_response :ok
candidate_start = start_date.beginning_of_month + 6.months
if candidate_start + 6.months >= Date.current.beginning_of_month
expected_next_end = Date.current.end_of_month
expected_next_start = (expected_next_end + 1.day - 6.months).beginning_of_month
else
expected_next_start = candidate_start
expected_next_end = expected_next_start + 6.months - 1.day
end
assert_select "a[href=?]",
reports_path(period_type: :last_6_months, start_date: expected_next_start, end_date: expected_next_end)
end
test "quarterly period navigation shows previous and next quarter links" do
get reports_path(period_type: :quarterly)
assert_response :ok
prev_start = (Date.current.beginning_of_quarter - 1.day).beginning_of_quarter
prev_end = prev_start.end_of_quarter
assert_select "a[href=?]", reports_path(period_type: :quarterly, start_date: prev_start, end_date: prev_end)
# Also verify a past quarter shows an enabled next-quarter link
get reports_path(period_type: :quarterly, start_date: prev_start, end_date: prev_end)
assert_response :ok
next_start = prev_start.next_quarter.beginning_of_quarter
next_end = next_start.end_of_quarter
assert_select "a[href=?]", reports_path(period_type: :quarterly, start_date: next_start, end_date: next_end)
end
test "custom period hides period display" do
get reports_path(
period_type: :custom,
start_date: 1.month.ago.to_date,
end_date: Date.current
)
assert_response :ok
prev_start = 1.month.ago.to_date.beginning_of_month - 1.month
next_start = 1.month.ago.to_date.beginning_of_month + 1.month
assert_select "a[href*=?]", "start_date=#{prev_start}", count: 0
assert_select "a[href*=?]", "start_date=#{next_start}", count: 0
end
test "ytd period navigation shows previous year link" do
get reports_path(period_type: :ytd)
assert_response :ok
prev_year = Date.current.year - 1
prev_start = Date.new(prev_year, 1, 1)
prev_end = Date.new(prev_year, 12, 31)
assert_select "a[href=?]", reports_path(period_type: :ytd, start_date: prev_start, end_date: prev_end)
end
test "ytd period navigation disables next arrow on current year" do
get reports_path(period_type: :ytd)
assert_response :ok
assert_select "button[disabled][aria-label=?]", I18n.t("reports.index.next_period")
end
end

View File

@@ -210,6 +210,26 @@ class RulesControllerTest < ActionDispatch::IntegrationTest
end
end
test "index shows blocked count in recent runs summary" do
rule = rules(:one)
RuleRun.create!(
rule: rule,
execution_type: "manual",
status: "success",
transactions_queued: 10,
transactions_processed: 7,
transactions_modified: 4,
pending_jobs_count: 0,
executed_at: Time.current
)
get rules_url
assert_response :success
assert_select "th", text: /Queued\s+Processed\s+Modified\s+Blocked/
assert_select "td", text: "10 / 7 / 4 / 3"
end
test "should get confirm_all" do
get confirm_all_rules_url
assert_response :success

View File

@@ -0,0 +1,68 @@
require "test_helper"
class Settings::DebugsControllerTest < ActionDispatch::IntegrationTest
setup do
ensure_tailwind_build
@entry = DebugLogEntry.create!(
category: "security_price_fetch",
level: "warn",
message: "Could not fetch prices",
source: "Security::Price::Importer",
provider_key: "twelve_data",
family: families(:dylan_family),
account: accounts(:depository),
user: users(:family_admin),
metadata: { ticker: "AAPL" }
)
end
test "super admins can view debug log" do
sign_in users(:sure_support_staff)
get settings_debug_url
assert_response :success
assert_match "Debug event log", response.body
assert_match @entry.message, response.body
end
test "non super admins are redirected" do
sign_in users(:family_admin)
get settings_debug_url
assert_redirected_to root_url
end
test "filters by provider key" do
sign_in users(:sure_support_staff)
DebugLogEntry.create!(
category: "security_price_fetch",
level: "warn",
message: "Should be filtered out",
source: "Security::Price::Importer",
provider_key: "finnhub",
family: families(:dylan_family),
account: accounts(:depository),
user: users(:family_admin),
metadata: { ticker: "MSFT" }
)
get settings_debug_url, params: { provider_key: "twelve_data" }
assert_response :success
assert_match @entry.message, response.body
refute_match "Should be filtered out", response.body
end
test "ignores invalid uuid filters" do
sign_in users(:sure_support_staff)
get settings_debug_url, params: { family_id: "not-a-uuid" }
assert_response :success
assert_match @entry.message, response.body
end
end

View File

@@ -235,6 +235,61 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest
end
end
test "accepts valid llm budget overrides and blanks clear them" do
with_self_hosting do
patch settings_hosting_url, params: { setting: {
llm_context_window: "4096",
llm_max_response_tokens: "1024",
llm_max_items_per_call: "40"
} }
assert_redirected_to settings_hosting_url
assert_equal 4096, Setting.llm_context_window
assert_equal 1024, Setting.llm_max_response_tokens
assert_equal 40, Setting.llm_max_items_per_call
patch settings_hosting_url, params: { setting: {
llm_context_window: "",
llm_max_response_tokens: "",
llm_max_items_per_call: ""
} }
assert_nil Setting.llm_context_window
assert_nil Setting.llm_max_response_tokens
assert_nil Setting.llm_max_items_per_call
end
ensure
Setting.llm_context_window = nil
Setting.llm_max_response_tokens = nil
Setting.llm_max_items_per_call = nil
end
test "rejects llm budget below field minimum" do
with_self_hosting do
patch settings_hosting_url, params: { setting: { llm_context_window: "0" } }
assert_response :unprocessable_entity
assert_match(/must be a whole number/, flash[:alert])
assert_nil Setting.llm_context_window
patch settings_hosting_url, params: { setting: { llm_max_response_tokens: "-5" } }
assert_response :unprocessable_entity
assert_match(/must be a whole number/, flash[:alert])
assert_nil Setting.llm_max_response_tokens
patch settings_hosting_url, params: { setting: { llm_max_items_per_call: "not-a-number" } }
assert_response :unprocessable_entity
assert_match(/must be a whole number/, flash[:alert])
assert_nil Setting.llm_max_items_per_call
end
ensure
Setting.llm_context_window = nil
Setting.llm_max_response_tokens = nil
Setting.llm_max_items_per_call = nil
end
test "can clear data only when admin" do
with_self_hosting do
sign_in users(:family_member)
@@ -247,4 +302,71 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest
assert_equal I18n.t("settings.hostings.not_authorized"), flash[:alert]
end
end
# --- Securities provider toggle ---
test "can update securities providers" do
with_self_hosting do
patch settings_hosting_url, params: { setting: { securities_providers: [ "twelve_data", "yahoo_finance" ] } }
assert_redirected_to settings_hosting_url
assert_equal "twelve_data,yahoo_finance", Setting.securities_providers
end
ensure
Setting.securities_providers = ""
end
test "filters out invalid provider names" do
with_self_hosting do
patch settings_hosting_url, params: { setting: { securities_providers: [ "twelve_data", "fake_provider", "hacked" ] } }
assert_redirected_to settings_hosting_url
# Only valid providers are stored
enabled = Setting.enabled_securities_providers
assert_includes enabled, "twelve_data"
refute_includes enabled, "fake_provider"
refute_includes enabled, "hacked"
end
ensure
Setting.securities_providers = ""
end
test "removing a provider marks linked securities offline" do
with_self_hosting do
security = Security.create!(ticker: "CSPX", exchange_operating_mic: "XLON", price_provider: "tiingo", offline: false)
# First enable tiingo
Setting.securities_providers = "twelve_data,tiingo"
# Then remove tiingo
patch settings_hosting_url, params: { setting: { securities_providers: [ "twelve_data" ] } }
security.reload
assert security.offline?, "Security should be marked offline when its provider is removed"
assert_equal "provider_disabled", security.offline_reason
end
ensure
Setting.securities_providers = ""
end
test "re-adding a provider brings securities back online" do
with_self_hosting do
security = Security.create!(
ticker: "CSPX2", exchange_operating_mic: "XLON",
price_provider: "tiingo", offline: true, offline_reason: "provider_disabled"
)
# Start without tiingo
Setting.securities_providers = "twelve_data"
# Re-add tiingo
patch settings_hosting_url, params: { setting: { securities_providers: [ "twelve_data", "tiingo" ] } }
security.reload
refute security.offline?, "Security should come back online when its provider is re-added"
assert_nil security.offline_reason
end
ensure
Setting.securities_providers = ""
end
end

View File

@@ -4,8 +4,50 @@ class Settings::PreferencesControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in users(:family_admin)
end
test "get" do
get settings_preferences_url
assert_response :success
end
test "group moniker uses group currencies copy and hides legacy currency field" do
users(:family_admin).family.update!(moniker: "Group")
get settings_preferences_url
assert_response :success
assert_includes response.body, "Group Currencies"
assert_includes response.body, "your group"
assert_select "select[name='user[family_attributes][currency]']", count: 0
end
test "renders preview features toggle for non-admin users too" do
sign_in users(:family_member)
get settings_preferences_url
assert_response :success
assert_includes response.body, "Enable preview features"
end
test "update toggles preview_features_enabled on" do
user = users(:family_admin)
assert_not user.preview_features_enabled?
patch settings_preferences_url, params: { user: { preview_features_enabled: "1" } }
assert_redirected_to settings_preferences_url
assert user.reload.preview_features_enabled?
end
test "update toggles preview_features_enabled off" do
user = users(:family_admin)
user.update!(preferences: (user.preferences || {}).merge("preview_features_enabled" => true))
assert user.preview_features_enabled?
patch settings_preferences_url, params: { user: { preview_features_enabled: "0" } }
assert_redirected_to settings_preferences_url
assert_not user.reload.preview_features_enabled?
end
end

View File

@@ -33,7 +33,7 @@ class Settings::ProfilesControllerTest < ActionDispatch::IntegrationTest
end
assert_redirected_to settings_profile_path
assert_equal "Member removed successfully.", flash[:notice]
assert_equal I18n.t("settings.profiles.destroy.member_removed"), flash[:notice]
assert_raises(ActiveRecord::RecordNotFound) { User.find(@member.id) }
end
@@ -74,7 +74,7 @@ class Settings::ProfilesControllerTest < ActionDispatch::IntegrationTest
end
assert_redirected_to settings_profile_path
assert_equal "Member removed successfully.", flash[:notice]
assert_equal I18n.t("settings.profiles.destroy.member_removed"), flash[:notice]
assert_raises(ActiveRecord::RecordNotFound) { User.find(@member.id) }
assert_raises(ActiveRecord::RecordNotFound) { Invitation.find(invitation.id) }
end

View File

@@ -1,6 +1,8 @@
require "test_helper"
class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest
include ActiveJob::TestHelper
setup do
sign_in users(:family_admin)
@@ -8,6 +10,12 @@ class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest
Provider::Factory.ensure_adapters_loaded
end
test "GET /settings/bank_sync redirects permanently to /settings/providers" do
get "/settings/bank_sync"
assert_redirected_to "/settings/providers"
assert_equal 301, response.status
end
test "can access when self hosting is disabled (managed mode)" do
Rails.configuration.stubs(:app_mode).returns("managed".inquiry)
get settings_providers_url
@@ -24,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
@@ -270,16 +299,17 @@ class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest
# We'll force an error by making the []= method raise
Setting.expects(:[]=).with("plaid_client_id", "test").raises(StandardError.new("Database error")).once
# Mock logger to verify error is logged
Rails.logger.expects(:error).with(regexp_matches(/Failed to update provider settings.*Database error/)).once
# Mock logger to verify error is logged (pin both the exception class
# name and the message so a regression that drops one still fails).
Rails.logger.expects(:error).with(regexp_matches(/Failed to update provider settings: StandardError - Database error/)).once
patch settings_providers_url, params: {
setting: { plaid_client_id: "test" }
}
# Controller should handle the error gracefully
# Controller should handle the error gracefully with generic message (no internal details)
assert_response :unprocessable_entity
assert_equal "Failed to update provider settings: Database error", flash[:alert]
assert_equal "Failed to update provider settings. Please try again.", flash[:alert]
end
end
@@ -297,6 +327,101 @@ class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest
end
end
test "POST sync_all enqueues SyncAllProvidersJob" do
SimplefinItem.create!(
family: families(:dylan_family),
name: "Test SimpleFIN Sync All",
access_url: "https://bridge.simplefin.org/simplefin/access"
)
families(:dylan_family).update_column(:last_sync_all_attempted_at, nil)
assert_enqueued_with(job: SyncAllProvidersJob) do
post sync_all_settings_providers_path
end
assert_redirected_to settings_providers_path
follow_redirect!
assert_response :success
assert_match(/Syncing all connected providers/i, response.body)
end
test "POST sync_all respects recent sync throttle" do
families(:dylan_family).update_column(:last_sync_all_attempted_at, Time.current)
assert_no_enqueued_jobs only: SyncAllProvidersJob do
post sync_all_settings_providers_path
end
assert_redirected_to settings_providers_path
assert_equal I18n.t("settings.providers.sync_all_recently"), flash[:notice]
end
test "POST sync for simplefin without an active Simplefin sync enqueues SyncJob" do
item = SimplefinItem.create!(
family: families(:dylan_family),
name: "Test SimpleFIN Per Row Sync",
access_url: "https://bridge.simplefin.org/simplefin/access"
)
Sync.where(syncable_type: "SimplefinItem", syncable_id: item.id).delete_all
assert_enqueued_jobs 1, only: SyncJob do
post sync_provider_settings_providers_path(provider_key: "simplefin")
end
assert_redirected_to settings_providers_path
follow_redirect!
assert_response :success
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)
@@ -305,7 +430,7 @@ class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest
setting: { plaid_client_id: "test" }
}
assert_redirected_to settings_providers_path
assert_redirected_to root_path
assert_equal "Not authorized", flash[:alert]
# Value should not have changed

View File

@@ -0,0 +1,168 @@
require "test_helper"
require "webauthn/fake_client"
class Settings::WebauthnCredentialsControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:family_admin)
@user.webauthn_credentials.destroy_all
sign_in @user
@user.setup_mfa!
@user.enable_mfa!
@client = WebAuthn::FakeClient.new("http://www.example.com")
end
test "options require enabled MFA" do
@user.disable_mfa!
post options_settings_webauthn_credentials_path, as: :json
assert_response :forbidden
assert_equal I18n.t("webauthn_credentials.mfa_required"), JSON.parse(response.body).fetch("error")
end
test "creates a credential from a verified registration challenge" do
options = registration_options
credential = @client.create(challenge: options.fetch("challenge"), rp_id: "www.example.com")
assert_difference -> { @user.webauthn_credentials.count }, 1 do
post settings_webauthn_credentials_path, params: {
webauthn_credential: { nickname: "MacBook Touch ID" },
credential: credential
}, as: :json
end
assert_response :success
assert_equal settings_security_path, JSON.parse(response.body).fetch("redirect_url")
stored_credential = @user.webauthn_credentials.reload.last
assert_equal "MacBook Touch ID", stored_credential.nickname
assert_equal credential.fetch("id"), stored_credential.credential_id
assert_includes stored_credential.transports, "internal"
assert @user.reload.webauthn_id.present?
end
test "uses configured relying party id and allowed origin" do
with_webauthn_config(rp_id: "example.test", allowed_origins: [ "https://app.example.test" ]) do
client = WebAuthn::FakeClient.new("https://app.example.test")
options = registration_options
assert_equal "example.test", options.dig("rp", "id")
credential = client.create(challenge: options.fetch("challenge"), rp_id: "example.test")
assert_difference -> { @user.webauthn_credentials.count }, 1 do
post settings_webauthn_credentials_path, params: {
webauthn_credential: { nickname: "Configured origin key" },
credential: credential
}, as: :json
end
assert_response :success
end
end
test "rejects a credential when registration challenge has already been used" do
options = registration_options
credential = @client.create(challenge: options.fetch("challenge"), rp_id: "www.example.com")
post settings_webauthn_credentials_path, params: {
webauthn_credential: { nickname: "MacBook Touch ID" },
credential: credential
}, as: :json
assert_response :success
assert_no_difference -> { @user.webauthn_credentials.count } do
post settings_webauthn_credentials_path, params: {
webauthn_credential: { nickname: "Replay" },
credential: credential
}, as: :json
end
assert_response :unprocessable_entity
end
test "rejects malformed credential payloads" do
registration_options
assert_no_difference -> { @user.webauthn_credentials.count } do
post settings_webauthn_credentials_path, params: {
webauthn_credential: { nickname: "Malformed" },
credential: []
}, as: :json
end
assert_response :unprocessable_entity
assert_equal I18n.t("webauthn_credentials.failure"), JSON.parse(response.body).fetch("error")
end
test "rejects database-level duplicate credential races" do
registration_options
@user.webauthn_credentials.create!(
nickname: "Existing security key",
credential_id: "duplicate-credential-id",
public_key: "public-key"
)
verified_credential = Struct.new(:id, :public_key, :sign_count).new("duplicate-credential-id", "new-public-key", 0)
relying_party = mock("webauthn_relying_party")
relying_party.expects(:verify_registration).returns(verified_credential)
Settings::WebauthnCredentialsController.any_instance.stubs(:webauthn_relying_party).returns(relying_party)
assert_no_difference -> { @user.webauthn_credentials.count } do
post settings_webauthn_credentials_path, params: {
webauthn_credential: { nickname: "Duplicate security key" },
credential: { id: "duplicate-credential-id", response: {} }
}, as: :json
end
assert_response :unprocessable_entity
assert_equal I18n.t("webauthn_credentials.failure"), JSON.parse(response.body).fetch("error")
end
test "uses localized default credential nickname" do
options = registration_options
credential = @client.create(challenge: options.fetch("challenge"), rp_id: "www.example.com")
post settings_webauthn_credentials_path, params: {
webauthn_credential: { nickname: "" },
credential: credential
}, as: :json
assert_response :success
assert_equal I18n.t("webauthn_credentials.default_name"), @user.webauthn_credentials.reload.last.nickname
end
test "destroys a credential owned by the current user" do
credential = @user.webauthn_credentials.create!(
nickname: "YubiKey",
credential_id: "credential-to-delete",
public_key: "public-key"
)
assert_difference -> { @user.webauthn_credentials.count }, -1 do
delete settings_webauthn_credential_path(credential)
end
assert_redirected_to settings_security_path
end
private
def registration_options
post options_settings_webauthn_credentials_path, as: :json
assert_response :success
JSON.parse(response.body)
end
def with_webauthn_config(rp_id:, allowed_origins:)
config = Rails.application.config.x.webauthn
previous_rp_id = config.rp_id
previous_allowed_origins = config.allowed_origins
config.rp_id = rp_id
config.allowed_origins = allowed_origins
yield
ensure
config.rp_id = previous_rp_id
config.allowed_origins = previous_allowed_origins
end
end

View File

@@ -476,6 +476,383 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
assert !q.key?("open_relink_for"), "did not expect auto-open when update produced no SFAs/candidates"
end
# Replacement detection prompt (surfaces on the SimpleFIN card when the
# importer's ReplacementDetector has persisted suggestions on sync_stats).
test "replacement prompt renders on accounts index when suggestions are persisted" do
# Create the two sfas the detector would flag
old_sfa = @simplefin_item.simplefin_accounts.create!(
name: "Citi-3831", account_id: "sf_3831",
currency: "USD", account_type: "credit", current_balance: 0,
org_data: { "name" => "Citibank" },
raw_transactions_payload: [
{ "id" => "t", "transacted_at" => 90.days.ago.to_i, "posted" => 90.days.ago.to_i, "amount" => "-5" }
]
)
sure_account = Account.create!(
family: @family,
name: "Citi Double Cash Card-3831",
balance: 0, currency: "USD",
accountable: CreditCard.create!(subtype: "credit_card")
)
AccountProvider.create!(account: sure_account, provider: old_sfa)
new_sfa = @simplefin_item.simplefin_accounts.create!(
name: "Citi-2879", account_id: "sf_2879",
currency: "USD", account_type: "credit", current_balance: -1200,
org_data: { "name" => "Citibank" },
raw_transactions_payload: [
{ "id" => "n", "transacted_at" => 2.days.ago.to_i, "posted" => 2.days.ago.to_i, "amount" => "-100" }
]
)
# Persist a suggestion on the latest sync
sync = @simplefin_item.syncs.create!(status: :completed, sync_stats: {
"replacement_suggestions" => [
{
"dormant_sfa_id" => old_sfa.id,
"active_sfa_id" => new_sfa.id,
"sure_account_id" => sure_account.id,
"institution_name" => "Citibank",
"dormant_account_name" => "Citi-3831",
"active_account_name" => "Citi-2879",
"confidence" => "high"
}
]
})
sync.update_column(:created_at, Time.current)
get accounts_url
assert_response :success
assert_match(/Citibank card may have been replaced/, response.body)
assert_match(/Citi Double Cash Card-3831/, response.body)
assert_match(/Relink to new card/, response.body)
end
test "replacement prompt is suppressed once the relink has been applied" do
old_sfa = @simplefin_item.simplefin_accounts.create!(
name: "Citi-3831", account_id: "sf_3831_applied",
currency: "USD", account_type: "credit", current_balance: 0,
org_data: { "name" => "Citibank" },
raw_transactions_payload: [
{ "id" => "t", "transacted_at" => 90.days.ago.to_i, "posted" => 90.days.ago.to_i, "amount" => "-5" }
]
)
sure_account = Account.create!(
family: @family,
name: "Citi Double Cash Card-3831 (applied)",
balance: 0, currency: "USD",
accountable: CreditCard.create!(subtype: "credit_card")
)
new_sfa = @simplefin_item.simplefin_accounts.create!(
name: "Citi-2879", account_id: "sf_2879_applied",
currency: "USD", account_type: "credit", current_balance: -1200,
org_data: { "name" => "Citibank" },
raw_transactions_payload: [
{ "id" => "n", "transacted_at" => 2.days.ago.to_i, "posted" => 2.days.ago.to_i, "amount" => "-100" }
]
)
# Simulate the post-relink state: new_sfa is now linked to the Sure account,
# old_sfa is unlinked. sync_stats still carries the stale suggestion.
AccountProvider.create!(account: sure_account, provider: new_sfa)
sync = @simplefin_item.syncs.create!(status: :completed, sync_stats: {
"replacement_suggestions" => [
{
"dormant_sfa_id" => old_sfa.id,
"active_sfa_id" => new_sfa.id,
"sure_account_id" => sure_account.id,
"institution_name" => "Citibank",
"dormant_account_name" => "Citi-3831",
"active_account_name" => "Citi-2879",
"confidence" => "high"
}
]
})
sync.update_column(:created_at, Time.current)
get accounts_url
assert_response :success
refute_match(/Citibank card may have been replaced/, response.body,
"banner should disappear once the relink has landed on the new sfa")
end
test "dismissing a replacement suggestion hides the banner for that pair" do
old_sfa = @simplefin_item.simplefin_accounts.create!(
name: "Citi-3831", account_id: "sf_3831_dismiss",
currency: "USD", account_type: "credit", current_balance: 0,
org_data: { "name" => "Citibank" },
raw_transactions_payload: [
{ "id" => "t", "transacted_at" => 90.days.ago.to_i, "posted" => 90.days.ago.to_i, "amount" => "-5" }
]
)
sure_account = Account.create!(
family: @family, name: "Citi Double Cash Card-3831 (dismiss)",
balance: 0, currency: "USD",
accountable: CreditCard.create!(subtype: "credit_card")
)
AccountProvider.create!(account: sure_account, provider: old_sfa)
new_sfa = @simplefin_item.simplefin_accounts.create!(
name: "Citi-2879", account_id: "sf_2879_dismiss",
currency: "USD", account_type: "credit", current_balance: -1200,
org_data: { "name" => "Citibank" },
raw_transactions_payload: [
{ "id" => "n", "transacted_at" => 2.days.ago.to_i, "posted" => 2.days.ago.to_i, "amount" => "-100" }
]
)
sync = @simplefin_item.syncs.create!(status: :completed, sync_stats: {
"replacement_suggestions" => [
{
"dormant_sfa_id" => old_sfa.id,
"active_sfa_id" => new_sfa.id,
"sure_account_id" => sure_account.id,
"institution_name" => "Citibank",
"confidence" => "high"
}
]
})
sync.update_column(:created_at, Time.current)
# Banner is present before dismissal
get accounts_url
assert_match(/Citibank card may have been replaced/, response.body)
# Dismiss — pair key (dormant + active)
post dismiss_replacement_suggestion_simplefin_item_path(@simplefin_item), params: {
dormant_sfa_id: old_sfa.id,
active_sfa_id: new_sfa.id
}
sync.reload
assert_includes Array(sync.sync_stats["dismissed_replacement_suggestions"]),
"#{old_sfa.id}:#{new_sfa.id}"
# Banner is gone after dismissal
get accounts_url
refute_match(/Citibank card may have been replaced/, response.body,
"banner should not render for a dismissed pair")
end
test "replacement prompt relink button successfully swaps AccountProvider" do
old_sfa = @simplefin_item.simplefin_accounts.create!(
name: "Old", account_id: "o1", currency: "USD",
account_type: "credit", current_balance: 0
)
new_sfa = @simplefin_item.simplefin_accounts.create!(
name: "New", account_id: "n1", currency: "USD",
account_type: "credit", current_balance: -500
)
sure_account = Account.create!(
family: @family, name: "Citi", balance: 0, currency: "USD",
accountable: CreditCard.create!(subtype: "credit_card")
)
AccountProvider.create!(account: sure_account, provider: old_sfa)
# The relink button posts to link_existing_account just like the modal does
post link_existing_account_simplefin_items_path, params: {
account_id: sure_account.id,
simplefin_account_id: new_sfa.id
}
sure_account.reload
sf_aps = sure_account.account_providers.where(provider_type: "SimplefinAccount")
assert_equal 1, sf_aps.count
assert_equal new_sfa.id, sf_aps.first.provider_id
end
# Same-provider relink tests (Bug #3 — allow SimpleFIN-to-SimpleFIN swap without unlink dance)
test "link_existing_account allows relink when account is already SimpleFIN-linked via AccountProvider" do
# @account currently linked to sfa_old (fraud-replaced card). User picks sfa_new.
account = Account.create!(
family: @family,
name: "Citi Double Cash",
balance: 0,
currency: "USD",
accountable: CreditCard.create!(subtype: "credit_card")
)
sfa_old = @simplefin_item.simplefin_accounts.create!(
name: "Citi Card-OLD",
account_id: "sf_citi_old",
currency: "USD",
account_type: "credit",
current_balance: 0
)
sfa_new = @simplefin_item.simplefin_accounts.create!(
name: "Citi Card-NEW",
account_id: "sf_citi_new",
currency: "USD",
account_type: "credit",
current_balance: -100
)
AccountProvider.create!(account: account, provider: sfa_old)
post link_existing_account_simplefin_items_path, params: {
account_id: account.id,
simplefin_account_id: sfa_new.id
}
assert_response :see_other
# The SimpleFIN link should now point at sfa_new
account.reload
sf_providers = account.account_providers.where(provider_type: "SimplefinAccount")
assert_equal 1, sf_providers.count, "should have exactly one SimpleFIN link after relink"
assert_equal sfa_new.id, sf_providers.first.provider_id
# Old AccountProvider for sfa_old on this account is detached
refute AccountProvider.exists?(account_id: account.id, provider: sfa_old),
"old SimpleFIN AccountProvider for this account should be detached"
end
test "link_existing_account allows relink when account has only legacy simplefin_account_id FK" do
account = Account.create!(
family: @family,
name: "Citi Double Cash",
balance: 0,
currency: "USD",
accountable: CreditCard.create!(subtype: "credit_card")
)
sfa_old = @simplefin_item.simplefin_accounts.create!(
name: "Citi Card-OLD",
account_id: "sf_citi_old2",
currency: "USD",
account_type: "credit",
current_balance: 0
)
sfa_new = @simplefin_item.simplefin_accounts.create!(
name: "Citi Card-NEW",
account_id: "sf_citi_new2",
currency: "USD",
account_type: "credit",
current_balance: -100
)
account.update!(simplefin_account_id: sfa_old.id)
post link_existing_account_simplefin_items_path, params: {
account_id: account.id,
simplefin_account_id: sfa_new.id
}
assert_response :see_other
account.reload
assert_nil account.simplefin_account_id, "legacy SimpleFIN FK should be cleared"
assert_equal sfa_new.id,
account.account_providers.where(provider_type: "SimplefinAccount").first&.provider_id
end
test "link_existing_account rejects when account is linked to a foreign provider (Plaid)" do
account = Account.create!(
family: @family,
name: "Plaid-Linked",
balance: 0,
currency: "USD",
accountable: Depository.create!(subtype: "checking")
)
plaid_item = PlaidItem.create!(family: @family, name: "Plaid Conn", access_token: "t", plaid_id: "p")
plaid_acct = PlaidAccount.create!(
plaid_item: plaid_item,
plaid_id: "p_acct_1",
name: "Plaid A",
plaid_type: "depository",
currency: "USD",
current_balance: 0
)
AccountProvider.create!(account: account, provider: plaid_acct)
sfa = @simplefin_item.simplefin_accounts.create!(
name: "SF-Target",
account_id: "sf_target_1",
currency: "USD",
account_type: "depository",
current_balance: 100
)
post link_existing_account_simplefin_items_path, params: {
account_id: account.id,
simplefin_account_id: sfa.id
}
# Should NOT have attached the SimpleFIN provider
account.reload
assert_empty account.account_providers.where(provider_type: "SimplefinAccount")
# Plaid link should remain intact
assert account.account_providers.where(provider_type: "PlaidAccount").exists?
end
# Activity badge tests (helps users distinguish live vs replaced/closed cards during setup)
test "setup_accounts renders recent-transactions badge for active sfa" do
@simplefin_item.simplefin_accounts.create!(
name: "Active Card",
account_id: "active_card_1",
currency: "USD",
account_type: "credit",
current_balance: -123.45,
raw_transactions_payload: [
{ "id" => "t1", "transacted_at" => 3.days.ago.to_i, "posted" => 3.days.ago.to_i, "amount" => "-10" },
{ "id" => "t2", "transacted_at" => 10.days.ago.to_i, "posted" => 10.days.ago.to_i, "amount" => "-20" }
]
)
get setup_accounts_simplefin_item_url(@simplefin_item)
assert_response :success
assert_match(/2 transactions.*3 days ago/, response.body,
"expected active sfa to show recent transaction count and last activity")
end
test "setup_accounts renders 'likely closed' warning for dormant+zero-balance sfa" do
@simplefin_item.simplefin_accounts.create!(
name: "Dead Card",
account_id: "dead_card_1",
currency: "USD",
account_type: "credit",
current_balance: 0,
raw_transactions_payload: [
{ "id" => "old", "transacted_at" => 120.days.ago.to_i, "posted" => 120.days.ago.to_i, "amount" => "-5" }
]
)
get setup_accounts_simplefin_item_url(@simplefin_item)
assert_response :success
assert_match(/closed or replaced card/, response.body,
"expected dormant+zero-balance sfa to show closed/replaced warning")
end
test "setup_accounts renders 'no transactions imported' for empty sfa" do
@simplefin_item.simplefin_accounts.create!(
name: "Brand New Card",
account_id: "fresh_card_1",
currency: "USD",
account_type: "credit",
current_balance: 0,
raw_transactions_payload: []
)
get setup_accounts_simplefin_item_url(@simplefin_item)
assert_response :success
assert_match(/No transactions imported yet/, response.body)
end
test "setup_accounts renders 'dormant but has balance' as plain text not warning" do
# Legitimate dormant case: HSA/savings account with real balance but no recent activity.
# Should NOT be flagged as likely-closed because the balance is non-trivial.
@simplefin_item.simplefin_accounts.create!(
name: "Dormant HSA",
account_id: "dormant_hsa_1",
currency: "USD",
account_type: "investment",
current_balance: 5432.10,
raw_transactions_payload: [
{ "id" => "old", "transacted_at" => 120.days.ago.to_i, "posted" => 120.days.ago.to_i, "amount" => "100" }
]
)
get setup_accounts_simplefin_item_url(@simplefin_item)
assert_response :success
assert_match(/No activity in 120 days/, response.body)
refute_match(/closed or replaced card/, response.body,
"dormant accounts with real balances should not be marked as closed")
end
# Stale account detection and handling tests
test "setup_accounts detects stale accounts not in upstream API" do

View File

@@ -6,6 +6,12 @@ class SnaptradeItemsControllerTest < ActionDispatch::IntegrationTest
@snaptrade_item = snaptrade_items(:configured_item)
end
def sign_out
@user.sessions.each do |session|
delete session_path(session)
end
end
test "connect handles decryption error gracefully" do
SnaptradeItem.any_instance
.stubs(:user_registered?)
@@ -39,6 +45,78 @@ class SnaptradeItemsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to portal_url
end
test "select_accounts redirects unregistered users into connect flow" do
sign_out
sign_in @user = users(:empty)
snaptrade_item = snaptrade_items(:pending_registration_item)
get select_accounts_snaptrade_items_url, params: { accountable_type: "Investment", return_to: "setup_accounts" }
assert_redirected_to connect_snaptrade_item_path(snaptrade_item)
end
test "callback resumes setup flow after first-time connect detour" do
sign_out
sign_in @user = users(:empty)
snaptrade_item = snaptrade_items(:pending_registration_item)
assert_difference "Sync.count", 1 do
get select_accounts_snaptrade_items_url, params: { accountable_type: "Investment", return_to: "setup_accounts" }
assert_redirected_to connect_snaptrade_item_path(snaptrade_item)
get callback_snaptrade_items_url, params: { item_id: snaptrade_item.id }
end
assert_redirected_to setup_accounts_snaptrade_item_path(snaptrade_item, accountable_type: "Investment")
end
test "select_accounts redirects registered users to setup flow" do
get select_accounts_snaptrade_items_url, params: { accountable_type: "Investment", return_to: "/accounts" }
assert_redirected_to setup_accounts_snaptrade_item_path(@snaptrade_item, accountable_type: "Investment", return_to: "/accounts")
end
test "preload_accounts redirects unregistered users into connect flow" do
sign_out
sign_in @user = users(:empty)
snaptrade_item = snaptrade_items(:pending_registration_item)
assert_no_difference "Sync.count" do
get preload_accounts_snaptrade_items_url
end
assert_redirected_to connect_snaptrade_item_path(snaptrade_item)
end
test "preload_accounts redirects registered users to setup flow and queues sync" do
assert_difference "Sync.count", 1 do
get preload_accounts_snaptrade_items_url
end
assert_redirected_to setup_accounts_snaptrade_item_path(@snaptrade_item)
end
test "entry routing prefers a registered active item over a pending one" do
pending_item = @user.family.snaptrade_items.create!(
name: "Pending Registration",
client_id: "pending_client_id",
consumer_key: "pending_consumer_key",
status: :good,
scheduled_for_deletion: false,
pending_account_setup: true
)
get select_accounts_snaptrade_items_url, params: { accountable_type: "Investment", return_to: "/accounts" }
assert_redirected_to setup_accounts_snaptrade_item_path(@snaptrade_item, accountable_type: "Investment", return_to: "/accounts")
assert_difference "Sync.count", 1 do
get preload_accounts_snaptrade_items_url
end
assert_redirected_to setup_accounts_snaptrade_item_path(@snaptrade_item)
assert_not pending_item.user_registered?
end
test "setup_accounts shows linkable investment and crypto accounts in dropdown" do
get setup_accounts_snaptrade_item_url(@snaptrade_item)
@@ -70,6 +148,30 @@ class SnaptradeItemsControllerTest < ActionDispatch::IntegrationTest
assert_match accounts(:crypto).name, response.body
end
test "select_existing_account prefers registered active item over pending one" do
pending_item = @user.family.snaptrade_items.create!(
name: "Pending Registration",
client_id: "pending_client_id",
consumer_key: "pending_consumer_key",
status: :good,
scheduled_for_deletion: false,
pending_account_setup: true
)
pending_item.snaptrade_accounts.create!(
snaptrade_account_id: "pending_snaptrade_account",
name: "Pending Brokerage Account",
brokerage_name: "Pending Broker",
currency: "USD",
current_balance: 0
)
get select_existing_account_snaptrade_items_url, params: { account_id: accounts(:investment).id }
assert_response :success
assert_includes response.body, snaptrade_accounts(:fidelity_401k).name
refute_includes response.body, "Pending Brokerage Account"
end
test "link_existing_account links account to snaptrade_account" do
account = accounts(:investment)
snaptrade_account = snaptrade_accounts(:fidelity_401k)

View File

@@ -0,0 +1,950 @@
require "test_helper"
class SophtronItemsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in @user = users(:family_admin)
@item = @user.family.sophtron_items.create!(
name: "Sophtron",
user_id: "developer-user",
access_key: Base64.strict_encode64("secret-key"),
customer_id: "cust-1"
)
end
test "select_accounts renders institution connection flow when no institution is connected" do
SophtronItem.any_instance.stubs(:ensure_customer!).returns("cust-1")
get select_accounts_sophtron_items_url
assert_response :success
assert_includes response.body, "Connect Sophtron Institution"
end
test "select_accounts renders institution search after failed connection attempt" do
@item.update!(user_institution_id: "ui-1", status: :requires_update)
SophtronItem.any_instance.stubs(:ensure_customer!).returns("cust-1")
get select_accounts_sophtron_items_url
assert_response :success
assert_includes response.body, "Connect Sophtron Institution"
end
test "select_accounts renders institution search after stale Sophtron timeout" do
@item.update!(
user_institution_id: "ui-1",
status: :good,
job_status: "Timeout",
raw_job_payload: {
AccountID: "00000000-0000-0000-0000-000000000000",
JobType: "AddAccounts",
JobID: "job-1",
SuccessFlag: false,
LastStep: "LogInPanel",
LastStatus: "Timeout"
}
)
SophtronItem.any_instance.stubs(:ensure_customer!).returns("cust-1")
get select_accounts_sophtron_items_url
assert_response :success
assert_includes response.body, "Connect Sophtron Institution"
end
test "select_accounts can start a new institution connection when already connected" do
@item.update!(user_institution_id: "ui-1", status: :good)
SophtronItem.any_instance.stubs(:ensure_customer!).returns("cust-1")
get select_accounts_sophtron_items_url(connect_new_institution: true)
assert_response :success
assert_includes response.body, "Connect Sophtron Institution"
assert_includes response.body, 'name="connect_new_institution"'
end
test "member cannot access Sophtron account selection" do
sign_in users(:family_member)
get select_accounts_sophtron_items_url
assert_redirected_to accounts_path
end
test "cannot access another family's Sophtron item" do
other_item = families(:empty).sophtron_items.create!(
name: "Other Sophtron",
user_id: "other-developer-user",
access_key: Base64.strict_encode64("other-secret")
)
get connection_status_sophtron_item_url(other_item)
assert_response :not_found
end
test "connect_institution persists job and user institution ids" do
provider = mock
provider.expects(:create_user_institution).with(
institution_id: "inst-1",
username: "bank-user",
password: "bank-pass",
pin: ""
).returns({
JobID: "job-1",
UserInstitutionID: "ui-1"
})
SophtronItem.any_instance.stubs(:ensure_customer!).returns("cust-1")
SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider)
post connect_institution_sophtron_item_url(@item), params: {
institution_id: "inst-1",
institution_name: "Example Bank",
bank_username: "bank-user",
bank_password: "bank-pass"
}
@item.reload
assert_equal "Sophtron", @item.name
assert_equal "Example Bank", @item.institution_name
assert_equal "job-1", @item.current_job_id
assert_equal "ui-1", @item.user_institution_id
assert_redirected_to connection_status_sophtron_item_path(@item)
end
test "connect_institution creates separate item for additional institution" do
@item.update!(
institution_id: "apple-inst",
institution_name: "Apple / Goldman Sachs",
user_institution_id: "ui-apple",
status: :good
)
@item.sophtron_accounts.create!(
name: "Juan",
account_id: "card-1",
currency: "USD",
balance: 1_947.18,
institution_metadata: {
name: "Apple / Goldman Sachs",
user_institution_id: "ui-apple"
}
)
provider = mock
provider.expects(:create_user_institution).with(
institution_id: "amazon-inst",
username: "bank-user",
password: "bank-pass",
pin: ""
).returns({
JobID: "job-amazon",
UserInstitutionID: "ui-amazon"
})
SophtronItem.any_instance.stubs(:ensure_customer!).returns("cust-1")
SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider)
assert_difference -> { @user.family.sophtron_items.count }, 1 do
post connect_institution_sophtron_item_url(@item), params: {
institution_id: "amazon-inst",
institution_name: "Amazon",
bank_username: "bank-user",
bank_password: "bank-pass",
connect_new_institution: true
}
end
@item.reload
new_item = @user.family.sophtron_items.find_by!(user_institution_id: "ui-amazon")
assert_equal "Apple / Goldman Sachs", @item.institution_name
assert_equal "ui-apple", @item.user_institution_id
assert_equal "Amazon", new_item.institution_name
assert_equal "ui-amazon", new_item.user_institution_id
assert_equal "job-amazon", new_item.current_job_id
assert_redirected_to connection_status_sophtron_item_path(new_item, connect_new_institution: "true")
end
test "Sophtron bank credentials and mfa inputs are filtered from logs" do
parameter_filter = ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters)
filtered_params = parameter_filter.filter(
bank_username: "bank-user",
bank_password: "bank-pass",
security_answers: [ "blue" ],
captcha_input: "captcha"
)
assert_equal "[FILTERED]", filtered_params[:bank_username]
assert_equal "[FILTERED]", filtered_params[:bank_password]
assert_equal "[FILTERED]", filtered_params[:security_answers]
assert_equal "[FILTERED]", filtered_params[:captcha_input]
end
test "create verifies credentials and persists provisioned customer id" do
stub_request(:get, "https://api.sophtron.com/api/Institution/HealthCheckAuth")
.to_return(status: 200, body: "")
stub_request(:get, "https://api.sophtron.com/api/v2/customers")
.to_return(status: 200, body: [].to_json)
stub_request(:post, "https://api.sophtron.com/api/v2/customers")
.to_return(status: 200, body: {
CustomerID: "cust-new",
CustomerName: "Sure family #{@user.family.id}"
}.to_json)
assert_difference "SophtronItem.count", 1 do
post sophtron_items_url, params: {
sophtron_item: {
name: "New Sophtron",
user_id: "developer-user",
access_key: Base64.strict_encode64("secret-key")
}
}
end
item = @user.family.sophtron_items.find_by!(name: "New Sophtron")
assert_equal "cust-new", item.customer_id
assert_redirected_to accounts_path
end
test "connection_status renders MFA challenge when Sophtron asks for security answers" do
@item.update!(user_institution_id: "ui-1", current_job_id: "job-1")
provider = mock
provider.expects(:get_job_information).with("job-1").returns({
SecurityQuestion: [ "What is your favorite color?" ].to_json,
SuccessFlag: nil,
LastStatus: "Waiting"
})
SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider)
get connection_status_sophtron_item_url(@item)
assert_response :success
assert_includes response.body, "What is your favorite color?"
end
test "connection_status sanitizes captcha image before rendering data uri" do
@item.update!(user_institution_id: "ui-1", current_job_id: "job-1")
provider = mock
provider.expects(:get_job_information).with("job-1").returns({
CaptchaImage: "YWJj+/=\"><svg onload=alert(1)>",
LastStatus: "Waiting"
})
SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider)
get connection_status_sophtron_item_url(@item)
assert_response :success
captcha_src = response.body[/src="data:image\/png;base64,([^"]+)"/, 1]
assert_equal "YWJj+/=", captcha_src
assert_no_match(/svg|onload|alert|[<>"\s]/i, captcha_src)
end
test "connection_status renders token challenge before failed timeout handling" do
@item.update!(user_institution_id: "ui-1", current_job_id: "job-1")
provider = mock
provider.expects(:get_job_information).with("job-1").returns({
AccountID: "00000000-0000-0000-0000-000000000000",
JobType: "AddAccounts",
JobID: "job-1",
SuccessFlag: false,
TokenSentFlag: true,
TokenInputName: "Token",
LastStep: "TokenInput",
LastStatus: "Timeout"
})
SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider)
get connection_status_sophtron_item_url(@item)
assert_response :success
assert_includes response.body, "Verification code"
assert_not_includes response.body, "Sophtron could not complete this connection."
end
test "connection_status times out after max UI polls" do
@item.update!(user_institution_id: "ui-1", current_job_id: "job-1")
provider = mock
provider.expects(:get_job_information).with("job-1").returns({
AccountID: "00000000-0000-0000-0000-000000000000",
JobType: "AddAccounts",
JobID: "job-1"
})
SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider)
get connection_status_sophtron_item_url(@item, poll_attempt: SophtronItemsController::CONNECTION_STATUS_MAX_POLLS)
assert_response :success
assert_includes response.body, "Sophtron did not finish connecting"
assert_includes response.body, "Attempt #{SophtronItemsController::CONNECTION_STATUS_MAX_POLLS} of #{SophtronItemsController::CONNECTION_STATUS_MAX_POLLS}"
assert_equal "requires_update", @item.reload.status
assert_equal "job-1", @item.current_job_id
end
test "connection_status increments polling attempt while job is still running" do
@item.update!(user_institution_id: "ui-1", current_job_id: "job-1")
provider = mock
provider.expects(:get_job_information).with("job-1").returns({
AccountID: "00000000-0000-0000-0000-000000000000",
JobType: "AddAccounts",
JobID: "job-1"
})
SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider)
get connection_status_sophtron_item_url(@item, poll_attempt: 2)
assert_response :success
assert_includes response.body, "Attempt 2 of #{SophtronItemsController::CONNECTION_STATUS_MAX_POLLS}"
assert_includes response.body, "poll_attempt=3"
assert_includes response.body, 'data-controller="polling"'
assert_includes response.body, 'data-polling-frame-id-value="modal"'
assert_includes response.body, 'data-turbo-prefetch="false"'
assert_select "a[href*='poll_attempt=3']"
end
test "connection_status keeps polling through the third initial attempt for delayed otp" do
@item.update!(user_institution_id: "ui-1", current_job_id: "job-1")
provider = mock
provider.expects(:get_job_information).with("job-1").returns({
AccountID: "00000000-0000-0000-0000-000000000000",
JobType: "AddAccounts",
JobID: "job-1"
})
SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider)
get connection_status_sophtron_item_url(@item, poll_attempt: 3)
assert_response :success
assert_includes response.body, "Attempt 3 of #{SophtronItemsController::CONNECTION_STATUS_MAX_POLLS}"
assert_includes response.body, "poll_attempt=4"
assert_not_includes response.body, "Sophtron did not finish connecting"
assert_not_equal "requires_update", @item.reload.status
end
test "connection_status extends polling when Sophtron starts institution login before otp" do
@item.update!(user_institution_id: "ui-1", current_job_id: "job-1")
provider = mock
provider.expects(:get_job_information).with("job-1").returns({
AccountID: "00000000-0000-0000-0000-000000000000",
JobType: "AddAccounts",
JobID: "job-1",
LastStep: "LogInPanel",
LastStatus: "Started"
})
SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider)
get connection_status_sophtron_item_url(@item, poll_attempt: SophtronItemsController::CONNECTION_STATUS_MAX_POLLS)
assert_response :success
assert_includes response.body, "Attempt #{SophtronItemsController::CONNECTION_STATUS_MAX_POLLS} of #{SophtronItemsController::LOGIN_PROGRESS_CONNECTION_STATUS_MAX_POLLS}"
assert_includes response.body, "poll_attempt=#{SophtronItemsController::CONNECTION_STATUS_MAX_POLLS + 1}"
assert_not_includes response.body, "Sophtron did not finish connecting"
assert_not_equal "requires_update", @item.reload.status
end
test "connection_status keeps polling after initial max when login progress was already seen" do
@item.update!(
user_institution_id: "ui-1",
current_job_id: "job-1",
raw_job_payload: {
AccountID: "00000000-0000-0000-0000-000000000000",
JobType: "AddAccounts",
JobID: "job-1",
LastStep: "LogInPanel",
LastStatus: "Started"
}
)
provider = mock
provider.expects(:get_job_information).with("job-1").returns({
AccountID: "00000000-0000-0000-0000-000000000000",
JobType: "AddAccounts",
JobID: "job-1",
LastStep: "LogInPanel",
LastStatus: "Started"
})
SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider)
get connection_status_sophtron_item_url(@item, poll_attempt: SophtronItemsController::CONNECTION_STATUS_MAX_POLLS + 1)
assert_response :success
assert_includes response.body, "Attempt #{SophtronItemsController::CONNECTION_STATUS_MAX_POLLS + 1} of #{SophtronItemsController::LOGIN_PROGRESS_CONNECTION_STATUS_MAX_POLLS}"
assert_includes response.body, "poll_attempt=#{SophtronItemsController::CONNECTION_STATUS_MAX_POLLS + 2}"
assert_not_includes response.body, "Sophtron did not finish connecting"
assert_not_equal "requires_update", @item.reload.status
end
test "connection_status uses longer polling after mfa is submitted" do
@item.update!(user_institution_id: "ui-1", current_job_id: "job-1")
provider = mock
provider.expects(:get_job_information).with("job-1").returns({
AccountID: "00000000-0000-0000-0000-000000000000",
JobType: "AddAccounts",
JobID: "job-1",
TokenInput: "123456",
LastStep: "TransactionTable",
LastStatus: "Started"
})
SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider)
get connection_status_sophtron_item_url(@item, poll_attempt: SophtronItemsController::CONNECTION_STATUS_MAX_POLLS, post_mfa: true)
assert_response :success
assert_includes response.body, "Attempt #{SophtronItemsController::CONNECTION_STATUS_MAX_POLLS} of 15"
assert_includes response.body, "poll_attempt=#{SophtronItemsController::CONNECTION_STATUS_MAX_POLLS + 1}"
assert_not_includes response.body, "Sophtron did not finish connecting"
end
test "connection_status renders accounts when post mfa completed job has available accounts" do
@item.update!(user_institution_id: "ui-1", current_job_id: "job-1")
provider = mock
provider.expects(:get_job_information).with("job-1").returns({
AccountID: "00000000-0000-0000-0000-000000000000",
JobType: "AddAccounts",
JobID: "job-1",
TokenInput: "123456",
LastStep: "TokenInput",
LastStatus: "Completed"
})
provider.expects(:get_accounts).with("ui-1").returns({
accounts: [
{
id: "acct-1",
account_id: "acct-1",
account_name: "Sophtron Checking",
institution_name: "Example Bank",
balance: "123.45",
balance_currency: "USD",
currency: "USD",
status: "active"
}.with_indifferent_access
],
total: 1
})
SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider)
get connection_status_sophtron_item_url(@item, poll_attempt: 5, post_mfa: true)
assert_response :success
assert_includes response.body, "Sophtron Checking"
assert_not_includes response.body, "poll_attempt=6"
@item.reload
assert_nil @item.current_job_id
assert_equal "good", @item.status
assert @item.pending_account_setup?
end
test "connection_status keeps polling when post mfa completed job has no accounts yet" do
@item.update!(user_institution_id: "ui-1", current_job_id: "job-1")
provider = mock
provider.expects(:get_job_information).with("job-1").returns({
AccountID: "00000000-0000-0000-0000-000000000000",
JobType: "AddAccounts",
JobID: "job-1",
TokenInput: "123456",
LastStep: "Reset",
LastStatus: "Completed"
})
provider.expects(:get_accounts).with("ui-1").returns({ accounts: [], total: 0 })
SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider)
get connection_status_sophtron_item_url(@item, poll_attempt: 6, post_mfa: true)
assert_response :success
assert_includes response.body, "Attempt 6 of #{SophtronItemsController::POST_MFA_CONNECTION_STATUS_MAX_POLLS}"
assert_includes response.body, "poll_attempt=7"
assert_not_includes response.body, "Sophtron did not finish connecting"
assert_equal "job-1", @item.reload.current_job_id
end
test "connection_status ignores browser prefetches" do
@item.update!(user_institution_id: "ui-1", current_job_id: "job-1")
SophtronItem.any_instance.expects(:sophtron_provider).never
get connection_status_sophtron_item_url(@item, poll_attempt: 2), headers: { "X-Sec-Purpose" => "prefetch" }
assert_response :no_content
assert_nil @item.reload.job_status
end
test "connection_status treats Sophtron timeout as failed" do
@item.update!(user_institution_id: "ui-1", current_job_id: "job-1")
provider = mock
provider.expects(:get_job_information).with("job-1").returns({
AccountID: "00000000-0000-0000-0000-000000000000",
JobType: "AddAccounts",
JobID: "job-1",
SuccessFlag: false,
LastStep: "LogInPanel",
LastStatus: "Timeout"
})
SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider)
get connection_status_sophtron_item_url(@item)
assert_response :success
assert_includes response.body, "Sophtron timed out while the institution was completing login."
assert_includes response.body, "Unable to connect to the institution"
assert_includes response.body, "Bank credentials"
assert_includes response.body, "Verification code"
assert_includes response.body, "Try connecting again"
assert_not_includes response.body, "Check Provider Settings"
@item.reload
assert_equal "requires_update", @item.status
assert_nil @item.current_job_id
assert_nil @item.user_institution_id
end
test "submit_mfa sends security answer as array string" do
@item.update!(user_institution_id: "ui-1", current_job_id: "job-1")
provider = mock
provider.expects(:update_job_security_answer).with("job-1", [ "blue" ]).returns({})
SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider)
post submit_mfa_sophtron_item_url(@item), params: {
mfa_type: "security_answer",
security_answers: [ "blue" ]
}
assert_redirected_to connection_status_sophtron_item_path(@item, post_mfa: true)
end
test "submit_mfa rejects too many security answers" do
@item.update!(user_institution_id: "ui-1", current_job_id: "job-1")
provider = mock
provider.expects(:update_job_security_answer).never
SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider)
post submit_mfa_sophtron_item_url(@item), params: {
mfa_type: "security_answer",
security_answers: Array.new(SophtronItemsController::MAX_SECURITY_ANSWERS + 1, "blue")
}
assert_redirected_to connection_status_sophtron_item_path(@item)
assert_equal "Security answers are missing or too long.", flash[:alert]
end
test "submit_mfa rejects oversized security answers" do
@item.update!(user_institution_id: "ui-1", current_job_id: "job-1")
provider = mock
provider.expects(:update_job_security_answer).never
SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider)
post submit_mfa_sophtron_item_url(@item), params: {
mfa_type: "security_answer",
security_answers: [ "a" * (SophtronItemsController::MAX_SECURITY_ANSWER_LENGTH + 1) ]
}
assert_redirected_to connection_status_sophtron_item_path(@item)
assert_equal "Security answers are missing or too long.", flash[:alert]
end
test "submit_mfa redirects to post mfa polling window" do
@item.update!(user_institution_id: "ui-1", current_job_id: "job-1")
provider = mock
provider.expects(:update_job_token_input).with("job-1", token_input: "123456").returns({})
SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider)
post submit_mfa_sophtron_item_url(@item), params: {
mfa_type: "token_input",
token_input: "123456"
}
assert_redirected_to connection_status_sophtron_item_path(@item, post_mfa: true)
end
test "toggle_manual_sync marks linked Sophtron institution accounts manual" do
sophtron_account = @item.sophtron_accounts.create!(
account_id: "acct-1",
name: "Sophtron Checking",
currency: "USD",
balance: 100
)
AccountProvider.create!(account: accounts(:depository), provider: sophtron_account)
assert_not @item.manual_sync?
assert_not sophtron_account.manual_sync?
post toggle_manual_sync_sophtron_item_url(@item)
assert_redirected_to accounts_path
assert_not @item.reload.manual_sync?
assert sophtron_account.reload.manual_sync?
assert_includes SophtronItem.syncable, @item
assert_equal "Sophtron institution now requires manual sync.", flash[:notice]
end
test "toggle_manual_sync can target one Sophtron institution on a mixed item" do
first_sophtron_account = @item.sophtron_accounts.create!(
account_id: "acct-1",
name: "Apple Card",
currency: "USD",
balance: 100,
institution_metadata: { name: "Apple", user_institution_id: "ui-apple" }
)
second_sophtron_account = @item.sophtron_accounts.create!(
account_id: "acct-2",
name: "Amazon Card",
currency: "USD",
balance: 200,
institution_metadata: { name: "Amazon", user_institution_id: "ui-amazon" }
)
AccountProvider.create!(account: accounts(:depository), provider: first_sophtron_account)
AccountProvider.create!(account: accounts(:credit_card), provider: second_sophtron_account)
post toggle_manual_sync_sophtron_item_url(@item), params: { user_institution_id: "ui-amazon" }
assert_not first_sophtron_account.reload.manual_sync?
assert second_sophtron_account.reload.manual_sync?
end
test "toggle_manual_sync makes targeted institution automatic when whole item is manual" do
first_sophtron_account = @item.sophtron_accounts.create!(
account_id: "acct-1",
name: "Apple Card",
currency: "USD",
balance: 100,
institution_metadata: { name: "Apple", user_institution_id: "ui-apple" }
)
second_sophtron_account = @item.sophtron_accounts.create!(
account_id: "acct-2",
name: "Amazon Card",
currency: "USD",
balance: 200,
institution_metadata: { name: "Amazon", user_institution_id: "ui-amazon" }
)
AccountProvider.create!(account: accounts(:depository), provider: first_sophtron_account)
AccountProvider.create!(account: accounts(:credit_card), provider: second_sophtron_account)
@item.update!(manual_sync: true)
post toggle_manual_sync_sophtron_item_url(@item), params: { user_institution_id: "ui-amazon" }
assert_not @item.reload.manual_sync?
assert first_sophtron_account.reload.manual_sync?
assert_not second_sophtron_account.reload.manual_sync?
assert_equal "Sophtron institution will sync automatically.", flash[:notice]
end
test "manual sync starts Sophtron refresh and renders MFA challenge" do
@item.update!(user_institution_id: "ui-1")
sophtron_account = @item.sophtron_accounts.create!(
account_id: "acct-1",
name: "Sophtron Checking",
currency: "USD",
balance: 100,
manual_sync: true
)
AccountProvider.create!(account: accounts(:depository), provider: sophtron_account)
provider = mock
provider.expects(:refresh_account).with("acct-1").returns({ JobID: "job-1" })
provider.expects(:get_job_information).with("job-1").returns({
SecurityQuestion: [ "What is your favorite color?" ].to_json,
SuccessFlag: nil,
LastStatus: "Waiting"
})
SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider)
assert_no_enqueued_jobs only: SyncJob do
assert_difference -> { @item.syncs.count }, 1 do
post sync_sophtron_item_url(@item)
end
end
assert_response :success
assert_includes response.body, "What is your favorite color?"
assert_equal "job-1", @item.reload.current_job_id
assert_equal sophtron_account.id, @item.current_job_sophtron_account_id
assert @item.syncs.ordered.first.syncing?
end
test "manual sync creates its own sync when an automatic sync is visible" do
@item.update!(user_institution_id: "ui-1")
automatic_sync = @item.syncs.create!
automatic_sync.start!
sophtron_account = @item.sophtron_accounts.create!(
account_id: "acct-1",
name: "Sophtron Checking",
currency: "USD",
balance: 100,
manual_sync: true
)
AccountProvider.create!(account: accounts(:depository), provider: sophtron_account)
provider = mock
provider.expects(:refresh_account).with("acct-1").returns({ JobID: "job-1" })
provider.expects(:get_job_information).with("job-1").returns({
SecurityQuestion: [ "What is your favorite color?" ].to_json,
LastStatus: "Waiting"
})
SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider)
assert_difference -> { @item.syncs.count }, 1 do
post sync_sophtron_item_url(@item)
end
assert_response :success
assert_equal automatic_sync.id, @item.syncs.ordered.second.id
manual_sync = @item.syncs.ordered.first
assert_equal [], manual_sync.sync_stats["manual_sync_processed_sophtron_account_ids"]
assert_includes response.body, "value=\"#{manual_sync.id}\""
assert_not_includes response.body, "value=\"#{automatic_sync.id}\""
end
test "manual sync does not start another refresh while one is active" do
@item.update!(user_institution_id: "ui-1")
sophtron_account = @item.sophtron_accounts.create!(
account_id: "acct-1",
name: "Sophtron Checking",
currency: "USD",
balance: 100,
manual_sync: true
)
AccountProvider.create!(account: accounts(:depository), provider: sophtron_account)
sync = @item.syncs.create!(sync_stats: { SophtronItemsController::MANUAL_SYNC_PROCESSED_ACCOUNT_IDS_KEY => [] })
sync.start!
@item.update!(current_job_id: "job-1", current_job_sophtron_account_id: sophtron_account.id)
SophtronItem.any_instance.expects(:sophtron_provider).never
post sync_sophtron_item_url(@item)
assert_redirected_to connection_status_sophtron_item_path(
@item,
manual_sync: true,
sync_id: sync.id,
sophtron_account_id: sophtron_account.id
)
assert_equal "Sophtron manual sync is already in progress.", flash[:alert]
end
test "manual sync refreshes every linked Sophtron account" do
@item.update!(user_institution_id: "ui-1")
first_sophtron_account = @item.sophtron_accounts.create!(
account_id: "acct-1",
name: "Sophtron Checking",
currency: "USD",
balance: 100,
manual_sync: true
)
second_sophtron_account = @item.sophtron_accounts.create!(
account_id: "acct-2",
name: "Sophtron Card",
currency: "USD",
balance: 200,
manual_sync: true
)
AccountProvider.create!(account: accounts(:depository), provider: first_sophtron_account)
AccountProvider.create!(account: accounts(:credit_card), provider: second_sophtron_account)
provider = mock
sequence = sequence("sophtron manual refresh")
provider.expects(:refresh_account).with("acct-1").in_sequence(sequence).returns({ JobID: "job-1" })
provider.expects(:get_job_information).with("job-1").in_sequence(sequence).returns({ LastStatus: "Completed" })
provider.expects(:refresh_account).with("acct-2").in_sequence(sequence).returns({ JobID: "job-2" })
provider.expects(:get_job_information).with("job-2").in_sequence(sequence).returns({ LastStatus: "Completed" })
SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider)
SophtronItem::Importer.any_instance.expects(:import_transactions_after_refresh)
.with(first_sophtron_account)
.returns({ success: true, transactions_count: 1 })
SophtronItem::Importer.any_instance.expects(:import_transactions_after_refresh)
.with(second_sophtron_account)
.returns({ success: true, transactions_count: 1 })
SophtronAccount::Processor.any_instance.expects(:process).twice.returns({ transactions_imported: 1 })
assert_enqueued_jobs 2, only: SyncJob do
post sync_sophtron_item_url(@item)
end
assert_response :success
assert_includes response.body, "Transactions were downloaded after Sophtron verification."
@item.reload
assert_nil @item.current_job_id
assert_nil @item.current_job_sophtron_account_id
assert_equal(
[ first_sophtron_account.id, second_sophtron_account.id ].map(&:to_s),
@item.syncs.ordered.first.sync_stats["manual_sync_processed_sophtron_account_ids"]
)
stats = @item.syncs.ordered.first.sync_stats
assert_equal 2, stats["total_accounts"]
assert_equal 2, stats["linked_accounts"]
assert_equal 0, stats["unlinked_accounts"]
assert_equal 0, stats["total_errors"]
assert stats.key?("tx_seen")
assert stats.key?("tx_imported")
assert stats.key?("tx_updated")
end
test "manual sync clears job pointers when refresh job fails" do
@item.update!(user_institution_id: "ui-1")
sophtron_account = @item.sophtron_accounts.create!(
account_id: "acct-1",
name: "Sophtron Checking",
currency: "USD",
balance: 100,
manual_sync: true
)
AccountProvider.create!(account: accounts(:depository), provider: sophtron_account)
provider = mock
provider.expects(:refresh_account).with("acct-1").returns({ JobID: "job-1" })
provider.expects(:get_job_information).with("job-1").returns({
SuccessFlag: false,
LastStatus: "Timeout"
})
SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider)
post sync_sophtron_item_url(@item)
assert_redirected_to accounts_path
@item.reload
assert_nil @item.current_job_id
assert_nil @item.current_job_sophtron_account_id
assert_equal "requires_update", @item.status
assert_equal "Sophtron manual sync failed", @item.last_connection_error
assert @item.syncs.ordered.first.failed?
end
test "manual sync clears job pointers when job polling raises provider error" do
@item.update!(user_institution_id: "ui-1")
sophtron_account = @item.sophtron_accounts.create!(
account_id: "acct-1",
name: "Sophtron Checking",
currency: "USD",
balance: 100,
manual_sync: true
)
AccountProvider.create!(account: accounts(:depository), provider: sophtron_account)
provider = mock
provider.expects(:refresh_account).with("acct-1").returns({ JobID: "job-1" })
provider.expects(:get_job_information)
.with("job-1")
.raises(Provider::Sophtron::Error.new("Sophtron unavailable", :api_error))
SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider)
post sync_sophtron_item_url(@item)
assert_redirected_to accounts_path
@item.reload
assert_nil @item.current_job_id
assert_nil @item.current_job_sophtron_account_id
assert_equal "requires_update", @item.status
assert_equal "Sophtron unavailable", @item.last_connection_error
assert @item.syncs.ordered.first.failed?
end
test "manual sync fails and clears job pointers when processing raises" do
@item.update!(user_institution_id: "ui-1")
sophtron_account = @item.sophtron_accounts.create!(
account_id: "acct-1",
name: "Sophtron Checking",
currency: "USD",
balance: 100,
manual_sync: true
)
AccountProvider.create!(account: accounts(:depository), provider: sophtron_account)
provider = mock
provider.expects(:refresh_account).with("acct-1").returns({ JobID: "job-1" })
provider.expects(:get_job_information).with("job-1").returns({ LastStatus: "Completed" })
SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider)
SophtronItem::Importer.any_instance.expects(:import_transactions_after_refresh)
.with(sophtron_account)
.returns({ success: true, transactions_count: 1 })
SophtronAccount::Processor.any_instance.expects(:process).raises(StandardError.new("processor failed"))
post sync_sophtron_item_url(@item)
assert_redirected_to accounts_path
@item.reload
assert_nil @item.current_job_id
assert_nil @item.current_job_sophtron_account_id
assert_equal "requires_update", @item.status
assert_equal "processor failed", @item.last_connection_error
assert @item.syncs.ordered.first.failed?
assert_equal "Sophtron manual sync failed: Sophtron manual sync could not process the refreshed transactions.", flash[:alert]
assert_not_includes flash[:alert], "processor failed"
end
test "submit_mfa preserves manual sync context" do
@item.update!(user_institution_id: "ui-1", current_job_id: "job-1")
sync = @item.syncs.create!
sophtron_account = @item.sophtron_accounts.create!(
account_id: "acct-1",
name: "Sophtron Checking",
currency: "USD",
balance: 100,
manual_sync: true
)
provider = mock
provider.expects(:update_job_token_input).with("job-1", token_input: "123456").returns({})
SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider)
post submit_mfa_sophtron_item_url(@item), params: {
mfa_type: "token_input",
token_input: "123456",
manual_sync: true,
sync_id: sync.id,
sophtron_account_id: sophtron_account.id
}
assert_redirected_to connection_status_sophtron_item_path(
@item,
manual_sync: "true",
post_mfa: true,
sophtron_account_id: sophtron_account.id,
sync_id: sync.id
)
end
test "link_existing_account links manual account to sophtron account" do
@item.update!(user_institution_id: "ui-1")
account = accounts(:depository)
provider = mock
provider.expects(:get_accounts).with("ui-1").returns({
accounts: [
{
id: "acct-1",
account_id: "acct-1",
account_name: "Sophtron Checking",
balance: "123.45",
balance_currency: "USD",
currency: "USD",
account_type: "checking"
}.with_indifferent_access
],
total: 1
})
SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider)
SophtronItem.any_instance.stubs(:start_initial_load_later)
assert_difference "AccountProvider.count", 1 do
post link_existing_account_sophtron_items_url, params: {
account_id: account.id,
sophtron_account_id: "acct-1"
}
end
assert account.reload.linked?
assert_equal "SophtronAccount", account.account_providers.first.provider_type
assert_redirected_to accounts_path
end
end

View File

@@ -120,6 +120,26 @@ class SplitsControllerTest < ActionDispatch::IntegrationTest
assert_response :success
end
test "create with excluded parameter sets child as excluded" do
assert_difference "Entry.count", 2 do
post transaction_split_path(@entry), params: {
split: {
splits: [
{ name: "Groceries", amount: "-70", category_id: categories(:food_and_drink).id, excluded: "true" },
{ name: "Household", amount: "-30", category_id: "", excluded: "false" }
]
}
}
end
assert_redirected_to transactions_url
children = @entry.child_entries.order(:amount)
# Household has amount 30 (smaller), Groceries has amount 70 (larger)
# Household is NOT excluded, Groceries IS excluded
refute children.first.excluded?
assert children.last.excluded?
end
# Edit action tests
test "edit renders with existing children pre-filled" do
@entry.split!([
@@ -193,6 +213,27 @@ class SplitsControllerTest < ActionDispatch::IntegrationTest
assert_equal 2, @entry.reload.child_entries.count
end
test "update with excluded parameter sets child as excluded" do
@entry.split!([
{ name: "Groceries", amount: 70, category_id: nil },
{ name: "Household", amount: 30, category_id: nil }
])
patch transaction_split_path(@entry), params: {
split: {
splits: [
{ name: "Groceries", amount: "-70", category_id: "", excluded: "true" },
{ name: "Household", amount: "-30", category_id: "", excluded: "false" }
]
}
}
assert_redirected_to transactions_url
children = @entry.child_entries.order(:amount)
refute children.first.excluded?
assert children.last.excluded?
end
# Destroy from child tests
test "destroy from child resolves to parent and unsplits" do
@entry.split!([

View File

@@ -20,7 +20,7 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
nature: "inflow",
entryable_type: @entry.entryable_type,
entryable_attributes: {
tag_ids: [ Tag.first.id, Tag.second.id ],
tag_ids: [ tags(:one).id, tags(:two).id ],
category_id: Category.first.id,
merchant_id: Merchant.first.id
}
@@ -49,7 +49,7 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
excluded: false,
entryable_attributes: {
id: @entry.entryable_id,
tag_ids: [ Tag.first.id, Tag.second.id ],
tag_ids: [ tags(:one).id, tags(:two).id ],
category_id: Category.first.id,
merchant_id: Merchant.first.id
}
@@ -63,7 +63,7 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
assert_equal Date.current, @entry.date
assert_equal "USD", @entry.currency
assert_equal -100, @entry.amount
assert_equal [ Tag.first.id, Tag.second.id ], @entry.entryable.tag_ids.sort
assert_equal [ tags(:one).id, tags(:two).id ].sort, @entry.entryable.tag_ids.sort
assert_equal Category.first.id, @entry.entryable.category_id
assert_equal Merchant.first.id, @entry.entryable.merchant_id
assert_equal "test notes", @entry.notes
@@ -96,6 +96,47 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
assert_dom "#total-transactions", count: 1, text: "1"
end
test "can update notes on split child transaction" do
parent = create_transaction(account: accounts(:depository), amount: 100)
parent.split!([ { name: "Part 1", amount: 60, category_id: nil }, { name: "Part 2", amount: 40, category_id: nil } ])
child = parent.child_entries.first
patch transaction_url(child), params: {
entry: { notes: "split child note", entryable_attributes: { id: child.entryable_id } }
}
assert_response :redirect
assert_equal "split child note", child.reload.notes
end
test "can update tags on split child transaction" do
parent = create_transaction(account: accounts(:depository), amount: 100)
parent.split!([ { name: "Part 1", amount: 60, category_id: nil }, { name: "Part 2", amount: 40, category_id: nil } ])
child = parent.child_entries.first
tag = tags(:one)
patch transaction_url(child), params: {
entry: { entryable_attributes: { id: child.entryable_id, tag_ids: [ tag.id ] } }
}
assert_response :redirect
assert_equal [ tag.id ], child.reload.entryable.tag_ids
end
test "split parent rows mark amount as privacy-sensitive" do
entry = create_transaction(account: accounts(:depository), amount: 100, name: "Split parent")
entry.split!([
{ name: "Part 1", amount: 60, category_id: nil },
{ name: "Part 2", amount: 40, category_id: nil }
])
get transactions_url
assert_response :success
assert_select ".split-group > div.opacity-50 p.privacy-sensitive", count: 1
end
test "can paginate" do
family = families(:empty)
sign_in users(:empty)
@@ -390,4 +431,157 @@ end
assert_not entry.import_locked?
assert_not entry.protected_from_sync?
end
test "exchange_rate endpoint returns rate for different currencies" do
ExchangeRate.expects(:find_or_fetch_rate)
.with(from: "EUR", to: "USD", date: Date.current)
.returns(1.2)
get exchange_rate_url, params: {
from: "EUR",
to: "USD",
date: Date.current
}
assert_response :success
json_response = JSON.parse(response.body)
assert_equal 1.2, json_response["rate"]
end
test "exchange_rate endpoint returns same_currency for matching currencies" do
get exchange_rate_url, params: {
from: "USD",
to: "USD"
}
assert_response :success
json_response = JSON.parse(response.body)
assert json_response["same_currency"]
assert_equal 1.0, json_response["rate"]
end
test "exchange_rate endpoint uses provided date" do
custom_date = 3.days.ago.to_date
ExchangeRate.expects(:find_or_fetch_rate)
.with(from: "EUR", to: "USD", date: custom_date)
.returns(1.25)
get exchange_rate_url, params: {
from: "EUR",
to: "USD",
date: custom_date
}
assert_response :success
json_response = JSON.parse(response.body)
assert_equal 1.25, json_response["rate"]
end
test "exchange_rate endpoint returns 400 when from currency is missing" do
get exchange_rate_url, params: {
to: "USD"
}
assert_response :bad_request
json_response = JSON.parse(response.body)
assert_equal "from and to currencies are required", json_response["error"]
end
test "exchange_rate endpoint returns 400 when to currency is missing" do
get exchange_rate_url, params: {
from: "EUR"
}
assert_response :bad_request
json_response = JSON.parse(response.body)
assert_equal "from and to currencies are required", json_response["error"]
end
test "exchange_rate endpoint returns 400 on invalid date format" do
get exchange_rate_url, params: {
from: "EUR",
to: "USD",
date: "not-a-date"
}
assert_response :bad_request
json_response = JSON.parse(response.body)
assert_equal "Invalid date format", json_response["error"]
end
test "exchange_rate endpoint returns 404 when rate not found" do
ExchangeRate.expects(:find_or_fetch_rate)
.with(from: "EUR", to: "USD", date: Date.current)
.returns(nil)
get exchange_rate_url, params: {
from: "EUR",
to: "USD"
}
assert_response :not_found
json_response = JSON.parse(response.body)
assert_equal "Exchange rate not found", json_response["error"]
end
test "creates transaction with custom exchange rate" do
account = @user.family.accounts.create!(
name: "USD Account",
currency: "USD",
balance: 1000,
accountable: Depository.new
)
assert_difference [ "Entry.count", "Transaction.count" ], 1 do
post transactions_url, params: {
entry: {
account_id: account.id,
name: "EUR transaction with custom rate",
date: Date.current,
currency: "EUR",
amount: 100,
nature: "outflow",
entryable_type: "Transaction",
entryable_attributes: {
category_id: Category.first.id,
exchange_rate: "1.5"
}
}
}
end
created_entry = Entry.order(:created_at).last
assert_equal "EUR", created_entry.currency
assert_equal 100, created_entry.amount
assert_equal 1.5, created_entry.transaction.extra["exchange_rate"]
end
test "creates transaction without custom exchange rate" do
account = @user.family.accounts.create!(
name: "USD Account",
currency: "USD",
balance: 1000,
accountable: Depository.new
)
assert_difference [ "Entry.count", "Transaction.count" ], 1 do
post transactions_url, params: {
entry: {
account_id: account.id,
name: "EUR transaction without custom rate",
date: Date.current,
currency: "EUR",
amount: 100,
nature: "outflow",
entryable_type: "Transaction",
entryable_attributes: {
category_id: Category.first.id
}
}
}
end
created_entry = Entry.order(:created_at).last
assert_nil created_entry.transaction.extra["exchange_rate"]
end
end

View File

@@ -25,6 +25,143 @@ class TransfersControllerTest < ActionDispatch::IntegrationTest
end
end
test "can create transfer with custom exchange rate" do
usd_account = accounts(:depository)
eur_account = users(:family_admin).family.accounts.create!(
name: "EUR Account",
balance: 1000,
currency: "EUR",
accountable: Depository.new
)
assert_equal "USD", usd_account.currency
assert_equal "EUR", eur_account.currency
assert_difference "Transfer.count", 1 do
post transfers_url, params: {
transfer: {
from_account_id: usd_account.id,
to_account_id: eur_account.id,
date: Date.current,
amount: 100,
exchange_rate: 0.92
}
}
end
transfer = Transfer.where(
"outflow_transaction_id IN (?) AND inflow_transaction_id IN (?)",
usd_account.transactions.pluck(:id),
eur_account.transactions.pluck(:id)
).last
assert_not_nil transfer
assert_equal "USD", transfer.outflow_transaction.entry.currency
assert_equal "EUR", transfer.inflow_transaction.entry.currency
assert_equal 100, transfer.outflow_transaction.entry.amount
assert_in_delta(-92, transfer.inflow_transaction.entry.amount, 0.01)
end
test "exchange_rate endpoint returns 400 when from currency is missing" do
get exchange_rate_url, params: {
to: "USD"
}
assert_response :bad_request
json_response = JSON.parse(response.body)
assert_equal "from and to currencies are required", json_response["error"]
end
test "exchange_rate endpoint returns 400 when to currency is missing" do
get exchange_rate_url, params: {
from: "EUR"
}
assert_response :bad_request
json_response = JSON.parse(response.body)
assert_equal "from and to currencies are required", json_response["error"]
end
test "exchange_rate endpoint returns 400 on invalid date format" do
get exchange_rate_url, params: {
from: "EUR",
to: "USD",
date: "not-a-date"
}
assert_response :bad_request
json_response = JSON.parse(response.body)
assert_equal "Invalid date format", json_response["error"]
end
test "exchange_rate endpoint returns rate for different currencies" do
ExchangeRate.expects(:find_or_fetch_rate)
.with(from: "USD", to: "EUR", date: Date.current)
.returns(OpenStruct.new(rate: 0.92))
get exchange_rate_url, params: {
from: "USD",
to: "EUR",
date: Date.current.to_s
}
assert_response :success
json_response = JSON.parse(response.body)
assert_equal 0.92, json_response["rate"]
end
test "exchange_rate endpoint returns error when exchange rate unavailable" do
ExchangeRate.expects(:find_or_fetch_rate)
.with(from: "USD", to: "EUR", date: Date.current)
.returns(nil)
get exchange_rate_url, params: {
from: "USD",
to: "EUR",
date: Date.current.to_s
}
assert_response :not_found
json_response = JSON.parse(response.body)
assert_equal "Exchange rate not found", json_response["error"]
end
test "cannot create transfer when exchange rate unavailable and no custom rate provided" do
usd_account = accounts(:depository)
eur_account = users(:family_admin).family.accounts.create!(
name: "EUR Account",
balance: 1000,
currency: "EUR",
accountable: Depository.new
)
ExchangeRate.stubs(:find_or_fetch_rate).returns(nil)
assert_no_difference "Transfer.count" do
post transfers_url, params: {
transfer: {
from_account_id: usd_account.id,
to_account_id: eur_account.id,
date: Date.current,
amount: 100
}
}
end
assert_response :unprocessable_entity
end
test "exchange_rate endpoint returns same_currency for matching currencies" do
get exchange_rate_url, params: {
from: "USD",
to: "USD"
}
assert_response :success
json_response = JSON.parse(response.body)
assert_equal true, json_response["same_currency"]
assert_equal 1.0, json_response["rate"]
end
test "soft deletes transfer" do
assert_difference -> { Transfer.count }, -1 do
delete transfer_url(transfers(:one))
@@ -61,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

View File

@@ -32,6 +32,40 @@ class UsersControllerTest < ActionDispatch::IntegrationTest
assert_equal "es", @user.reload.locale
end
test "admin can update enabled family currencies" do
patch user_url(@user), params: {
user: {
redirect_to: "preferences",
family_attributes: {
id: @user.family.id,
enabled_currencies: [ "USD", "SGD" ]
}
}
}
assert_redirected_to settings_preferences_url
assert_equal [ "USD", "SGD" ], @user.family.reload.enabled_currency_codes
end
test "non-admin cannot update enabled family currencies" do
sign_in @member = users(:family_member)
original_codes = @member.family.enabled_currency_codes
patch user_url(@member), params: {
user: {
redirect_to: "preferences",
family_attributes: {
id: @member.family.id,
enabled_currencies: [ "USD", "SGD" ]
}
}
}
assert_redirected_to settings_profile_url
assert_equal I18n.t("users.reset.unauthorized"), flash[:alert]
assert_equal original_codes, @member.family.reload.enabled_currency_codes
end
test "admin can reset family data" do
account = accounts(:investment)
category = categories(:income)

View File

@@ -50,4 +50,31 @@ class ValuationsControllerTest < ActionDispatch::IntegrationTest
assert_equal 22000, @entry.amount
assert_equal "Test notes", @entry.notes
end
test "confirm_create with blank amount returns unprocessable entity" do
account = accounts(:investment)
post confirm_create_valuations_url, params: {
entry: {
amount: "",
date: Date.current.to_s,
account_id: account.id
}
}
assert_response :unprocessable_entity
assert_match I18n.t("valuations.errors.amount_required"), response.body
end
test "confirm_update with blank amount returns unprocessable entity" do
post confirm_update_valuation_url(@entry), params: {
entry: {
amount: "",
date: Date.current.to_s
}
}
assert_response :unprocessable_entity
assert_match I18n.t("valuations.errors.amount_required"), response.body
end
end