Merge remote-tracking branch 'origin/main' into feat/goals-v2-architecture

# Conflicts:
#	.github/workflows/preview-deploy.yml
#	app/models/account/provider_import_adapter.rb
This commit is contained in:
Guillem Arias
2026-05-30 09:28:11 +02:00
279 changed files with 16066 additions and 1093 deletions

View File

@@ -55,6 +55,47 @@ class BinanceItemsControllerTest < ActionDispatch::IntegrationTest
assert_equal "Crypto", binance_account.current_account.accountable_type
end
test "complete_account_setup updates sync_start_date when provided with a valid past date" do
binance_account = @binance_item.binance_accounts.create!(
name: "Spot Portfolio",
account_type: "spot",
currency: "USD",
current_balance: 1000.0
)
past_date = (Date.current - 7.days).to_s
post complete_account_setup_binance_item_url(@binance_item), params: {
selected_accounts: [ binance_account.id ],
sync_start_date: past_date
}
assert_response :redirect
@binance_item.reload
assert_equal Date.parse(past_date), @binance_item.sync_start_date
end
test "complete_account_setup rejects a future sync_start_date and sets flash alert" do
binance_account = @binance_item.binance_accounts.create!(
name: "Spot Portfolio",
account_type: "spot",
currency: "USD",
current_balance: 1000.0
)
future_date = (Date.current + 2.days).to_s
original_sync_date = @binance_item.sync_start_date
post complete_account_setup_binance_item_url(@binance_item), params: {
selected_accounts: [ binance_account.id ],
sync_start_date: future_date
}
@binance_item.reload
assert_nil @binance_item.sync_start_date
assert_equal "Sync start date must be a valid date in the past.", flash[:alert]
end
test "complete_account_setup with no selection shows message" do
@binance_item.binance_accounts.create!(
name: "Spot Portfolio",

View File

@@ -2,7 +2,8 @@ require "test_helper"
class CategoriesControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in users(:family_admin)
sign_in @user = users(:family_admin)
@family = @user.family
@transaction = transactions :one
ensure_tailwind_build
end
@@ -95,4 +96,208 @@ class CategoriesControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to categories_url
end
test "merge renders in the settings layout" do
get merge_categories_path
assert_response :success
assert_select "#mobile-settings-nav"
end
test "merge renders without the settings layout for modal frame requests" do
get merge_categories_path, headers: { "Turbo-Frame" => "modal" }
assert_response :success
assert_no_match(/<html/i, response.body)
assert_no_match(/<turbo-frame id="modal"><\/turbo-frame>/, response.body)
assert_select "dialog"
end
test "merge selected categories into an existing category" do
target = @family.categories.create!(
name: "Dining",
color: "#111111",
lucide_icon: "utensils"
)
source = @family.categories.create!(
name: "Coffee Shops",
color: "#000000",
lucide_icon: "coffee"
)
transaction = Transaction.create!(category: source)
Entry.create!(
account: accounts(:depository),
entryable: transaction,
name: "Coffee transaction",
date: Date.current,
amount: 10,
currency: "USD"
)
assert_difference "Category.count", -1 do
post perform_merge_categories_path, params: {
target_id: target.id,
source_ids: [ source.id ]
}
end
assert_redirected_to categories_path
assert_equal target, transaction.reload.category
assert_not Category.exists?(source.id)
end
test "merge redirects when a source category cannot be destroyed" do
target = @family.categories.create!(
name: "Destroy Failure Target",
color: "#000000",
lucide_icon: "shapes"
)
source = @family.categories.create!(
name: "Destroy Failure Source",
color: "#111111",
lucide_icon: "shapes"
)
Category::Merger.any_instance
.stubs(:merge!)
.raises(ActiveRecord::RecordNotDestroyed.new("cannot destroy category", source))
post perform_merge_categories_path, params: {
target_id: target.id,
source_ids: [ source.id ]
}
assert_redirected_to merge_categories_path
assert Category.exists?(source.id)
end
test "merge rejects selecting the target as a source" do
target = @family.categories.create!(
name: "Self Target",
color: "#000000",
lucide_icon: "shapes"
)
source = @family.categories.create!(
name: "Self Source",
color: "#111111",
lucide_icon: "shapes"
)
post perform_merge_categories_path, params: {
target_id: target.id,
source_ids: [ target.id, source.id ]
}
assert_redirected_to merge_categories_path
assert Category.exists?(target.id)
assert Category.exists?(source.id)
end
test "merge rejects parent category into any descendant" do
parent = @family.categories.create!(
name: "Parent Category",
color: "#000000",
lucide_icon: "folder"
)
child = @family.categories.create!(
name: "Child Category",
color: "#111111",
lucide_icon: "folder",
parent: parent
)
grandchild = @family.categories.create!(
name: "Grandchild Category",
color: "#222222",
lucide_icon: "folder"
)
# Category validation normally prevents this depth; the merger still guards
# against stale or imported data with deeper hierarchies.
grandchild.update_column(:parent_id, child.id)
post perform_merge_categories_path, params: {
target_id: grandchild.id,
source_ids: [ parent.id ]
}
assert_redirected_to merge_categories_path
assert Category.exists?(parent.id)
end
test "merge reparents source children to target category" do
target = @family.categories.create!(
name: "Target Category",
color: "#000000",
lucide_icon: "folder"
)
source = @family.categories.create!(
name: "Source Category",
color: "#111111",
lucide_icon: "folder"
)
child = @family.categories.create!(
name: "Source Child Category",
color: "#222222",
lucide_icon: "folder",
parent: source
)
post perform_merge_categories_path, params: {
target_id: target.id,
source_ids: [ source.id ]
}
assert_redirected_to categories_path
assert_equal target.id, child.reload.parent_id
assert_not Category.exists?(source.id)
end
test "merge rejects moving source children under a subcategory target" do
parent = @family.categories.create!(
name: "Target Parent Category",
color: "#000000",
lucide_icon: "folder"
)
target = @family.categories.create!(
name: "Target Subcategory",
color: "#111111",
lucide_icon: "folder",
parent: parent
)
source = @family.categories.create!(
name: "Source With Child",
color: "#222222",
lucide_icon: "folder"
)
child = @family.categories.create!(
name: "Source Child",
color: "#333333",
lucide_icon: "folder",
parent: source
)
post perform_merge_categories_path, params: {
target_id: target.id,
source_ids: [ source.id ]
}
assert_redirected_to merge_categories_path
assert Category.exists?(source.id)
assert_equal source.id, child.reload.parent_id
end
test "merge ignores categories outside current family" do
other = families(:empty).categories.create!(
name: "Other Family Category",
color: "#000000",
lucide_icon: "shapes"
)
post perform_merge_categories_path, params: {
target_id: categories(:income).id,
source_ids: [ other.id ]
}
assert_redirected_to merge_categories_path
assert Category.exists?(other.id)
end
end

View File

@@ -1,6 +1,8 @@
require "test_helper"
class ImportsControllerTest < ActionDispatch::IntegrationTest
include ActiveJob::TestHelper
setup do
sign_in @user = users(:family_admin)
ensure_tailwind_build
@@ -85,18 +87,242 @@ class ImportsControllerTest < ActionDispatch::IntegrationTest
@user.family.expects(:upload_document).never
assert_difference "Import.count", 1 do
assert_difference "AccountStatement.count", 1 do
post imports_url, params: {
import: {
type: "DocumentImport",
import_file: file_fixture_upload("imports/sample_bank_statement.pdf", "application/pdf")
}
}
end
end
created_import = Import.order(:created_at).last
assert_equal "PdfImport", created_import.type
assert_equal AccountStatement.order(:created_at).last, created_import.account_statement
assert_not created_import.pdf_file.attached?
assert_redirected_to import_url(created_import)
assert_equal I18n.t("imports.create.pdf_processing"), flash[:notice]
end
test "uploads pdf import through account statement" do
assert_difference "AccountStatement.count", 1 do
assert_difference "Import.where(type: 'PdfImport').count", 1 do
post imports_url, params: {
import: {
import_file: file_fixture_upload("imports/sample_bank_statement.pdf", "application/pdf")
}
}
end
end
statement = AccountStatement.order(:created_at).last
created_import = PdfImport.order(:created_at).last
assert_equal statement, created_import.account_statement
assert_not created_import.pdf_file.attached?
assert_redirected_to import_url(created_import)
assert_equal I18n.t("imports.create.pdf_processing"), flash[:notice]
end
test "guest cannot create statement backed pdf import" do
sign_in users(:intro_user)
assert_no_difference [ "AccountStatement.count", "Import.where(type: 'PdfImport').count" ] do
assert_no_enqueued_jobs only: ProcessPdfJob do
post imports_url, params: {
import: {
import_file: file_fixture_upload("imports/sample_bank_statement.pdf", "application/pdf")
}
}
end
end
assert_redirected_to new_import_url
assert_equal I18n.t("accounts.not_authorized"), flash[:alert]
end
test "duplicate pdf import reuses account statement" do
statement = AccountStatement.create_from_upload!(
family: @user.family,
account: nil,
file: uploaded_file(
filename: "existing_statement.pdf",
content_type: "application/pdf",
content: file_fixture("imports/sample_bank_statement.pdf").binread
)
)
assert_no_difference "AccountStatement.count" do
assert_difference "Import.where(type: 'PdfImport').count", 1 do
post imports_url, params: {
import: {
import_file: file_fixture_upload("imports/sample_bank_statement.pdf", "application/pdf")
}
}
end
end
created_import = PdfImport.order(:created_at).last
assert_equal statement, created_import.account_statement
assert_redirected_to import_url(created_import)
end
test "duplicate pdf import does not enqueue processing twice for reused import" do
assert_difference "AccountStatement.count", 1 do
assert_difference "Import.where(type: 'PdfImport').count", 1 do
assert_enqueued_jobs 1, only: ProcessPdfJob do
post imports_url, params: {
import: {
import_file: file_fixture_upload("imports/sample_bank_statement.pdf", "application/pdf")
}
}
post imports_url, params: {
import: {
import_file: file_fixture_upload("imports/sample_bank_statement.pdf", "application/pdf")
}
}
end
end
end
created_import = PdfImport.order(:created_at).last
assert_equal "importing", created_import.status
assert_redirected_to import_url(created_import)
end
test "duplicate pdf import for inaccessible statement does not create import" do
AccountStatement.create_from_upload!(
family: @user.family,
account: accounts(:investment),
file: uploaded_file(
filename: "existing_statement.pdf",
content_type: "application/pdf",
content: file_fixture("imports/sample_bank_statement.pdf").binread
)
)
sign_in users(:family_member)
assert_no_difference [ "AccountStatement.count", "Import.where(type: 'PdfImport').count" ] do
post imports_url, params: {
import: {
type: "DocumentImport",
import_file: file_fixture_upload("imports/sample_bank_statement.pdf", "application/pdf")
}
}
end
created_import = Import.order(:created_at).last
assert_equal "PdfImport", created_import.type
assert_redirected_to import_url(created_import)
assert_equal I18n.t("imports.create.pdf_processing"), flash[:notice]
assert_redirected_to new_import_url
assert_equal I18n.t("imports.create.duplicate_pdf_unavailable"), flash[:alert]
end
test "read only shared user cannot reuse duplicate statement backed pdf import" do
AccountStatement.create_from_upload!(
family: @user.family,
account: accounts(:credit_card),
file: uploaded_file(
filename: "existing_statement.pdf",
content_type: "application/pdf",
content: file_fixture("imports/sample_bank_statement.pdf").binread
)
)
sign_in users(:family_member)
assert_no_difference [ "AccountStatement.count", "Import.where(type: 'PdfImport').count" ] do
assert_no_enqueued_jobs only: ProcessPdfJob do
post imports_url, params: {
import: {
import_file: file_fixture_upload("imports/sample_bank_statement.pdf", "application/pdf")
}
}
end
end
assert_redirected_to new_import_url
assert_equal I18n.t("imports.create.duplicate_pdf_unavailable"), flash[:alert]
end
test "setting statement backed pdf import account links source statement" do
statement = AccountStatement.create_from_upload!(
family: @user.family,
account: nil,
file: uploaded_file(
filename: "statement.pdf",
content_type: "application/pdf",
content: file_fixture("imports/sample_bank_statement.pdf").binread
)
)
pdf_import = PdfImport.create_from_statement!(statement: statement)
account = accounts(:depository)
patch import_url(pdf_import), params: { import: { account_id: account.id } }
assert_redirected_to import_url(pdf_import)
assert_equal I18n.t("imports.update.account_saved", default: "Account saved."), flash[:notice]
assert_equal account, pdf_import.reload.account
assert_equal account, statement.reload.account
end
test "read only shared user cannot link source statement through pdf import account update" do
account = accounts(:credit_card)
statement = AccountStatement.create_from_upload!(
family: @user.family,
account: nil,
file: uploaded_file(
filename: "statement.pdf",
content_type: "application/pdf",
content: file_fixture("imports/sample_bank_statement.pdf").binread
)
)
pdf_import = PdfImport.create_from_statement!(statement: statement)
sign_in users(:family_member)
patch import_url(pdf_import), params: { import: { account_id: account.id } }
assert_redirected_to account_url(account)
assert_equal I18n.t("accounts.not_authorized"), flash[:alert]
assert_nil pdf_import.reload.account
assert_nil statement.reload.account
end
test "user cannot view statement backed pdf import for inaccessible statement" do
statement = AccountStatement.create_from_upload!(
family: @user.family,
account: accounts(:investment),
file: uploaded_file(
filename: "statement.pdf",
content_type: "application/pdf",
content: file_fixture("imports/sample_bank_statement.pdf").binread
)
)
pdf_import = PdfImport.create_from_statement!(statement: statement)
sign_in users(:family_member)
get import_url(pdf_import)
assert_response :not_found
end
test "read only shared user cannot publish statement backed pdf import" do
account = accounts(:credit_card)
statement = AccountStatement.create_from_upload!(
family: @user.family,
account: account,
file: uploaded_file(
filename: "statement.pdf",
content_type: "application/pdf",
content: file_fixture("imports/sample_bank_statement.pdf").binread
)
)
pdf_import = PdfImport.create_from_statement!(statement: statement)
PdfImport.any_instance.expects(:publish_later).never
sign_in users(:family_member)
post publish_import_url(pdf_import)
assert_redirected_to account_url(account)
assert_equal I18n.t("accounts.not_authorized"), flash[:alert]
end
test "rejects unsupported document type for DocumentImport option" do

View File

@@ -6,6 +6,58 @@ class PlaidItemsControllerTest < ActionDispatch::IntegrationTest
sign_in @user = users(:family_admin)
end
test "new redirects with friendly alert when Plaid rejects link_token request for unauthorized products" do
# Reproduces issue #1792: the Plaid client account isn't enabled for the
# requested products, so Plaid returns an actionable error message. We
# should surface that message instead of letting the modal frame render
# blank.
plaid_provider = mock
Provider::Registry.stubs(:plaid_provider_for_region).with(:us).returns(plaid_provider)
error_body = {
"error_code" => "INVALID_PRODUCT",
"error_message" => "Your account is not enabled for the following products: [\"investments\" \"liabilities\" \"transactions\"]. To request access, visit https://dashboard.plaid.com/overview/request-products or contact Sales or your Account Manager."
}.to_json
plaid_provider.expects(:get_link_token).raises(
Plaid::ApiError.new(code: 400, response_body: error_body)
)
get new_plaid_item_url(accountable_type: "Investment")
assert_redirected_to accounts_path
assert_match(/not enabled for the following products/, flash[:alert])
end
test "new redirects with generic alert when Plaid raises an unclassified error" do
plaid_provider = mock
Provider::Registry.stubs(:plaid_provider_for_region).with(:us).returns(plaid_provider)
plaid_provider.expects(:get_link_token).raises(
Plaid::ApiError.new(code: 500, response_body: { "error_code" => "INTERNAL_SERVER_ERROR" }.to_json)
)
get new_plaid_item_url
assert_redirected_to accounts_path
assert_equal I18n.t("plaid_items.errors.link_token_generic"), flash[:alert]
end
test "edit redirects with friendly alert when Plaid rejects update link_token request" do
plaid_item = plaid_items(:one)
error_body = {
"error_code" => "INVALID_PRODUCT",
"error_message" => "Your account is not enabled for the following products: [\"transactions\"]."
}.to_json
PlaidItem.any_instance.expects(:get_update_link_token).raises(
Plaid::ApiError.new(code: 400, response_body: error_body)
)
get edit_plaid_item_url(plaid_item)
assert_redirected_to accounts_path
assert_match(/not enabled for the following products/, flash[:alert])
end
test "create" do
@plaid_provider = mock
Provider::Registry.expects(:plaid_provider_for_region).with("us").returns(@plaid_provider)

View File

@@ -59,6 +59,25 @@ class Settings::ProfilesControllerTest < ActionDispatch::IntegrationTest
assert User.find(@admin.id)
end
test "admin cannot destroy a member who owns accounts in another family" do
other_family = families(:empty)
legacy_account = other_family.accounts.create!(
name: "Legacy savings", balance: 250, currency: "USD",
accountable: Depository.new
)
legacy_account.update_columns(owner_id: @member.id)
sign_in @admin
assert_no_difference("User.count") do
delete settings_profile_path(user_id: @member)
end
assert_redirected_to settings_profile_path
assert_equal I18n.t("settings.profiles.destroy.member_owns_other_family_data"), flash[:alert]
assert User.find(@member.id), "user row must be preserved so historical access can be restored"
end
test "admin removing a family member also destroys their invitation" do
# Create an invitation for the member
invitation = @admin.family.invitations.create!(