from __future__ import annotations import json from datetime import UTC, datetime from decimal import Decimal from pathlib import Path import pytest from broker_sync.models import Account, AccountType, ActivityType from broker_sync.providers.fidelity_planviewer import ( ACCOUNT_ID, FidelityCreds, FidelityPlanViewerProvider, FidelityProviderConfigError, _gains_offset_activity, ) from broker_sync.providers.parsers.fidelity import ( parse_transactions_html, parse_valuation_json, ) _FIXTURES = Path(__file__).parent.parent / "fixtures" / "fidelity" def test_accounts_exposes_single_workplace_pension_account() -> None: prov = FidelityPlanViewerProvider(FidelityCreds( storage_state_path="/tmp/x", plan_id="META", )) assert prov.accounts() == [ Account( id=ACCOUNT_ID, name="Fidelity UK Pension", account_type=AccountType.WORKPLACE_PENSION, currency="GBP", provider="fidelity-planviewer", ), ] async def test_fetch_raises_without_storage_state() -> None: prov = FidelityPlanViewerProvider(FidelityCreds( storage_state_path="/tmp/does-not-exist-xyzzy.json", plan_id="META", )) with pytest.raises(FidelityProviderConfigError, match="storage_state"): async for _ in prov.fetch(): pytest.fail("should have raised before yielding") # -- parser tests against real (captured) fixture -- def test_parse_transactions_real_fixture() -> None: html = (_FIXTURES / "transactions-full.html").read_text() txs = parse_transactions_html(html) # Scheme has ~48 months + a couple of single premiums + 1 rebate; # Bulk Switches must be filtered out (zero-amount rows). assert 40 <= len(txs) <= 100 # All dates are within the scheme's lifetime (2022-03 to today-ish). assert all(tx.date >= datetime(2022, 1, 1, tzinfo=UTC) for tx in txs) # Sum should match the header total on the page (£102,004.15 at # fixture time). Allow a £5 tolerance in case the page summary row # changes in future captures — the unit test primarily guards parsing # correctness, not drift in the fixture. total = sum((tx.amount for tx in txs), Decimal(0)) assert abs(total - Decimal("102004.15")) < Decimal("5") def test_parse_transactions_skips_bulk_switch() -> None: html = (_FIXTURES / "transactions-full.html").read_text() txs = parse_transactions_html(html) assert not any("bulk switch" in tx.tx_type.lower() for tx in txs) def test_parse_transactions_external_id_deterministic() -> None: html = (_FIXTURES / "transactions-full.html").read_text() a = parse_transactions_html(html) b = parse_transactions_html(html) assert [tx.external_id for tx in a] == [tx.external_id for tx in b] assert all(tx.external_id.startswith("fidelity:tx:") for tx in a) def test_parse_valuation_fixture() -> None: payload = json.loads((_FIXTURES / "valuation.json").read_text()) holdings = parse_valuation_json(payload) assert len(holdings) >= 1 h = holdings[0] assert h.fund_code == "KDOA" assert "Passive Global Equity" in h.fund_name assert h.currency == "GBP" assert h.units > 0 assert h.unit_price > 0 # Value ≈ units * price assert abs(h.total_value - h.units * h.unit_price) < Decimal("1") # Contribution-type breakdown must parse assert set(h.units_by_source.keys()) >= {"SASC", "ERXS"} def test_gains_offset_emits_deposit_when_pot_exceeds_contributions() -> None: html = (_FIXTURES / "transactions-full.html").read_text() valuation = json.loads((_FIXTURES / "valuation.json").read_text()) txs = parse_transactions_html(html) holdings = parse_valuation_json(valuation) as_of = datetime(2026, 4, 18, tzinfo=UTC) offset = _gains_offset_activity(holdings, txs, as_of) assert offset is not None assert offset.activity_type in (ActivityType.DEPOSIT, ActivityType.WITHDRAWAL) assert offset.amount is not None and offset.amount > 0 assert offset.external_id == "fidelity:gains:2026-04-18" def test_gains_offset_none_when_no_holdings() -> None: assert _gains_offset_activity( holdings=[], transactions=[], as_of=datetime(2026, 4, 18, tzinfo=UTC), ) is None