Files
sure/test/controllers/trade_republic_items_controller_test.rb
T
Aland BabanandAland Baban 0cdab9a0bc Add first-class Trade Republic support (#3168)
* Add Trade Republic provider integration

Introduce authenticated web and QR login, resilient account synchronization, deterministic financial imports, account discovery, and provider diagnostics. Keep login state encrypted, PINs transient, and incomplete provider responses non-destructive.

* Address Trade Republic review findings

Keep QR-authenticated sessions syncable, preserve historical holding snapshots, correct dividend direction, handle unpriced positions safely, localize repair feedback, and align provider controls with the design system.

* Add Trade Republic translations for supported locales

* Restore German Trade Republic account labels

* Resolve remaining Trade Republic review findings

* Resolve remaining Trade Republic review findings

* Address latest Trade Republic review feedback

* Refactor Trade Republic panel buttons to use DS::Button component and add integration tests

* Fix 100x money inflation and missing positions locale key in TR views

Money.new takes major units, so multiplying by 100 displayed EUR 12.34
as EUR 1234 in the holdings category cards and expense summary. Also
add the pluralized holdings.index.positions key that t(".positions")
resolves to (previously only defined at the unused holdings.positions
root level), across all 18 locales.

* fix(db): repair merge artifacts in schema and migrations

- Remove duplicated icon/progress_basis columns on goals in schema.rb
- Renumber Trade Republic migrations to unique versions (clashed with
  main's 20260824120000_add_lifecycle_to_goals)
- Bump schema version to match latest migration

* Address remaining Trade Republic review feedback

* fix(trade-republic): address open PR #3168 review findings\n\n- Reject authenticated sessions without a securities account number so a\n  blank account does not mark the item connected on a broken session.\n- Derive a missing trade amount from |quantity| x price, and a missing\n  price from the resolved amount, without changing the signed import amount.\n- Regenerate db/schema.rb so the Trade Republic item/account tables and\n  indexes are present; a fresh test database was otherwise missing the\n  tables even though the migrations were marked up.\n

* fix(trade-republic): localize activity labels in ActivitiesProcessor (i18n)

* fix(trade-republic): localize activity labels in ActivitiesProcessor (i18n)

* fix(trade-republic): add activity labels i18n keys to all locale files

* Fix Trade Republic PR review follow-ups

* fix(trade-republic): i18n-aware category guard and ignore generated graphify cache

- Category matcher skipped core deposit/withdrawal labels; guard now compares
  against translated values so German etc skip correctly
- Remove committed graphify-out cache and ignore dir

* Protect holdings from malformed snapshots

* Consolidate Trade Republic migrations

* Address final Trade Republic review comments

* Address final Trade Republic review comments

- Remove hard-coded category matcher (merchant keyword taxonomy) and leave Trade Republic transactions uncategorized when no structured category exists; rely on Sure rules/AI
- Revert shared ProviderImportAdapter# import_trade extra: param; handle Trade Republic trade metadata locally in ActivitiesProcessor via post-import Trade extra merge (preserve existing extra, deep_merge)
- Preserve Trade Republic product distinctions (cash, brokerage/private_markets/interest_products/crypto_wallet via portfolio categories) without collapsing account kinds

---------

Co-authored-by: Aland Baban <snow@iBananaMac.fritz.box>
2026-09-03 00:24:49 +02:00

188 lines
7.0 KiB
Ruby

require "test_helper"
class TradeRepublicItemsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in users(:family_admin)
end
test "create rejects a web login without a PIN before persisting the item" do
assert_no_difference "TradeRepublicItem.count" do
post trade_republic_items_url, params: {
trade_republic_item: {
phone_number: "+491701234567",
pin: ""
}
}
end
assert_redirected_to settings_providers_path(anchor: "trade-republic")
assert_equal I18n.t("trade_republic_items.initiate_login.pin_required"), flash[:alert]
end
test "update rejects a changed phone number without a PIN" do
item = trade_republic_items(:configured_item)
original_phone_number = item.phone_number
original_session_blob = item.session_blob
patch trade_republic_item_url(item), params: {
trade_republic_item: {
phone_number: "+491709999999",
pin: ""
}
}
assert_redirected_to settings_providers_path(anchor: "trade-republic")
assert_equal I18n.t("trade_republic_items.update.pin_required"), flash[:alert]
item.reload
assert_equal original_phone_number, item.phone_number
assert_equal original_session_blob, item.session_blob
end
test "initiate login does not destroy a working session when the PIN is missing" do
item = trade_republic_items(:configured_item)
original_session_blob = item.session_blob
post initiate_login_trade_republic_item_url(item)
assert_redirected_to settings_providers_path(anchor: "trade-republic")
assert_equal I18n.t("trade_republic_items.initiate_login.pin_required"), flash[:alert]
item.reload
assert_equal original_session_blob, item.session_blob
assert_predicate item, :good?
end
test "initiate login clears an expired pending state when the PIN is missing" do
item = trade_republic_items(:requires_update_item)
item.update!(pending_login_state: "expired-state")
post initiate_login_trade_republic_item_url(item)
assert_redirected_to settings_providers_path(anchor: "trade-republic")
assert_equal I18n.t("trade_republic_items.initiate_login.pin_required"), flash[:alert]
item.reload
assert_nil item.pending_login_state
assert_predicate item, :requires_update?
end
test "complete account setup creates and links the selected account" do
item = trade_republic_items(:configured_item)
provider_account = trade_republic_accounts(:main_account)
assert_difference "Account.count", 1 do
assert_difference "AccountProvider.count", 1 do
post complete_account_setup_trade_republic_item_url(item), params: {
account_ids: [ provider_account.id ]
}
end
end
assert_redirected_to accounts_path
provider_account.reload
assert_not_nil provider_account.current_account
end
test "complete account setup rolls back an account when linking fails" do
item = trade_republic_items(:configured_item)
provider_account = trade_republic_accounts(:main_account)
TradeRepublicAccount.any_instance.stubs(:ensure_account_provider!).returns(nil)
assert_no_difference "Account.count" do
assert_no_difference "AccountProvider.count" do
post complete_account_setup_trade_republic_item_url(item), params: {
account_ids: [ provider_account.id ]
}
end
end
assert_redirected_to setup_accounts_trade_republic_item_path(item)
assert_equal I18n.t("trade_republic_items.complete_account_setup.partial_failure", count: 1), flash[:alert]
end
test "link_existing_account rejects an account already connected to another provider" do
item = trade_republic_items(:no_session_item)
trade_republic_account = trade_republic_accounts(:pending_setup_account)
account = accounts(:connected)
account.reload
assert_not_nil account.plaid_account_id
assert_no_difference "AccountProvider.count" do
post link_existing_account_trade_republic_items_url, params: {
account_id: account.id,
trade_republic_account_id: trade_republic_account.id
}
end
assert_redirected_to account_path(account)
assert_equal I18n.t("trade_republic_items.link_existing_account.only_manual_investment"), flash[:alert]
trade_republic_account.reload
assert_nil trade_republic_account.current_account
assert_equal item, trade_republic_account.trade_republic_item
end
test "successful QR polling can complete without a phone number" do
item = families(:dylan_family).trade_republic_items.create!(
name: "Trade Republic QR Connection",
currency: "EUR",
status: :requires_update
)
item.update!(pending_login_state: "qr-pending")
provider = mock
provider.expects(:poll_qr_login).with(pending_login_b64: "qr-pending").returns(
Provider::TradeRepublicClient::Result.new(
data: { "status" => "confirmed", "session_txt" => "qr-session" }
)
)
TradeRepublicItem.any_instance.stubs(:trade_republic_provider).returns(provider)
TradeRepublicItem.any_instance.stubs(:syncing?).returns(true)
post poll_qr_login_trade_republic_item_url(item), headers: { "ACCEPT" => "application/json" }
assert_response :success
item.reload
assert_predicate item, :good?
assert_predicate item, :session_configured?
assert_nil item.pending_login_state
assert_nil item.phone_number
end
test "QR polling exposes transient provider failures as retryable" do
item = families(:dylan_family).trade_republic_items.create!(
name: "Trade Republic QR Connection",
currency: "EUR",
status: :requires_update
)
item.update!(pending_login_state: "qr-pending")
provider = mock
provider.expects(:poll_qr_login).with(pending_login_b64: "qr-pending").raises(
Provider::TradeRepublicClient::Timeout,
"Trade Republic WebSocket timed out"
)
TradeRepublicItem.any_instance.stubs(:trade_republic_provider).returns(provider)
post poll_qr_login_trade_republic_item_url(item), headers: { "ACCEPT" => "application/json" }
assert_response :service_unavailable
assert_equal true, JSON.parse(response.body).fetch("retryable")
assert_equal "qr-pending", item.reload.pending_login_state
end
test "successful web login renders a dialog button that closes the modal" do
item = trade_republic_items(:requires_update_item)
item.update!(pending_login_state: "pending-login")
provider = mock
provider.expects(:complete_login).with(pending_login_b64: "pending-login").returns(
Provider::TradeRepublicClient::Result.new(
data: { "status" => "confirmed", "session_txt" => "session" }
)
)
TradeRepublicItem.any_instance.stubs(:trade_republic_provider).returns(provider)
TradeRepublicItem.any_instance.stubs(:syncing?).returns(true)
post poll_login_trade_republic_item_url(item), headers: { "ACCEPT" => "text/vnd.turbo-stream.html" }
assert_response :success
assert_includes response.body, 'data-action="DS--dialog#close"'
assert_includes response.body, I18n.t("settings.providers.trade_republic_panel.connection_success.close")
end
end