from __future__ import annotations import json from datetime import UTC, date, datetime from decimal import Decimal from pathlib import Path import pytest from broker_sync.models import Account, AccountType from broker_sync.providers.fidelity_planviewer import ( ACCOUNT_ID, FidelityCreds, FidelityPlanViewerProvider, FidelityProviderConfigError, fidelity_holdings_to_snapshot, ) 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_holdings_to_snapshot_real_fixture() -> None: html = (_FIXTURES / "transactions-full.html").read_text() valuation = json.loads((_FIXTURES / "valuation.json").read_text()) holdings = parse_valuation_json(valuation) total_contrib = sum((tx.amount for tx in parse_transactions_html(html)), Decimal(0)) snapshot = fidelity_holdings_to_snapshot( holdings=holdings, total_real_contribution=total_contrib, as_of=date(2026, 4, 18), ) assert snapshot is not None assert snapshot.date == date(2026, 4, 18) assert snapshot.currency == "GBP" # Cost basis sums to the cash contributions (allocated by fund value share) sum_cost = sum((p.total_cost_basis for p in snapshot.positions), Decimal(0)) assert abs(sum_cost - total_contrib) < Decimal("1") # Meta scheme had KDOA + LAFC + one other at fixture time; the # dominant fund must be KDOA. symbols = [p.symbol for p in snapshot.positions] assert "KDOA" in symbols kdoa = next(p for p in snapshot.positions if p.symbol == "KDOA") assert kdoa.quantity > 0 # Proportional cost-basis allocation: KDOA holds nearly the whole pot # so it should get the lion's share of cost kdoa_share = kdoa.total_cost_basis / sum_cost assert kdoa_share > Decimal("0.9") # cashBalances zero — pension contributions flow straight into funds assert snapshot.cash_balances == {"GBP": Decimal(0)} def test_holdings_to_snapshot_none_when_no_holdings() -> None: assert fidelity_holdings_to_snapshot( holdings=[], total_real_contribution=Decimal("100"), as_of=date(2026, 4, 18), ) is None def test_provider_caches_holdings_for_cli_snapshot_push() -> None: """The CLI reads `last_holdings` after fetch() drains to push the manual snapshot. This guards the contract that fetch() populates the attribute even when no Activity is yielded (e.g., backfill window cut-off).""" prov = FidelityPlanViewerProvider(FidelityCreds( storage_state_path="/tmp/x", plan_id="META", )) # Pre-fetch state: empty assert prov.last_holdings == [] assert prov.last_total_contribution == Decimal(0)