mirror of
https://github.com/we-promise/sure.git
synced 2026-06-01 00:39:01 +00:00
Merge branch 'main' into feature/retirement-planning
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
483
test/controllers/account_statements_controller_test.rb
Normal file
483
test/controllers/account_statements_controller_test.rb
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
197
test/controllers/api/v1/balances_controller_test.rb
Normal file
197
test/controllers/api/v1/balances_controller_test.rb
Normal 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
|
||||
@@ -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"
|
||||
|
||||
154
test/controllers/api/v1/budget_categories_controller_test.rb
Normal file
154
test/controllers/api/v1/budget_categories_controller_test.rb
Normal 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
|
||||
141
test/controllers/api/v1/budgets_controller_test.rb
Normal file
141
test/controllers/api/v1/budgets_controller_test.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
201
test/controllers/api/v1/family_exports_controller_test.rb
Normal file
201
test/controllers/api/v1/family_exports_controller_test.rb
Normal 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
|
||||
84
test/controllers/api/v1/family_settings_controller_test.rb
Normal file
84
test/controllers/api/v1/family_settings_controller_test.rb
Normal 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
@@ -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)
|
||||
|
||||
221
test/controllers/api/v1/provider_connections_controller_test.rb
Normal file
221
test/controllers/api/v1/provider_connections_controller_test.rb
Normal 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
|
||||
@@ -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
|
||||
182
test/controllers/api/v1/rejected_transfers_controller_test.rb
Normal file
182
test/controllers/api/v1/rejected_transfers_controller_test.rb
Normal 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
|
||||
207
test/controllers/api/v1/rule_runs_controller_test.rb
Normal file
207
test/controllers/api/v1/rule_runs_controller_test.rb
Normal 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
|
||||
174
test/controllers/api/v1/rules_controller_test.rb
Normal file
174
test/controllers/api/v1/rules_controller_test.rb
Normal 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
|
||||
182
test/controllers/api/v1/securities_controller_test.rb
Normal file
182
test/controllers/api/v1/securities_controller_test.rb
Normal 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
|
||||
196
test/controllers/api/v1/security_prices_controller_test.rb
Normal file
196
test/controllers/api/v1/security_prices_controller_test.rb
Normal 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
|
||||
@@ -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)
|
||||
|
||||
211
test/controllers/api/v1/syncs_controller_test.rb
Normal file
211
test/controllers/api/v1/syncs_controller_test.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
203
test/controllers/api/v1/transfers_controller_test.rb
Normal file
203
test/controllers/api/v1/transfers_controller_test.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
488
test/controllers/brex_items_controller_test.rb
Normal file
488
test/controllers/brex_items_controller_test.rb
Normal file
@@ -0,0 +1,488 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class BrexItemsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
sign_in users(:family_admin)
|
||||
SyncJob.stubs(:perform_later)
|
||||
|
||||
@family = families(:dylan_family)
|
||||
clear_brex_cache_entries
|
||||
@existing_item = brex_items(:one)
|
||||
@second_item = BrexItem.create!(
|
||||
family: @family,
|
||||
name: "Business Brex",
|
||||
token: "second_brex_token",
|
||||
base_url: "https://api.brex.com"
|
||||
)
|
||||
end
|
||||
|
||||
teardown do
|
||||
clear_brex_cache_entries
|
||||
end
|
||||
|
||||
test "create adds a new brex connection without overwriting existing credentials" do
|
||||
existing_token = @existing_item.token
|
||||
|
||||
assert_difference "BrexItem.count", 1 do
|
||||
post brex_items_url, params: {
|
||||
brex_item: {
|
||||
name: "Joint Brex",
|
||||
token: "joint_brex_token",
|
||||
base_url: "https://api.brex.com"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
assert_redirected_to accounts_path
|
||||
assert_equal existing_token, @existing_item.reload.token
|
||||
assert_equal "joint_brex_token", @family.brex_items.find_by!(name: "Joint Brex").token
|
||||
end
|
||||
|
||||
test "create uses localized default name when submitted name is blank" do
|
||||
assert_difference "BrexItem.count", 1 do
|
||||
post brex_items_url, params: {
|
||||
brex_item: {
|
||||
name: " ",
|
||||
token: "default_name_token",
|
||||
base_url: "https://api.brex.com"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
assert_redirected_to accounts_path
|
||||
assert_equal I18n.t("brex_items.default_connection_name"), @family.brex_items.order(:created_at).last.name
|
||||
end
|
||||
|
||||
test "update changes only the selected brex connection" do
|
||||
existing_token = @existing_item.token
|
||||
|
||||
patch brex_item_url(@second_item), params: {
|
||||
brex_item: {
|
||||
name: "Renamed Business Brex",
|
||||
token: "updated_second_token",
|
||||
base_url: "https://api-staging.brex.com"
|
||||
}
|
||||
}
|
||||
|
||||
assert_redirected_to accounts_path
|
||||
assert_equal existing_token, @existing_item.reload.token
|
||||
assert_equal "Renamed Business Brex", @second_item.reload.name
|
||||
assert_equal "updated_second_token", @second_item.token
|
||||
assert_equal "https://api-staging.brex.com", @second_item.base_url
|
||||
end
|
||||
|
||||
test "update rejects arbitrary brex base url" do
|
||||
patch brex_item_url(@second_item), params: {
|
||||
brex_item: {
|
||||
name: "Renamed Business Brex",
|
||||
token: "updated_second_token",
|
||||
base_url: "https://evil.example.test"
|
||||
}
|
||||
}
|
||||
|
||||
assert_redirected_to settings_providers_path
|
||||
assert_includes flash[:alert], "https://api.brex.com"
|
||||
assert_equal "https://api.brex.com", @second_item.reload.base_url
|
||||
assert_equal "second_brex_token", @second_item.token
|
||||
end
|
||||
|
||||
test "blank token update preserves the selected brex token" do
|
||||
original_token = @second_item.token
|
||||
|
||||
patch brex_item_url(@second_item), params: {
|
||||
brex_item: {
|
||||
name: "Renamed Business Brex",
|
||||
token: "",
|
||||
base_url: "https://api.brex.com"
|
||||
}
|
||||
}
|
||||
|
||||
assert_redirected_to accounts_path
|
||||
assert_equal "Renamed Business Brex", @second_item.reload.name
|
||||
assert_equal original_token, @second_item.token
|
||||
end
|
||||
|
||||
test "update expires selected brex account cache when credentials change" do
|
||||
Rails.cache.expects(:delete).with(brex_cache_key(@existing_item)).never
|
||||
Rails.cache.expects(:delete).with(brex_cache_key(@second_item)).once
|
||||
|
||||
patch brex_item_url(@second_item), params: {
|
||||
brex_item: {
|
||||
name: "Renamed Business Brex",
|
||||
token: "updated_second_token",
|
||||
base_url: "https://api-staging.brex.com"
|
||||
}
|
||||
}
|
||||
|
||||
assert_redirected_to accounts_path
|
||||
end
|
||||
|
||||
test "update does not expire selected brex account cache for name-only changes" do
|
||||
Rails.cache.expects(:delete).never
|
||||
|
||||
patch brex_item_url(@second_item), params: {
|
||||
brex_item: {
|
||||
name: "Renamed Business Brex"
|
||||
}
|
||||
}
|
||||
|
||||
assert_redirected_to accounts_path
|
||||
assert_equal "Renamed Business Brex", @second_item.reload.name
|
||||
end
|
||||
|
||||
test "preload accounts uses selected brex item cache key" do
|
||||
Rails.cache.expects(:read).with(brex_cache_key(@second_item)).returns(nil)
|
||||
Rails.cache.expects(:write).with(brex_cache_key(@second_item), brex_accounts_payload, expires_in: 5.minutes)
|
||||
|
||||
provider = mock("brex_provider")
|
||||
provider.expects(:get_accounts).returns(accounts: brex_accounts_payload)
|
||||
Provider::Brex.expects(:new)
|
||||
.with(@second_item.token, base_url: @second_item.effective_base_url)
|
||||
.returns(provider)
|
||||
|
||||
get preload_accounts_brex_items_url, params: { brex_item_id: @second_item.id }, as: :json
|
||||
|
||||
assert_response :success
|
||||
response = JSON.parse(@response.body)
|
||||
assert_equal true, response["success"]
|
||||
assert_equal true, response["has_accounts"]
|
||||
end
|
||||
|
||||
test "select accounts requires an explicit connection when multiple brex items exist" do
|
||||
get select_accounts_brex_items_url, params: { accountable_type: "Depository" }
|
||||
|
||||
assert_redirected_to settings_providers_path
|
||||
assert_equal I18n.t("brex_items.select_accounts.select_connection"), flash[:alert]
|
||||
end
|
||||
|
||||
test "select accounts renders the selected brex item id" do
|
||||
Rails.cache.expects(:read).with(brex_cache_key(@second_item)).returns(nil)
|
||||
Rails.cache.expects(:write).with(brex_cache_key(@second_item), brex_accounts_payload, expires_in: 5.minutes)
|
||||
|
||||
provider = mock("brex_provider")
|
||||
provider.expects(:get_accounts).returns(accounts: brex_accounts_payload)
|
||||
Provider::Brex.expects(:new)
|
||||
.with(@second_item.token, base_url: @second_item.effective_base_url)
|
||||
.returns(provider)
|
||||
|
||||
get select_accounts_brex_items_url, params: {
|
||||
brex_item_id: @second_item.id,
|
||||
accountable_type: "Depository"
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
assert_includes @response.body, %(name="brex_item_id")
|
||||
assert_includes @response.body, %(value="#{@second_item.id}")
|
||||
end
|
||||
|
||||
test "select accounts rejects protocol relative return paths" do
|
||||
Rails.cache.expects(:read).with(brex_cache_key(@second_item)).returns(brex_accounts_payload)
|
||||
|
||||
get select_accounts_brex_items_url, params: {
|
||||
brex_item_id: @second_item.id,
|
||||
accountable_type: "Depository",
|
||||
return_to: "//evil.example/accounts"
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
refute_includes @response.body, "//evil.example/accounts"
|
||||
end
|
||||
|
||||
test "select accounts rejects backslash and unsafe local return paths" do
|
||||
[
|
||||
"/\\evil.example/accounts",
|
||||
"/%2fevil.example/accounts",
|
||||
"/%2Fevil.example/accounts",
|
||||
"/%5cevil.example/accounts",
|
||||
"/%5Cevil.example/accounts",
|
||||
"/\naccounts",
|
||||
"/ accounts",
|
||||
"/"
|
||||
].each do |return_to|
|
||||
Rails.cache.expects(:read).with(brex_cache_key(@second_item)).returns(brex_accounts_payload)
|
||||
|
||||
get select_accounts_brex_items_url, params: {
|
||||
brex_item_id: @second_item.id,
|
||||
accountable_type: "Depository",
|
||||
return_to: return_to
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
assert_select %(input[name="return_to"]) do |fields|
|
||||
assert fields.first["value"].blank?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "select existing account rejects unsafe return paths" do
|
||||
account = @family.accounts.create!(
|
||||
name: "Manual Checking",
|
||||
balance: 0,
|
||||
currency: "USD",
|
||||
accountable: Depository.new
|
||||
)
|
||||
|
||||
[
|
||||
"//evil.example/accounts",
|
||||
"\\evil.example/accounts",
|
||||
"/\\evil.example/accounts",
|
||||
"/%2fevil.example/accounts",
|
||||
"/%2Fevil.example/accounts",
|
||||
"/%5cevil.example/accounts",
|
||||
"/%5Cevil.example/accounts",
|
||||
"/\naccounts",
|
||||
"/ accounts",
|
||||
" ",
|
||||
"/"
|
||||
].each do |return_to|
|
||||
Rails.cache.expects(:read).with(brex_cache_key(@second_item)).returns(brex_accounts_payload)
|
||||
|
||||
get select_existing_account_brex_items_url, params: {
|
||||
brex_item_id: @second_item.id,
|
||||
account_id: account.id,
|
||||
return_to: return_to
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
assert_select %(input[name="return_to"]) do |fields|
|
||||
assert fields.first["value"].blank?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "select existing account preserves safe local return path" do
|
||||
account = @family.accounts.create!(
|
||||
name: "Manual Checking",
|
||||
balance: 0,
|
||||
currency: "USD",
|
||||
accountable: Depository.new
|
||||
)
|
||||
return_to = "/accounts?tab=manual"
|
||||
|
||||
Rails.cache.expects(:read).with(brex_cache_key(@second_item)).returns(brex_accounts_payload)
|
||||
|
||||
get select_existing_account_brex_items_url, params: {
|
||||
brex_item_id: @second_item.id,
|
||||
account_id: account.id,
|
||||
return_to: return_to
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
assert_select %(input[name="return_to"][value="#{return_to}"])
|
||||
end
|
||||
|
||||
test "select existing account redirects when account id is invalid" do
|
||||
get select_existing_account_brex_items_url, params: {
|
||||
brex_item_id: @second_item.id,
|
||||
account_id: SecureRandom.uuid
|
||||
}
|
||||
|
||||
assert_redirected_to accounts_path
|
||||
assert_equal I18n.t("brex_items.select_existing_account.no_account_specified"), flash[:alert]
|
||||
end
|
||||
|
||||
test "select existing account renders the selected brex item id" do
|
||||
account = @family.accounts.create!(
|
||||
name: "Manual Checking",
|
||||
balance: 0,
|
||||
currency: "USD",
|
||||
accountable: Depository.new
|
||||
)
|
||||
|
||||
Rails.cache.expects(:read).with(brex_cache_key(@second_item)).returns(nil)
|
||||
Rails.cache.expects(:write).with(brex_cache_key(@second_item), brex_accounts_payload, expires_in: 5.minutes)
|
||||
|
||||
provider = mock("brex_provider")
|
||||
provider.expects(:get_accounts).returns(accounts: brex_accounts_payload)
|
||||
Provider::Brex.expects(:new)
|
||||
.with(@second_item.token, base_url: @second_item.effective_base_url)
|
||||
.returns(provider)
|
||||
|
||||
get select_existing_account_brex_items_url, params: {
|
||||
brex_item_id: @second_item.id,
|
||||
account_id: account.id
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
assert_includes @response.body, %(name="brex_item_id")
|
||||
assert_includes @response.body, %(value="#{@second_item.id}")
|
||||
end
|
||||
|
||||
test "link accounts uses selected brex item and allows duplicate upstream ids across items" do
|
||||
@existing_item.brex_accounts.create!(
|
||||
account_id: "shared_brex_account",
|
||||
name: "Shared Checking",
|
||||
currency: "USD",
|
||||
current_balance: 1000
|
||||
)
|
||||
|
||||
provider = mock("brex_provider")
|
||||
provider.expects(:get_accounts).returns(accounts: brex_accounts_payload)
|
||||
Provider::Brex.expects(:new)
|
||||
.with(@second_item.token, base_url: @second_item.effective_base_url)
|
||||
.returns(provider)
|
||||
|
||||
assert_difference -> { @second_item.brex_accounts.where(account_id: "shared_brex_account").count }, 1 do
|
||||
assert_difference "AccountProvider.count", 1 do
|
||||
post link_accounts_brex_items_url, params: {
|
||||
brex_item_id: @second_item.id,
|
||||
account_ids: [ "shared_brex_account" ],
|
||||
accountable_type: "Depository"
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
assert_redirected_to accounts_path
|
||||
assert_equal 1, @existing_item.brex_accounts.where(account_id: "shared_brex_account").count
|
||||
end
|
||||
|
||||
test "link accounts does not silently use the first connection when multiple items exist" do
|
||||
assert_no_difference "BrexAccount.count" do
|
||||
assert_no_difference "Account.count" do
|
||||
post link_accounts_brex_items_url, params: {
|
||||
account_ids: [ "shared_brex_account" ],
|
||||
accountable_type: "Depository"
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
assert_redirected_to settings_providers_path
|
||||
assert_equal I18n.t("brex_items.link_accounts.select_connection"), flash[:alert]
|
||||
end
|
||||
|
||||
test "link existing account does not silently use the first connection when multiple items exist" do
|
||||
account = @family.accounts.create!(
|
||||
name: "Manual Checking",
|
||||
balance: 0,
|
||||
currency: "USD",
|
||||
accountable: Depository.new
|
||||
)
|
||||
|
||||
assert_no_difference "BrexAccount.count" do
|
||||
assert_no_difference "AccountProvider.count" do
|
||||
post link_existing_account_brex_items_url, params: {
|
||||
account_id: account.id,
|
||||
brex_account_id: "shared_brex_account"
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
assert_redirected_to settings_providers_path
|
||||
assert_equal I18n.t("brex_items.link_existing_account.select_connection"), flash[:alert]
|
||||
end
|
||||
|
||||
test "link existing account requires account id" do
|
||||
assert_no_difference "AccountProvider.count" do
|
||||
post link_existing_account_brex_items_url, params: {
|
||||
brex_item_id: @second_item.id,
|
||||
brex_account_id: "shared_brex_account"
|
||||
}
|
||||
end
|
||||
|
||||
assert_redirected_to accounts_path
|
||||
assert_equal I18n.t("brex_items.link_existing_account.no_account_specified"), flash[:alert]
|
||||
end
|
||||
|
||||
test "link existing account redirects when account id is invalid" do
|
||||
assert_no_difference "AccountProvider.count" do
|
||||
post link_existing_account_brex_items_url, params: {
|
||||
brex_item_id: @second_item.id,
|
||||
account_id: SecureRandom.uuid,
|
||||
brex_account_id: "shared_brex_account"
|
||||
}
|
||||
end
|
||||
|
||||
assert_redirected_to accounts_path
|
||||
assert_equal I18n.t("brex_items.link_existing_account.no_account_specified"), flash[:alert]
|
||||
end
|
||||
|
||||
test "sync only queues a sync for the selected brex item" do
|
||||
assert_difference -> { Sync.where(syncable: @second_item).count }, 1 do
|
||||
assert_no_difference -> { Sync.where(syncable: @existing_item).count } do
|
||||
post sync_brex_item_url(@second_item)
|
||||
end
|
||||
end
|
||||
|
||||
assert_response :redirect
|
||||
end
|
||||
|
||||
test "complete account setup ignores unsupported account type and subtype params" do
|
||||
valid_brex_account = @second_item.brex_accounts.create!(
|
||||
account_id: "setup_valid",
|
||||
account_kind: "cash",
|
||||
name: "Setup Valid",
|
||||
currency: "USD",
|
||||
current_balance: 100
|
||||
)
|
||||
unsupported_brex_account = @second_item.brex_accounts.create!(
|
||||
account_id: "setup_unsupported",
|
||||
account_kind: "cash",
|
||||
name: "Setup Unsupported",
|
||||
currency: "USD",
|
||||
current_balance: 100
|
||||
)
|
||||
|
||||
assert_difference "AccountProvider.count", 1 do
|
||||
post complete_account_setup_brex_item_url(@second_item), params: {
|
||||
account_types: {
|
||||
valid_brex_account.id => "Depository",
|
||||
unsupported_brex_account.id => "Investment",
|
||||
"not-a-brex-account" => "Depository"
|
||||
},
|
||||
account_subtypes: {
|
||||
valid_brex_account.id => "savings",
|
||||
unsupported_brex_account.id => "brokerage",
|
||||
"not-a-brex-account" => "checking"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
assert_redirected_to accounts_path
|
||||
assert_equal "savings", valid_brex_account.reload.account.accountable.subtype
|
||||
assert_nil unsupported_brex_account.reload.account_provider
|
||||
assert_match(/skipped/i, flash[:notice])
|
||||
end
|
||||
|
||||
test "complete account setup treats scalar setup params as empty" do
|
||||
assert_no_difference "AccountProvider.count" do
|
||||
post complete_account_setup_brex_item_url(@second_item), params: {
|
||||
account_types: "not-a-hash",
|
||||
account_subtypes: "also-not-a-hash"
|
||||
}
|
||||
end
|
||||
|
||||
assert_redirected_to accounts_path
|
||||
assert_equal I18n.t("brex_items.complete_account_setup.no_accounts"), flash[:alert]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def brex_accounts_payload
|
||||
[
|
||||
{
|
||||
id: "shared_brex_account",
|
||||
name: "Shared Checking",
|
||||
account_kind: "cash",
|
||||
status: "active",
|
||||
current_balance: { amount: 100_000, currency: "USD" },
|
||||
available_balance: { amount: 95_000, currency: "USD" }
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
def brex_cache_key(brex_item)
|
||||
BrexItem::AccountFlow.cache_key(@family, brex_item)
|
||||
end
|
||||
|
||||
def clear_brex_cache_entries
|
||||
return unless defined?(@family) && @family.present?
|
||||
return unless Rails.cache.respond_to?(:delete_matched)
|
||||
|
||||
Rails.cache.delete_matched("brex_accounts_#{@family.id}_*")
|
||||
rescue NotImplementedError
|
||||
# Some test cache stores do not implement delete_matched; tests that depend
|
||||
# on cache state stub exact Brex cache keys instead of relying on globals.
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
173
test/controllers/exchange_rates_controller_test.rb
Normal file
173
test/controllers/exchange_rates_controller_test.rb
Normal 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
|
||||
100
test/controllers/ibkr_items_controller_test.rb
Normal file
100
test/controllers/ibkr_items_controller_test.rb
Normal file
@@ -0,0 +1,100 @@
|
||||
require "test_helper"
|
||||
|
||||
class IbkrItemsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
sign_in @user = users(:family_admin)
|
||||
@ibkr_item = ibkr_items(:configured_item)
|
||||
end
|
||||
|
||||
test "select_existing_account renders available ibkr accounts" do
|
||||
get select_existing_account_ibkr_items_url, params: { account_id: accounts(:investment).id }
|
||||
|
||||
assert_response :success
|
||||
assert_includes response.body, ibkr_accounts(:main_account).name
|
||||
end
|
||||
|
||||
test "create redirects to accounts on success" do
|
||||
assert_difference "IbkrItem.count", 1 do
|
||||
post ibkr_items_url, params: {
|
||||
ibkr_item: {
|
||||
query_id: "QUERYNEW",
|
||||
token: "TOKENNEW"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
assert_redirected_to accounts_path
|
||||
end
|
||||
|
||||
test "update redirects to accounts on success" do
|
||||
patch ibkr_item_url(@ibkr_item), params: {
|
||||
ibkr_item: {
|
||||
query_id: "",
|
||||
token: ""
|
||||
}
|
||||
}
|
||||
|
||||
assert_redirected_to accounts_path
|
||||
end
|
||||
|
||||
test "complete_account_setup creates investment account and provider link" do
|
||||
assert_difference "Account.count", 1 do
|
||||
assert_difference "AccountProvider.count", 1 do
|
||||
post complete_account_setup_ibkr_item_url(@ibkr_item), params: {
|
||||
account_ids: [ ibkr_accounts(:main_account).id ]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
created_account = Account.order(created_at: :desc).first
|
||||
assert_equal "Investment", created_account.accountable_type
|
||||
assert_equal "brokerage", created_account.accountable.subtype
|
||||
assert_redirected_to accounts_path
|
||||
|
||||
ibkr_accounts(:main_account).reload
|
||||
assert_equal created_account, ibkr_accounts(:main_account).current_account
|
||||
end
|
||||
|
||||
test "link_existing_account links manual investment account" do
|
||||
account = accounts(:investment)
|
||||
|
||||
assert_difference "AccountProvider.count", 1 do
|
||||
post link_existing_account_ibkr_items_url, params: {
|
||||
account_id: account.id,
|
||||
ibkr_account_id: ibkr_accounts(:main_account).id
|
||||
}
|
||||
end
|
||||
|
||||
assert_redirected_to account_path(account)
|
||||
ibkr_accounts(:main_account).reload
|
||||
assert_equal account, ibkr_accounts(:main_account).current_account
|
||||
end
|
||||
|
||||
test "link_existing_account rejects already linked ibkr account" do
|
||||
original_account = accounts(:investment)
|
||||
ibkr_account = ibkr_accounts(:main_account)
|
||||
AccountProvider.create!(account: original_account, provider: ibkr_account)
|
||||
|
||||
replacement_account = Account.create!(
|
||||
family: @ibkr_item.family,
|
||||
owner: @user,
|
||||
name: "Replacement Brokerage Account",
|
||||
balance: 2500,
|
||||
cash_balance: 2500,
|
||||
currency: "USD",
|
||||
accountable: Investment.create!(subtype: "brokerage")
|
||||
)
|
||||
|
||||
assert_no_difference "AccountProvider.count" do
|
||||
post link_existing_account_ibkr_items_url, params: {
|
||||
account_id: replacement_account.id,
|
||||
ibkr_account_id: ibkr_account.id
|
||||
}
|
||||
end
|
||||
|
||||
assert_redirected_to account_path(replacement_account)
|
||||
assert_equal "This Interactive Brokers account is already linked.", flash[:alert]
|
||||
ibkr_account.reload
|
||||
assert_equal original_account, ibkr_account.current_account
|
||||
end
|
||||
end
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
278
test/controllers/kraken_items_controller_test.rb
Normal file
278
test/controllers/kraken_items_controller_test.rb
Normal file
@@ -0,0 +1,278 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class KrakenItemsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
sign_in users(:family_admin)
|
||||
SyncJob.stubs(:perform_later)
|
||||
|
||||
@family = families(:dylan_family)
|
||||
@existing_item = kraken_items(:one)
|
||||
kraken_items(:requires_update).update!(scheduled_for_deletion: true)
|
||||
@second_item = KrakenItem.create!(
|
||||
family: @family,
|
||||
name: "Business Kraken",
|
||||
api_key: "second_kraken_key",
|
||||
api_secret: "second_kraken_secret"
|
||||
)
|
||||
end
|
||||
|
||||
test "create adds a new kraken connection without overwriting existing credentials" do
|
||||
existing_key = @existing_item.api_key
|
||||
existing_secret = @existing_item.api_secret
|
||||
|
||||
assert_difference "KrakenItem.count", 1 do
|
||||
post kraken_items_url, params: {
|
||||
kraken_item: {
|
||||
name: "Joint Kraken",
|
||||
api_key: "joint_kraken_key",
|
||||
api_secret: "joint_kraken_secret"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
assert_redirected_to settings_providers_path
|
||||
assert_equal existing_key, @existing_item.reload.api_key
|
||||
assert_equal existing_secret, @existing_item.api_secret
|
||||
assert_equal "joint_kraken_key", @family.kraken_items.find_by!(name: "Joint Kraken").api_key
|
||||
end
|
||||
|
||||
test "update changes only the selected kraken connection" do
|
||||
existing_key = @existing_item.api_key
|
||||
|
||||
patch kraken_item_url(@second_item), params: {
|
||||
kraken_item: {
|
||||
name: "Renamed Business Kraken",
|
||||
api_key: "updated_second_key",
|
||||
api_secret: "updated_second_secret"
|
||||
}
|
||||
}
|
||||
|
||||
assert_redirected_to settings_providers_path
|
||||
assert_equal existing_key, @existing_item.reload.api_key
|
||||
assert_equal "Renamed Business Kraken", @second_item.reload.name
|
||||
assert_equal "updated_second_key", @second_item.api_key
|
||||
assert_equal "updated_second_secret", @second_item.api_secret
|
||||
end
|
||||
|
||||
test "blank secret update preserves the selected kraken credentials" do
|
||||
original_key = @second_item.api_key
|
||||
original_secret = @second_item.api_secret
|
||||
|
||||
patch kraken_item_url(@second_item), params: {
|
||||
kraken_item: {
|
||||
name: "Renamed Business Kraken",
|
||||
api_key: "",
|
||||
api_secret: ""
|
||||
}
|
||||
}
|
||||
|
||||
assert_redirected_to settings_providers_path
|
||||
assert_equal "Renamed Business Kraken", @second_item.reload.name
|
||||
assert_equal original_key, @second_item.api_key
|
||||
assert_equal original_secret, @second_item.api_secret
|
||||
end
|
||||
|
||||
test "create rejects whitespace-only credentials" do
|
||||
assert_no_difference "KrakenItem.count" do
|
||||
post kraken_items_url, params: {
|
||||
kraken_item: {
|
||||
name: "Blank Kraken",
|
||||
api_key: " ",
|
||||
api_secret: "\n"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
assert_redirected_to settings_providers_path
|
||||
assert_match(/API key can't be blank/i, flash[:alert])
|
||||
end
|
||||
|
||||
test "select accounts requires an explicit connection when multiple kraken items exist" do
|
||||
get select_accounts_kraken_items_url, params: { accountable_type: "Crypto" }
|
||||
|
||||
assert_redirected_to settings_providers_path
|
||||
assert_equal "Choose a Kraken connection in Provider Settings.", flash[:alert]
|
||||
end
|
||||
|
||||
test "select accounts targets selected kraken item" do
|
||||
get select_accounts_kraken_items_url, params: {
|
||||
kraken_item_id: @second_item.id,
|
||||
accountable_type: "Crypto"
|
||||
}
|
||||
|
||||
assert_redirected_to setup_accounts_kraken_item_path(@second_item, return_to: nil)
|
||||
end
|
||||
|
||||
test "select accounts rejects protocol-relative return paths" do
|
||||
get select_accounts_kraken_items_url, params: {
|
||||
kraken_item_id: @second_item.id,
|
||||
accountable_type: "Crypto",
|
||||
return_to: "//evil.example/accounts"
|
||||
}
|
||||
|
||||
assert_redirected_to setup_accounts_kraken_item_path(@second_item, return_to: nil)
|
||||
end
|
||||
|
||||
test "sync only queues a sync for the selected kraken item" do
|
||||
assert_difference -> { Sync.where(syncable: @second_item).count }, 1 do
|
||||
assert_no_difference -> { Sync.where(syncable: @existing_item).count } do
|
||||
post sync_kraken_item_url(@second_item)
|
||||
end
|
||||
end
|
||||
|
||||
assert_response :redirect
|
||||
end
|
||||
|
||||
test "setup accounts creates crypto exchange account for selected item only" do
|
||||
first_account = kraken_accounts(:one)
|
||||
second_account = @second_item.kraken_accounts.create!(
|
||||
name: "Second Kraken",
|
||||
account_id: "combined",
|
||||
account_type: "combined",
|
||||
currency: "USD",
|
||||
current_balance: 1000
|
||||
)
|
||||
KrakenAccount::Processor.any_instance.stubs(:process).returns(nil)
|
||||
|
||||
assert_difference "Account.count", 1 do
|
||||
post complete_account_setup_kraken_item_url(@second_item), params: {
|
||||
selected_accounts: [ second_account.id ]
|
||||
}
|
||||
end
|
||||
|
||||
assert_redirected_to accounts_path
|
||||
assert_nil first_account.reload.current_account
|
||||
assert_equal "Crypto", second_account.reload.current_account.accountable_type
|
||||
assert_equal "exchange", second_account.current_account.accountable.subtype
|
||||
end
|
||||
|
||||
test "link existing account links manual crypto exchange account to selected kraken account" do
|
||||
manual_account = manual_crypto_exchange_account
|
||||
kraken_account = @second_item.kraken_accounts.create!(
|
||||
name: "Kraken",
|
||||
account_id: "combined",
|
||||
account_type: "combined",
|
||||
currency: "USD",
|
||||
current_balance: 1000
|
||||
)
|
||||
|
||||
assert_difference "AccountProvider.count", 1 do
|
||||
post link_existing_account_kraken_items_url, params: {
|
||||
kraken_item_id: @second_item.id,
|
||||
account_id: manual_account.id,
|
||||
kraken_account_id: kraken_account.id
|
||||
}
|
||||
end
|
||||
|
||||
assert_redirected_to accounts_path
|
||||
assert_equal manual_account, kraken_account.reload.current_account
|
||||
end
|
||||
|
||||
test "link existing account requires explicit connection when multiple items exist" do
|
||||
account = manual_crypto_exchange_account
|
||||
|
||||
assert_no_difference "AccountProvider.count" do
|
||||
post link_existing_account_kraken_items_url, params: {
|
||||
account_id: account.id,
|
||||
kraken_account_id: "combined"
|
||||
}
|
||||
end
|
||||
|
||||
assert_redirected_to settings_providers_path
|
||||
assert_equal "Choose a Kraken connection before linking accounts.", flash[:alert]
|
||||
end
|
||||
|
||||
test "link existing account rejects non crypto accounts" do
|
||||
account = @family.accounts.create!(
|
||||
name: "Manual Checking",
|
||||
balance: 0,
|
||||
currency: "USD",
|
||||
accountable: Depository.new
|
||||
)
|
||||
kraken_account = @second_item.kraken_accounts.create!(name: "Kraken", account_id: "combined", account_type: "combined", currency: "USD")
|
||||
|
||||
assert_no_difference "AccountProvider.count" do
|
||||
post link_existing_account_kraken_items_url, params: {
|
||||
kraken_item_id: @second_item.id,
|
||||
account_id: account.id,
|
||||
kraken_account_id: kraken_account.id
|
||||
}
|
||||
end
|
||||
|
||||
assert_redirected_to account_path(account)
|
||||
end
|
||||
|
||||
test "link existing account rejects accounts with existing provider links" do
|
||||
account = manual_crypto_exchange_account
|
||||
linked_kraken_account = kraken_accounts(:one)
|
||||
AccountProvider.create!(account: account, provider: linked_kraken_account)
|
||||
kraken_account = @second_item.kraken_accounts.create!(name: "Kraken", account_id: "combined", account_type: "combined", currency: "USD")
|
||||
|
||||
assert_no_difference "AccountProvider.count" do
|
||||
post link_existing_account_kraken_items_url, params: {
|
||||
kraken_item_id: @second_item.id,
|
||||
account_id: account.id,
|
||||
kraken_account_id: kraken_account.id
|
||||
}
|
||||
end
|
||||
|
||||
assert_redirected_to account_path(account)
|
||||
end
|
||||
|
||||
test "link existing account rejects kraken accounts already linked elsewhere" do
|
||||
linked_account = manual_crypto_exchange_account
|
||||
available_account = manual_crypto_exchange_account
|
||||
kraken_account = @second_item.kraken_accounts.create!(name: "Kraken", account_id: "combined", account_type: "combined", currency: "USD")
|
||||
AccountProvider.create!(account: linked_account, provider: kraken_account)
|
||||
|
||||
assert_no_difference "AccountProvider.count" do
|
||||
post link_existing_account_kraken_items_url, params: {
|
||||
kraken_item_id: @second_item.id,
|
||||
account_id: available_account.id,
|
||||
kraken_account_id: kraken_account.id
|
||||
}
|
||||
end
|
||||
|
||||
assert_redirected_to account_path(available_account)
|
||||
end
|
||||
|
||||
test "select existing account renders selected kraken item id" do
|
||||
account = manual_crypto_exchange_account
|
||||
@second_item.kraken_accounts.create!(name: "Kraken", account_id: "combined", account_type: "combined", currency: "USD")
|
||||
|
||||
get select_existing_account_kraken_items_url, params: {
|
||||
kraken_item_id: @second_item.id,
|
||||
account_id: account.id
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
assert_includes @response.body, %(name="kraken_item_id")
|
||||
assert_includes @response.body, %(value="#{@second_item.id}")
|
||||
end
|
||||
|
||||
test "cannot access another family's kraken item" do
|
||||
other_item = KrakenItem.create!(
|
||||
family: families(:empty),
|
||||
name: "Other Kraken",
|
||||
api_key: "other_key",
|
||||
api_secret: "other_secret"
|
||||
)
|
||||
|
||||
get setup_accounts_kraken_item_url(other_item)
|
||||
|
||||
assert_response :not_found
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def manual_crypto_exchange_account
|
||||
@family.accounts.create!(
|
||||
name: "Manual Crypto",
|
||||
balance: 0,
|
||||
currency: "USD",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
268
test/controllers/mercury_items_controller_test.rb
Normal file
268
test/controllers/mercury_items_controller_test.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
11
test/controllers/pwa_controller_test.rb
Normal file
11
test/controllers/pwa_controller_test.rb
Normal 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
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
68
test/controllers/settings/debugs_controller_test.rb
Normal file
68
test/controllers/settings/debugs_controller_test.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
950
test/controllers/sophtron_items_controller_test.rb
Normal file
950
test/controllers/sophtron_items_controller_test.rb
Normal 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
|
||||
@@ -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!([
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user