broker-sync/tests/providers/test_trading212.py
Viktor Barzin 1eb3f78ea5 Wire T212 pagination, retries, and click<8.2 pin
Context
-------
Closes out the Trading212 provider's retry + pagination surface so
the "Add Trading212Provider core fetch" commit has everything the
CronJob needs: cursor-based pagination, 429 honouring Retry-After,
jittered exponential backoff for 429-without-header and 5xx, bailout
after _MAX_RETRIES, and checkpoint-after-page semantics so a crashed
run resumes at the start of the unfinished page.

Also pins click<8.2 — typer 0.12 calls Parameter.make_metavar()
without a ctx argument, which click 8.2 removed; `broker-sync --help`
was crashing with TypeError until this pin. typer 0.15+ would also
fix it; the pin is lower friction.

One test fix: test_checkpoint_advances_only_after_page_yielded had a
handler that unconditionally returned a next_path → infinite loop. The
assertion was always about "a cursor was saved after page 1", so I
changed the handler to return page 2 as empty-with-no-next, which
terminates the loop cleanly.

Test plan
---------
## Automated
- poetry run pytest -q  →  70 passed
- poetry run mypy broker_sync tests  →  Success: no issues found in 29 source files
- poetry run ruff check .  →  All checks passed!
- poetry run broker-sync --help  →  renders without crash; lists version + auth-spike

## Manual Verification
End-to-end against a live T212 key is in the next commit once the
CLI subcommand and pipeline land.
2026-04-17 19:45:23 +00:00

388 lines
12 KiB
Python

