489 lines
15 KiB
Python
489 lines
15 KiB
Python
|
|
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)
|