diff --git a/agent/src/agent/firefly.py b/agent/src/agent/firefly.py new file mode 100644 index 0000000..b588b22 --- /dev/null +++ b/agent/src/agent/firefly.py @@ -0,0 +1,49 @@ +"""Minimal read-only client for the Firefly III REST API.""" + +from __future__ import annotations + +import httpx + + +class FireflyError(Exception): + """Firefly III returned an unexpected response.""" + + +class FireflyClient: + def __init__( + self, + base_url: str, + token: str, + transport: httpx.BaseTransport | None = None, + ) -> None: + self._http = httpx.Client( + base_url=base_url, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/json", + }, + timeout=30.0, + transport=transport, + ) + + def _get(self, path: str, params: dict | None = None) -> dict: + response = self._http.get(path, params=params) + if response.status_code != 200: + raise FireflyError( + f"GET {path} -> {response.status_code}: {response.text[:200]}" + ) + return response.json() + + def about(self) -> dict: + return self._get("/api/v1/about")["data"] + + def accounts(self, type: str = "asset") -> list[dict]: + results: list[dict] = [] + page = 1 + while True: + body = self._get("/api/v1/accounts", params={"type": type, "page": page}) + results.extend(body["data"]) + pagination = body.get("meta", {}).get("pagination", {}) + if page >= pagination.get("total_pages", 1): + return results + page += 1 diff --git a/agent/tests/test_firefly.py b/agent/tests/test_firefly.py new file mode 100644 index 0000000..efc64d5 --- /dev/null +++ b/agent/tests/test_firefly.py @@ -0,0 +1,49 @@ +import httpx +import pytest + +from agent.firefly import FireflyClient, FireflyError + + +def make_client(handler) -> FireflyClient: + return FireflyClient( + "http://firefly.test", "tok", transport=httpx.MockTransport(handler) + ) + + +def test_about_sends_bearer_token_and_returns_data(): + def handler(request: httpx.Request) -> httpx.Response: + assert request.headers["Authorization"] == "Bearer tok" + assert request.headers["Accept"] == "application/json" + assert request.url.path == "/api/v1/about" + return httpx.Response(200, json={"data": {"version": "6.1.1"}}) + + assert make_client(handler).about() == {"version": "6.1.1"} + + +def test_non_200_raises_firefly_error_with_status(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(401, json={"message": "Unauthenticated"}) + + with pytest.raises(FireflyError, match="401"): + make_client(handler).about() + + +def test_accounts_walks_all_pages(): + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/api/v1/accounts" + assert request.url.params["type"] == "asset" + page = int(request.url.params.get("page", "1")) + data = { + 1: [{"id": "1", "attributes": {"name": "NFCU Checking"}}], + 2: [{"id": "2", "attributes": {"name": "Venmo"}}], + }[page] + return httpx.Response( + 200, + json={ + "data": data, + "meta": {"pagination": {"total_pages": 2, "current_page": page}}, + }, + ) + + accounts = make_client(handler).accounts() + assert [a["attributes"]["name"] for a in accounts] == ["NFCU Checking", "Venmo"]