from __future__ import annotations
from datetime import UTC, datetime
from decimal import Decimal
from pathlib import Path
from typing import Any
import httpx
import pytest
from broker_sync.models import Account, AccountType, ActivityType
from broker_sync.providers.trading212 import (
Trading212AuthError,
Trading212Error,
Trading212Provider,
)
_ISA = Account(
id="t212-isa",
name="Trading212 ISA",
account_type=AccountType.ISA,
currency="GBP",
provider="trading212",
)
_GIA = Account(
id="t212-invest",
name="Trading212 Invest",
account_type=AccountType.GIA,
currency="GBP",
provider="trading212",
)
def _fill(
*,
fill_id: str = "fill-1",
order_id: str = "order-1",
ticker: str = "VUAGl_EQ",
currency: str = "GBP",
price: str = "85.5",
quantity: str = "2.0",
filled_at: str = "2026-04-01T10:30:00.000Z",
) -> dict[str, Any]:
return {
"type": "MARKET",
"id": 123,
"fill": {
"id": fill_id,
"price": price,
"quantity": quantity,
"filledAt": filled_at,
},
"order": {
"id": order_id,
"ticker": ticker,
"currency": currency,
},
}
def _page(items: list[dict[str, Any]], next_path: str | None = None) -> dict[str, Any]:
return {"items": items, "nextPagePath": next_path}
def _provider(
*,
checkpoint_dir: Path,
transport: httpx.MockTransport,
accounts: list[tuple[Account, str]] | None = None,
) -> Trading212Provider:
return Trading212Provider(
accounts=accounts or [(_ISA, "isa-key")],
checkpoint_dir=checkpoint_dir,
transport=transport,
)
async def _collect(provider: Trading212Provider) -> list[Any]:
out = []
async for a in provider.fetch():
out.append(a)
return out
# -- auth header shape --
async def test_auth_header_is_literal_api_key(tmp_path: Path) -> None:
seen: list[str | None] = []
def handler(req: httpx.Request) -> httpx.Response:
seen.append(req.headers.get("Authorization"))
return httpx.Response(200, json=_page([]))
p = _provider(checkpoint_dir=tmp_path, transport=httpx.MockTransport(handler))
await _collect(p)
assert seen == ["isa-key"]
# Not the Bearer-prefixed variant other APIs use.
assert "Bearer" not in (seen[0] or "")
# -- single fill → Activity mapping --
async def test_buy_fill_becomes_buy_activity(tmp_path: Path) -> None:
def handler(req: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=_page([_fill()]))
p = _provider(checkpoint_dir=tmp_path, transport=httpx.MockTransport(handler))
acts = await _collect(p)
assert len(acts) == 1
a = acts[0]
assert a.activity_type is ActivityType.BUY
assert a.external_id == "t212:fill:fill-1"
assert a.symbol == "VUAG"
assert a.quantity == Decimal("2.0")
assert a.unit_price == Decimal("85.5")
assert a.currency == "GBP"
assert a.account_id == "t212-isa"
assert a.account_type is AccountType.ISA
assert a.date == datetime(2026, 4, 1, 10, 30, tzinfo=UTC)
async def test_negative_quantity_becomes_sell(tmp_path: Path) -> None:
def handler(req: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=_page([_fill(quantity="-3.0")]))
p = _provider(checkpoint_dir=tmp_path, transport=httpx.MockTransport(handler))
acts = await _collect(p)
assert acts[0].activity_type is ActivityType.SELL
# Quantity is stored as absolute.
assert acts[0].quantity == Decimal("3.0")
async def test_null_fill_is_skipped(tmp_path: Path) -> None:
cancelled = {
"type": "MARKET",
"id": 999,
"fill": None,
"order": {
"id": "o-cancelled",
"ticker": "VUAGl_EQ",
"currency": "GBP"
},
}
def handler(req: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=_page([cancelled, _fill()]))
p = _provider(checkpoint_dir=tmp_path, transport=httpx.MockTransport(handler))
acts = await _collect(p)
assert len(acts) == 1
assert acts[0].external_id == "t212:fill:fill-1"
# -- since filter --
async def test_since_drops_older_fills(tmp_path: Path) -> None:
old = _fill(fill_id="old", filled_at="2020-01-01T00:00:00.000Z")
new = _fill(fill_id="new", filled_at="2026-04-01T10:30:00.000Z")
def handler(req: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=_page([new, old]))
p = _provider(checkpoint_dir=tmp_path, transport=httpx.MockTransport(handler))
since = datetime(2026, 1, 1, tzinfo=UTC)
out = [a async for a in p.fetch(since=since)]
assert [a.external_id for a in out] == ["t212:fill:new"]
# -- error types --
async def test_401_raises_auth_error(tmp_path: Path) -> None:
def handler(req: httpx.Request) -> httpx.Response:
return httpx.Response(401, json={"error": "bad key"})
p = _provider(checkpoint_dir=tmp_path, transport=httpx.MockTransport(handler))
with pytest.raises(Trading212AuthError):
await _collect(p)
async def test_403_raises_generic_error(tmp_path: Path) -> None:
def handler(req: httpx.Request) -> httpx.Response:
return httpx.Response(403, json={"error": "forbidden"})
p = _provider(checkpoint_dir=tmp_path, transport=httpx.MockTransport(handler))
with pytest.raises(Trading212Error):
await _collect(p)
# -- name + accounts contract --
def test_provider_name() -> None:
assert Trading212Provider.name == "trading212"
def test_accounts_returns_registered_pairs(tmp_path: Path) -> None:
def handler(req: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=_page([]))
p = _provider(
checkpoint_dir=tmp_path,
transport=httpx.MockTransport(handler),
accounts=[(_ISA, "isa-key"), (_GIA, "gia-key")],
)
accs = p.accounts()
assert [a.id for a in accs] == ["t212-isa", "t212-invest"]
# -- pagination --
async def test_pagination_follows_next_page_path(tmp_path: Path) -> None:
pages = iter([
_page([_fill(fill_id="p1-a"), _fill(fill_id="p1-b")],
next_path="/api/v0/equity/history/orders?cursor=p2"),
_page([_fill(fill_id="p2-a")], next_path=None),
])
visited: list[str | None] = []
def handler(req: httpx.Request) -> httpx.Response:
visited.append(req.url.params.get("cursor"))
return httpx.Response(200, json=next(pages))
p = _provider(checkpoint_dir=tmp_path, transport=httpx.MockTransport(handler))
out = await _collect(p)
assert [a.external_id for a in out] == [
"t212:fill:p1-a",
"t212:fill:p1-b",
"t212:fill:p2-a",
]
# First call has no cursor; second uses the cursor from nextPagePath.
assert visited == [None, "p2"]
async def test_pagination_stops_when_since_reached(tmp_path: Path) -> None:
# First page has one too-old fill; remaining fill is new. Provider
# must stop without fetching page 2 once a page has items strictly
# older than `since`.
pages = iter([
_page(
[
_fill(fill_id="new", filled_at="2026-04-01T10:30:00.000Z"),
_fill(fill_id="old", filled_at="2020-01-01T00:00:00.000Z"),
],
next_path="/api/v0/equity/history/orders?cursor=p2",
),
_page([_fill(fill_id="p2-a")], next_path=None),
])
call_count = 0
def handler(req: httpx.Request) -> httpx.Response:
nonlocal call_count
call_count += 1
return httpx.Response(200, json=next(pages))
p = _provider(checkpoint_dir=tmp_path, transport=httpx.MockTransport(handler))
since = datetime(2026, 1, 1, tzinfo=UTC)
out = [a async for a in p.fetch(since=since)]
assert [a.external_id for a in out] == ["t212:fill:new"]
assert call_count == 1 # did NOT walk to page 2
async def test_checkpoint_advances_only_after_page_yielded(tmp_path: Path) -> None:
cursor_next = "/api/v0/equity/history/orders?cursor=p2"
calls = 0
def handler(req: httpx.Request) -> httpx.Response:
# Page 1: one fill + next_path (triggers a checkpoint save).
# Page 2: empty + no next — terminates the loop cleanly.
nonlocal calls
calls += 1
if calls == 1:
return httpx.Response(200, json=_page([_fill()], next_path=cursor_next))
return httpx.Response(200, json=_page([], next_path=None))
p = _provider(checkpoint_dir=tmp_path, transport=httpx.MockTransport(handler))
await _collect(p)
# After a successful fetch, the checkpoint holds the cursor for the NEXT page.
from broker_sync.providers._checkpoint import Checkpoint
cp = Checkpoint(tmp_path, provider="trading212", account_id="t212-isa")
assert cp.load() == cursor_next
# -- retries --
async def test_429_with_retry_after_sleeps_then_retries(tmp_path: Path,
monkeypatch: pytest.MonkeyPatch) -> None:
calls = 0
sleeps: list[float] = []
async def fake_sleep(seconds: float) -> None:
sleeps.append(seconds)
monkeypatch.setattr("asyncio.sleep", fake_sleep)
def handler(req: httpx.Request) -> httpx.Response:
nonlocal calls
calls += 1
if calls == 1:
return httpx.Response(429, headers={"Retry-After": "7"})
return httpx.Response(200, json=_page([_fill()]))
p = _provider(checkpoint_dir=tmp_path, transport=httpx.MockTransport(handler))
out = await _collect(p)
assert len(out) == 1
assert calls == 2
assert sleeps == [7.0]
async def test_429_without_retry_after_uses_backoff(tmp_path: Path,
monkeypatch: pytest.MonkeyPatch) -> None:
calls = 0
sleeps: list[float] = []
async def fake_sleep(seconds: float) -> None:
sleeps.append(seconds)
monkeypatch.setattr("asyncio.sleep", fake_sleep)
# Deterministic jitter: always 0.5 of the cap.
monkeypatch.setattr(
"broker_sync.providers.trading212._jitter",
lambda base: base,
)
def handler(req: httpx.Request) -> httpx.Response:
nonlocal calls
calls += 1
if calls < 3:
return httpx.Response(429)
return httpx.Response(200, json=_page([_fill()]))
p = _provider(checkpoint_dir=tmp_path, transport=httpx.MockTransport(handler))
out = await _collect(p)
assert len(out) == 1
# First backoff = 10s; second doubles to 20s.
assert sleeps == [10.0, 20.0]
async def test_429_gives_up_after_max_retries(tmp_path: Path,
monkeypatch: pytest.MonkeyPatch) -> None:
async def noop_sleep(seconds: float) -> None:
return None
monkeypatch.setattr("asyncio.sleep", noop_sleep)
def handler(req: httpx.Request) -> httpx.Response:
return httpx.Response(429)
p = _provider(checkpoint_dir=tmp_path, transport=httpx.MockTransport(handler))
with pytest.raises(Trading212Error, match="429"):
await _collect(p)
async def test_5xx_retries_with_backoff(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
calls = 0
sleeps: list[float] = []
async def fake_sleep(seconds: float) -> None:
sleeps.append(seconds)
monkeypatch.setattr("asyncio.sleep", fake_sleep)
monkeypatch.setattr(
"broker_sync.providers.trading212._jitter",
lambda base: base,
)
def handler(req: httpx.Request) -> httpx.Response:
nonlocal calls
calls += 1
if calls == 1:
return httpx.Response(502)
return httpx.Response(200, json=_page([_fill()]))
p = _provider(checkpoint_dir=tmp_path, transport=httpx.MockTransport(handler))
out = await _collect(p)
assert len(out) == 1
assert sleeps == [10.0]