broker-sync/tests/sinks/test_wealthfolio.py
Viktor Barzin ea881e272b sinks: match Wealthfolio NewAccount camelCase schema + required booleans
Wealthfolio 3.2's POST /api/v1/accounts was 422ing on live traffic — its
NewAccount struct uses camelCase field names and requires isDefault +
isActive as booleans. Reference:
https://github.com/afadil/wealthfolio/blob/main/apps/server/src/models.rs#L~145

Sends trackingMode=TRANSACTIONS so Wealthfolio computes holdings from
our imported activities (vs HOLDINGS mode which requires periodic
holdings snapshots). Populates providerAccountId so the broker account
is traceable back to our sync's id scheme.

Test plan:
  poetry run pytest -q   →  70 passed
  poetry run mypy        →  clean
  poetry run ruff check  →  clean

Live re-run of the backfill Job follows this commit's image rebuild.
2026-04-17 20:29:43 +00:00

242 lines
7.4 KiB
Python

from __future__ import annotations
import json
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, Activity, ActivityType
from broker_sync.sinks.wealthfolio import (
ImportValidationError,
WealthfolioSink,
WealthfolioUnauthorizedError,
)
def _buy() -> Activity:
return Activity(
external_id="t212:1",
account_id="t212-isa",
account_type=AccountType.ISA,
date=datetime(2026, 4, 1, 10, 30, tzinfo=UTC),
activity_type=ActivityType.BUY,
symbol="VUAG",
quantity=Decimal("1"),
unit_price=Decimal("100"),
currency="GBP",
)
def _client(handler: httpx.MockTransport, session_path: Path) -> WealthfolioSink:
return WealthfolioSink(
base_url="https://wf.test",
username="viktor",
password="hunter2",
session_path=session_path,
transport=handler,
)
def _login_ok(req: httpx.Request) -> httpx.Response:
assert req.url.path == "/api/v1/auth/login"
body = json.loads(req.content)
# Wealthfolio 3.2 LoginRequest is password-only.
assert body == {"password": "hunter2"}
return httpx.Response(
200,
json={"authenticated": True, "expiresIn": 604800},
headers={"set-cookie": "wf_token=abc123; Path=/api; HttpOnly"},
)
# -- Login --
async def test_login_persists_cookie(tmp_path: Path) -> None:
async def handler(req: httpx.Request) -> httpx.Response:
return _login_ok(req)
sp = tmp_path / "session.json"
sink = _client(httpx.MockTransport(handler), sp)
await sink.login()
data = json.loads(sp.read_text())
assert "wf_token" in data["cookies"]
assert data["cookies"]["wf_token"] == "abc123"
async def test_login_raises_on_401(tmp_path: Path) -> None:
async def handler(req: httpx.Request) -> httpx.Response:
return httpx.Response(401, json={"error": "bad creds"})
sink = _client(httpx.MockTransport(handler), tmp_path / "s.json")
with pytest.raises(WealthfolioUnauthorizedError):
await sink.login()
# -- Session reuse --
async def test_session_reused_from_disk(tmp_path: Path) -> None:
sp = tmp_path / "s.json"
sp.write_text(json.dumps({"cookies": {"wf_token": "cached"}}))
calls: list[str] = []
async def handler(req: httpx.Request) -> httpx.Response:
calls.append(req.url.path)
assert "wf_token=cached" in req.headers.get("cookie", "")
return httpx.Response(200, json=[])
sink = _client(httpx.MockTransport(handler), sp)
await sink.list_accounts()
assert calls == ["/api/v1/accounts"]
async def test_401_triggers_single_reauth_and_retry(tmp_path: Path) -> None:
sp = tmp_path / "s.json"
sp.write_text(json.dumps({"cookies": {"wf_token": "stale"}}))
path_calls: list[tuple[str, str]] = []
async def handler(req: httpx.Request) -> httpx.Response:
path_calls.append((req.method, req.url.path))
if req.url.path == "/api/v1/accounts" and req.headers.get("cookie", "").endswith("stale"):
return httpx.Response(401)
if req.url.path == "/api/v1/auth/login":
return _login_ok(req)
# After login the stored cookie is fresh; second GET should succeed.
return httpx.Response(200, json=[{"id": "a1"}])
sink = _client(httpx.MockTransport(handler), sp)
out = await sink.list_accounts()
assert out == [{"id": "a1"}]
assert path_calls == [
("GET", "/api/v1/accounts"),
("POST", "/api/v1/auth/login"),
("GET", "/api/v1/accounts"),
]
# -- Account ensure --
async def test_ensure_account_no_op_if_exists(tmp_path: Path) -> None:
sp = tmp_path / "s.json"
sp.write_text(json.dumps({"cookies": {"wf_token": "fresh"}}))
posts: list[dict[str, Any]] = []
async def handler(req: httpx.Request) -> httpx.Response:
if req.method == "GET" and req.url.path == "/api/v1/accounts":
return httpx.Response(
200,
json=[{
"id": "t212-isa",
"name": "Trading212 ISA"
}],
)
if req.method == "POST":
posts.append(json.loads(req.content))
return httpx.Response(500)
sink = _client(httpx.MockTransport(handler), sp)
acc = Account(
id="t212-isa",
name="Trading212 ISA",
account_type=AccountType.ISA,
currency="GBP",
provider="trading212",
)
await sink.ensure_account(acc)
assert posts == [] # no create
async def test_ensure_account_creates_if_missing(tmp_path: Path) -> None:
sp = tmp_path / "s.json"
sp.write_text(json.dumps({"cookies": {"wf_token": "fresh"}}))
posted: list[dict[str, Any]] = []
async def handler(req: httpx.Request) -> httpx.Response:
if req.method == "GET" and req.url.path == "/api/v1/accounts":
return httpx.Response(200, json=[])
if req.method == "POST" and req.url.path == "/api/v1/accounts":
posted.append(json.loads(req.content))
return httpx.Response(200, json={"id": "t212-isa"})
return httpx.Response(500)
sink = _client(httpx.MockTransport(handler), sp)
acc = Account(
id="t212-isa",
name="Trading212 ISA",
account_type=AccountType.ISA,
currency="GBP",
provider="trading212",
)
await sink.ensure_account(acc)
assert len(posted) == 1
assert posted[0]["id"] == "t212-isa"
assert posted[0]["accountType"] == "ISA"
assert posted[0]["currency"] == "GBP"
assert posted[0]["isActive"] is True
assert posted[0]["isDefault"] is False
# -- Activity import --
async def test_import_dry_run_then_real(tmp_path: Path) -> None:
sp = tmp_path / "s.json"
sp.write_text(json.dumps({"cookies": {"wf_token": "fresh"}}))
calls: list[str] = []
async def handler(req: httpx.Request) -> httpx.Response:
calls.append(req.url.path)
if req.url.path == "/api/v1/activities/import/check":
return httpx.Response(200, json={"ok": True, "rows": 1})
if req.url.path == "/api/v1/activities/import":
return httpx.Response(
200,
json=[{
"id": "wf-1",
"external_id": "t212:1"
}],
)
return httpx.Response(500)
sink = _client(httpx.MockTransport(handler), sp)
out = await sink.import_activities([_buy()])
assert calls == [
"/api/v1/activities/import/check",
"/api/v1/activities/import",
]
assert out == [{"id": "wf-1", "external_id": "t212:1"}]
async def test_import_halts_on_validation_failure(tmp_path: Path) -> None:
sp = tmp_path / "s.json"
sp.write_text(json.dumps({"cookies": {"wf_token": "fresh"}}))
calls: list[str] = []
async def handler(req: httpx.Request) -> httpx.Response:
calls.append(req.url.path)
if req.url.path == "/api/v1/activities/import/check":
return httpx.Response(
400,
json={"errors": ["row 1: unknown symbol"]},
)
return httpx.Response(500)
sink = _client(httpx.MockTransport(handler), sp)
with pytest.raises(ImportValidationError, match="unknown symbol"):
await sink.import_activities([_buy()])
assert calls == ["/api/v1/activities/import/check"] # real import never hit