from __future__ import annotations from collections.abc import Callable from datetime import UTC, datetime, timedelta from decimal import Decimal from typing import Any import httpx import pytest from broker_sync.models import AccountType, ActivityType from broker_sync.providers.invest_engine import ( InvestEngineError, InvestEngineProvider, InvestEngineTokenExpiredError, InvestEngineVersionError, _probe_version, _transaction_to_activity, ) # -- helpers -- def _future() -> datetime: return datetime.now(UTC) + timedelta(days=30) def _past() -> datetime: return datetime.now(UTC) - timedelta(days=1) def _client(handler: Callable[[httpx.Request], httpx.Response]) -> httpx.AsyncClient: return httpx.AsyncClient( base_url="https://investengine.com", transport=httpx.MockTransport(handler), ) # -- version probe -- async def test_probe_stops_at_first_live_version() -> None: """v0.32 is live (401). Probe should return 32 without touching v0.33.""" visited: list[str] = [] def handler(req: httpx.Request) -> httpx.Response: visited.append(req.url.path) return httpx.Response(401) async with _client(handler) as c: minor = await _probe_version(c, start_minor=32) assert minor == 32 assert visited == ["/api/v0.32/"] async def test_probe_skips_410_and_advances() -> None: """v0.32 is Gone, v0.33 is live (401). Probe lands on 33.""" visited: list[str] = [] def handler(req: httpx.Request) -> httpx.Response: visited.append(req.url.path) if "v0.32" in req.url.path: return httpx.Response(410) return httpx.Response(401) async with _client(handler) as c: minor = await _probe_version(c, start_minor=32) assert minor == 33 assert visited == ["/api/v0.32/", "/api/v0.33/"] async def test_probe_gives_up_after_max_minor() -> None: """Every version 410s → explicit error rather than infinite loop.""" def handler(req: httpx.Request) -> httpx.Response: return httpx.Response(410) async with _client(handler) as c: with pytest.raises(InvestEngineVersionError): await _probe_version(c, start_minor=32, max_minor=34) # -- token expiry fail-fast -- async def test_expired_token_raises_on_fetch() -> None: """If expires_at is in the past, we fail before making any request.""" def handler(req: httpx.Request) -> httpx.Response: raise AssertionError("should not have called the API") p = InvestEngineProvider( bearer_token="x", token_expires_at=_past(), transport=httpx.MockTransport(handler), ) try: with pytest.raises(InvestEngineTokenExpiredError): async for _ in p.fetch(): pass finally: await p.close() # -- 401 during fetch -- async def test_401_during_probe_is_live_version() -> None: """401 on version-probe GET means version is live — we then request the portfolios endpoint which, with a bad token, also 401s, and that second 401 is what should surface as TokenExpired.""" def handler(req: httpx.Request) -> httpx.Response: return httpx.Response(401) p = InvestEngineProvider( bearer_token="dead-token", token_expires_at=_future(), transport=httpx.MockTransport(handler), ) try: with pytest.raises(InvestEngineTokenExpiredError): async for _ in p.fetch(): pass finally: await p.close() # -- headers -- async def test_bearer_and_user_agent_headers_attached() -> None: seen: list[tuple[str | None, str | None]] = [] def handler(req: httpx.Request) -> httpx.Response: seen.append((req.headers.get("Authorization"), req.headers.get("User-Agent"))) # Probe returns live; portfolios returns empty list shape. if req.url.path.endswith("/portfolios/"): return httpx.Response(200, json={"results": []}) return httpx.Response(401) p = InvestEngineProvider( bearer_token="abc123", token_expires_at=_future(), transport=httpx.MockTransport(handler), ) try: async for _ in p.fetch(): pass finally: await p.close() # Probe + portfolios — both should carry the Bearer + UA. assert len(seen) == 2 for auth, ua in seen: assert auth == "Bearer abc123" assert ua is not None and "broker-sync" in ua # -- accounts contract -- def test_accounts_returns_single_isa() -> None: p = InvestEngineProvider( bearer_token="x", token_expires_at=_future(), ) accs = p.accounts() assert [a.id for a in accs] == ["invest-engine-primary"] assert accs[0].account_type is AccountType.ISA assert accs[0].currency == "GBP" assert accs[0].provider == "invest-engine" def test_provider_name() -> None: assert InvestEngineProvider.name == "invest-engine" # -- transaction → activity mapping -- # # The real IE response shape is UNVERIFIED (MFA blocks authed probes). # These tests use best-guess shapes based on Django REST conventions. # `_transaction_to_activity` is written defensively so alternative casings # and common field names round-trip correctly. def _mock_txn( *, txn_id: str = "txn-1", txn_type: str = "BUY", symbol: str = "VUAG", quantity: str = "10", price: str = "90.5", amount: str = "905.00", currency: str = "GBP", date: str = "2026-04-01T10:00:00Z", ) -> dict[str, Any]: return { "id": txn_id, "type": txn_type, "symbol": symbol, "quantity": quantity, "price": price, "amount": amount, "currency": currency, "date": date, } def test_buy_txn_becomes_buy_activity() -> None: a = _transaction_to_activity(_mock_txn(txn_type="BUY")) assert a is not None assert a.activity_type is ActivityType.BUY assert a.external_id == "invest-engine:txn-1" assert a.account_id == "invest-engine-primary" assert a.account_type is AccountType.ISA assert a.symbol == "VUAG" assert a.quantity == Decimal("10") assert a.unit_price == Decimal("90.5") assert a.currency == "GBP" def test_sell_txn_becomes_sell_activity() -> None: a = _transaction_to_activity(_mock_txn(txn_type="SELL", quantity="5")) assert a is not None assert a.activity_type is ActivityType.SELL assert a.quantity == Decimal("5") def test_dividend_txn_becomes_dividend_with_amount() -> None: raw = _mock_txn(txn_type="DIVIDEND", amount="12.34") raw.pop("quantity") raw.pop("price") a = _transaction_to_activity(raw) assert a is not None assert a.activity_type is ActivityType.DIVIDEND assert a.amount == Decimal("12.34") def test_deposit_txn_mapped() -> None: raw = _mock_txn(txn_type="DEPOSIT", amount="500.00") raw.pop("quantity") raw.pop("price") a = _transaction_to_activity(raw) assert a is not None assert a.activity_type is ActivityType.DEPOSIT assert a.amount == Decimal("500.00") def test_withdrawal_txn_mapped() -> None: raw = _mock_txn(txn_type="WITHDRAWAL", amount="100.00") raw.pop("quantity") raw.pop("price") a = _transaction_to_activity(raw) assert a is not None assert a.activity_type is ActivityType.WITHDRAWAL def test_unknown_txn_type_is_skipped_with_warning(caplog: pytest.LogCaptureFixture, ) -> None: raw = _mock_txn(txn_type="MYSTERY_EVENT") a = _transaction_to_activity(raw) assert a is None assert any("MYSTERY_EVENT" in r.message for r in caplog.records) def test_date_parsing_handles_z_suffix() -> None: a = _transaction_to_activity(_mock_txn(date="2026-04-01T10:00:00Z")) assert a is not None assert a.date == datetime(2026, 4, 1, 10, 0, tzinfo=UTC) def test_date_parsing_handles_offset_suffix() -> None: a = _transaction_to_activity(_mock_txn(date="2026-04-01T10:00:00+00:00")) assert a is not None assert a.date == datetime(2026, 4, 1, 10, 0, tzinfo=UTC) # -- end-to-end fetch (portfolios + transactions happy path) -- async def test_fetch_enumerates_portfolios_and_transactions() -> None: # Mock Django-REST-style paginated response with results + next. portfolios = {"results": [{"id": 7, "name": "Viktor's ISA"}], "next": None} dividend = _mock_txn(txn_id="t2", txn_type="DIVIDEND", amount="5.00") dividend.pop("quantity") dividend.pop("price") transactions: dict[str, Any] = { "results": [ _mock_txn(txn_id="t1", txn_type="BUY"), dividend, ], "next": None, } visited: list[str] = [] def handler(req: httpx.Request) -> httpx.Response: visited.append(req.url.path) if req.url.path == "/api/v0.32/": return httpx.Response(401) if req.url.path == "/api/v0.32/portfolios/": return httpx.Response(200, json=portfolios) if req.url.path == "/api/v0.32/transactions/": assert req.url.params.get("portfolio") == "7" return httpx.Response(200, json=transactions) raise AssertionError(f"unexpected path: {req.url.path}") p = InvestEngineProvider( bearer_token="good-token", token_expires_at=_future(), transport=httpx.MockTransport(handler), ) try: out = [a async for a in p.fetch()] finally: await p.close() assert [a.external_id for a in out] == [ "invest-engine:t1", "invest-engine:t2", ] async def test_fetch_supports_data_meta_pagination_shape() -> None: """Defensive: handle the alternative {data, meta.next_page} shape too.""" portfolios = {"data": [{"id": 9, "name": "ISA"}], "meta": {"next_page": None}} transactions = { "data": [_mock_txn(txn_id="dm1")], "meta": { "next_page": None }, } def handler(req: httpx.Request) -> httpx.Response: if req.url.path == "/api/v0.32/": return httpx.Response(401) if req.url.path == "/api/v0.32/portfolios/": return httpx.Response(200, json=portfolios) if req.url.path == "/api/v0.32/transactions/": return httpx.Response(200, json=transactions) raise AssertionError(f"unexpected: {req.url.path}") p = InvestEngineProvider( bearer_token="t", token_expires_at=_future(), transport=httpx.MockTransport(handler), ) try: out = [a async for a in p.fetch()] finally: await p.close() assert [a.external_id for a in out] == ["invest-engine:dm1"] # -- since filter -- async def test_since_drops_older_transactions() -> None: txns = { "results": [ _mock_txn(txn_id="old", date="2020-01-01T00:00:00Z"), _mock_txn(txn_id="new", date="2026-04-01T10:00:00Z"), ], "next": None, } def handler(req: httpx.Request) -> httpx.Response: if req.url.path == "/api/v0.32/": return httpx.Response(401) if req.url.path == "/api/v0.32/portfolios/": return httpx.Response(200, json={"results": [{"id": 1}]}) return httpx.Response(200, json=txns) p = InvestEngineProvider( bearer_token="t", token_expires_at=_future(), transport=httpx.MockTransport(handler), ) try: since = datetime(2026, 1, 1, tzinfo=UTC) out = [a async for a in p.fetch(since=since)] finally: await p.close() assert [a.external_id for a in out] == ["invest-engine:new"] # -- 401 on a data endpoint → TokenExpired -- async def test_401_on_portfolios_triggers_token_expired() -> None: def handler(req: httpx.Request) -> httpx.Response: if req.url.path == "/api/v0.32/": return httpx.Response(401) if req.url.path == "/api/v0.32/portfolios/": return httpx.Response(401, json={"detail": "Invalid token"}) raise AssertionError(f"unexpected: {req.url.path}") p = InvestEngineProvider( bearer_token="stale", token_expires_at=_future(), # clock says alive, server says dead transport=httpx.MockTransport(handler), ) try: with pytest.raises(InvestEngineTokenExpiredError): async for _ in p.fetch(): pass finally: await p.close() # -- 410 on a data endpoint → one re-probe + retry -- async def test_410_on_data_triggers_reprobe_and_retry() -> None: # Scenario: probe lands on v0.32, then the portfolios call 410s because # IE just rolled the version mid-session. We re-probe, find v0.33, and # retry the call. We verify both versions are hit and we don't loop. visited: list[str] = [] portfolios_call_count = 0 def handler(req: httpx.Request) -> httpx.Response: visited.append(req.url.path) # Version probe endpoints: 32 was live when process started; new probe # now shows 32 Gone and 33 live. if req.url.path == "/api/v0.32/": # First probe (process start) → live (401). # Second probe (after 410) → Gone. if "/api/v0.32/portfolios/" in [v for v in visited]: return httpx.Response(410) return httpx.Response(401) if req.url.path == "/api/v0.33/": return httpx.Response(401) if req.url.path == "/api/v0.32/portfolios/": nonlocal portfolios_call_count portfolios_call_count += 1 return httpx.Response(410, json={"detail": "Version Gone"}) if req.url.path == "/api/v0.33/portfolios/": return httpx.Response(200, json={"results": []}) raise AssertionError(f"unexpected: {req.url.path}") p = InvestEngineProvider( bearer_token="t", token_expires_at=_future(), transport=httpx.MockTransport(handler), ) try: out = [a async for a in p.fetch()] finally: await p.close() assert out == [] assert "/api/v0.32/portfolios/" in visited assert "/api/v0.33/" in visited assert "/api/v0.33/portfolios/" in visited # Exactly one 410 on v0.32/portfolios/; no repeat loop. assert portfolios_call_count == 1 # -- integration stub -- @pytest.mark.skip(reason="needs live token — flip on manually") async def test_live_integration_smoke() -> None: # pragma: no cover """Real API smoke test. Enable manually after Viktor pastes a token.""" import os token = os.environ.get("IE_BEARER_TOKEN") if not token: pytest.skip("IE_BEARER_TOKEN not set") p = InvestEngineProvider( bearer_token=token, token_expires_at=_future(), ) try: out = [a async for a in p.fetch(since=_past())] finally: await p.close() # No assertions on content yet — just proves a live round-trip works. assert isinstance(out, list) # -- smoke check InvestEngineError is public -- def test_error_types_public() -> None: assert issubclass(InvestEngineTokenExpiredError, InvestEngineError) assert issubclass(InvestEngineVersionError, InvestEngineError)