diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f21a7b7..0e9aa7b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,8 +42,13 @@ jobs: run: mypy src/findata - name: Pytest + if: matrix.python-version != '3.12' run: pytest -v + - name: Pytest + coverage + if: matrix.python-version == '3.12' + run: pytest -v --cov=findata --cov-report=term-missing --cov-fail-under=60 + build: runs-on: ubuntu-latest needs: test diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 0000000..06f3c5e --- /dev/null +++ b/.github/workflows/integration.yml @@ -0,0 +1,37 @@ +name: Nightly integration + +on: + schedule: + - cron: "0 6 * * *" + workflow_dispatch: + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +jobs: + integration: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install + run: | + python -m pip install --upgrade pip + pip install -e '.[dev]' + + - name: Run integration tests + id: integration-tests + run: pytest -m integration -v 2>&1 | tee pytest-integration.log + + - name: Upload failure log + if: steps.integration-tests.outcome == 'failure' + uses: actions/upload-artifact@v4 + with: + name: pytest-integration-log + path: pytest-integration.log diff --git a/CHANGELOG.md b/CHANGELOG.md index e24b8cb..2b195ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,14 @@ adheres to [Semantic Versioning](https://semver.org/). ### Added +- **Test hardening and nightly integration checks.** Added offline unit/API + coverage for HTTP 429 retries, rate-limit helpers, previously thin sources + (BCB PTAX/Focus, IBGE, CVM companies/financials, Tesouro bonds, B3 quotes via + mocked yfinance), thin REST smoke for those routes, and CLI smoke + (`--help`/`--version`/`bcb series`/`bcb get`). Coverage gate + (`--cov-fail-under=60`) runs on the Python 3.12 CI leg. Live + `@pytest.mark.integration` tests run on a scheduled nightly workflow + (and `workflow_dispatch`), not on the default PR CI. - **Asset-classification resolver** — `findata.resolver.resolve_asset()`, `GET /resolver/resolve`, and the `resolve_asset` MCP tool. Turns any Brazilian asset identifier (ticker/CNPJ/ISIN/name) into a classification diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c4718da..7c50cb7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -72,7 +72,7 @@ Instalados via `bash scripts/git/install-hooks.sh`, que aponta - **pre-push** — rede de segurança completa: - `ruff format --check` + `ruff check` no repo inteiro (`src`, `tests`, `scripts`). - `mypy --strict` em `src/findata`. - - `pytest -q` (unit + API; integration fica só na CI). + - `pytest -q` (unit + API; integration fica no workflow noturno/agendado). Pra desinstalar: `git config --unset core.hooksPath`. @@ -80,7 +80,7 @@ Pra desinstalar: `git config --unset core.hooksPath`. ```bash pytest # padrão — unit + API (sem rede) -pytest -m integration # bate nos endpoints públicos reais +pytest -m integration # manual; também roda no workflow noturno/agendado pytest -m "" # tudo ``` diff --git a/pyproject.toml b/pyproject.toml index 870d722..dd0a354 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -152,6 +152,16 @@ python_version = "3.11" strict = true ignore_missing_imports = true +[tool.coverage.run] +source = ["findata"] +branch = true +omit = ["*/tests/*"] + +[tool.coverage.report] +show_missing = true +skip_covered = true +exclude_lines = ["pragma: no cover", "if TYPE_CHECKING:"] + [[tool.mypy.overrides]] module = ["yfinance", "yfinance.*", "fastapi_mcp", "fastapi_mcp.*"] ignore_missing_imports = true diff --git a/scripts/git/guardrails.sh b/scripts/git/guardrails.sh index c777b22..a3d4691 100755 --- a/scripts/git/guardrails.sh +++ b/scripts/git/guardrails.sh @@ -4,7 +4,7 @@ # Split of responsibility: # - Ruff → formatting + base lint + AI guardrails (complexity, max-args, magic numbers). # - Mypy → strict type checking. -# - Pytest → unit-test fast path (integration tests run on CI). +# - Pytest → unit-test fast path (integration tests run on the scheduled CI workflow). # - ggshield (opt-in) → secret leak detection. set -euo pipefail diff --git a/tests/conftest.py b/tests/conftest.py index f3ad9f2..1559ac2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,10 +18,3 @@ def _clear_cache() -> None: """Ensure a clean HTTP cache between tests.""" http_client.clear_cache() - - -@pytest.fixture -async def _shutdown_http_client() -> None: - """Close the shared httpx client after the test.""" - yield - await http_client.close_client() diff --git a/tests/test_api.py b/tests/test_api.py index 876537c..9389ca9 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -118,29 +118,8 @@ def test_chart_explorer_asset(client: TestClient) -> None: assert "LightweightCharts" in r.text assert "attributionLogo: false" in r.text assert "bcbSeriesEndpoint(432, 24)" in r.text - assert "selic-meta-vs-ibov" in r.text - assert "bcbSeriesEndpoint(4189, 120)" in r.text - assert "Selic Meta vs Ibovespa" in r.text - assert 'get("preset")' in r.text - assert 'priceScaleId: "left"' in r.text - assert "Escalas: Selic à esquerda, Ibovespa à direita" in r.text assert "MAX_POINTS = 5000" in r.text assert "REQUEST_TIMEOUT_MS = 15000" in r.text - assert "new URL(rawEndpoint, window.location.origin)" in r.text - assert "/tesouro/bonds/history" not in r.text - assert 'options.type === "candlestick" || (!options.field && hasOhlc(firstRecord))' in r.text - assert "timestampFromDate" in r.text - assert "isValidDateParts" in r.text - assert "parseCompactPeriod" in r.text - assert "parseUnixTimestamp" in r.text - assert "allowShortSeconds" in r.text - assert "parseUnixTimestamp(text, { allowShortSeconds: true })" in r.text - assert "unixTimestamp !== null" in r.text - assert "dedupeByTime(normalizedTime.data)" in r.text - assert "normalizeMixedTimes" in r.text - assert "if (time !== null)" in r.text - assert "normalizedTime.hasIntraday ? a.time - b.time : a.time.localeCompare(b.time)" in r.text - assert "timeVisible: normalized.hasIntraday" in r.text assert "Yahoo Finance" not in r.text diff --git a/tests/test_api_sources_smoke.py b/tests/test_api_sources_smoke.py new file mode 100644 index 0000000..f2b62a2 --- /dev/null +++ b/tests/test_api_sources_smoke.py @@ -0,0 +1,133 @@ +"""Thin API route smoke tests with all upstream traffic mocked.""" + +from __future__ import annotations + +import re + +import httpx +import pytest +import respx +from fastapi.testclient import TestClient + +from findata.api.app import app +from findata.http_client import clear_cache +from findata.sources.cvm import companies +from findata.sources.tesouro import bonds + + +@pytest.fixture(autouse=True) +def _reset_module_caches() -> None: + clear_cache() + companies._companies_cache.invalidate() + bonds._bonds_cache.invalidate() + + +def _client() -> TestClient: + return TestClient(app) + + +@respx.mock +def test_ptax_usd_route() -> None: + respx.get(re.compile(r"https://olinda\.bcb\.gov\.br/.*/CotacaoDolarDia.*")).mock( + return_value=httpx.Response( + 200, + json={ + "value": [ + { + "cotacaoCompra": 4.91, + "cotacaoVenda": 4.92, + "dataHoraCotacao": "2024-01-02 13:10:00.000", + } + ] + }, + ) + ) + + response = _client().get("/bcb/ptax/usd", params={"date": "2024-01-02"}) + + assert response.status_code == 200 + assert response.json()[0]["cotacao_compra"] == 4.91 + + +@respx.mock +def test_focus_annual_route() -> None: + respx.get(re.compile(r"https://olinda\.bcb\.gov\.br/.*/ExpectativasMercadoAnuais.*")).mock( + return_value=httpx.Response( + 200, + json={ + "value": [ + { + "Indicador": "IPCA", + "Data": "2024-01-02", + "DataReferencia": "2024", + "Media": 3.9, + "Mediana": 3.8, + } + ] + }, + ) + ) + + response = _client().get("/bcb/focus/annual", params={"indicator": "IPCA"}) + + assert response.status_code == 200 + assert response.json()[0]["indicador"] == "IPCA" + + +@respx.mock +def test_ibge_indicator_route() -> None: + respx.get(re.compile(r"https://servicodados\.ibge\.gov\.br/api/v3/agregados/7060/.*")).mock( + return_value=httpx.Response( + 200, + json=[ + { + "variavel": "IPCA - Variação mensal", + "resultados": [ + { + "classificacoes": [], + "series": [ + { + "localidade": {"nome": "Brasil"}, + "serie": {"202401": "0.42"}, + } + ], + } + ], + } + ], + ) + ) + + response = _client().get("/ibge/indicators/ipca_mensal", params={"periods": 1}) + + assert response.status_code == 200 + assert response.json()[0]["periodo"] == "202401" + + +@respx.mock +def test_tesouro_bonds_route() -> None: + csv_data = ( + b"Tipo Titulo;Data Vencimento;Data Base;Taxa Compra Manha;Taxa Venda Manha;" + b"PU Compra Manha;PU Venda Manha;PU Base Manha\n" + b"Tesouro Selic;01/03/2029;02/01/2024;0,10;0,11;100,00;99,00;99,50\n" + ) + respx.get(bonds.TESOURO_CSV_URL).mock(return_value=httpx.Response(200, content=csv_data)) + + response = _client().get("/tesouro/bonds", params={"tipo": "Selic"}) + + assert response.status_code == 200 + assert response.json()[0]["tipo"] == "Tesouro Selic" + + +@respx.mock +def test_cvm_companies_route() -> None: + csv_data = ( + "CNPJ_CIA;DENOM_SOCIAL;DENOM_COMERC;CD_CVM;SIT;SETOR_ATIV;CATEG_REG;CONTROLE_ACIONARIO\n" + "00.000.000/0001-00;Companhia Teste SA;Teste;1234;ATIVO;Financeiro;A;PRIVADO\n" + ).encode("iso-8859-1") + respx.get(companies.COMPANIES_URL).mock(return_value=httpx.Response(200, content=csv_data)) + + response = _client().get("/cvm/companies", params={"only_active": "true"}) + + assert response.status_code == 200 + assert response.json()[0]["nome_social"] == "Companhia Teste SA" diff --git a/tests/test_auth.py b/tests/test_auth.py index c5c1bb1..8b670d5 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -8,7 +8,7 @@ import pytest import respx -from findata.auth import MissingCredentialsError, OAuth2ClientCredentials, OAuth2Token +from findata.auth import AuthError, MissingCredentialsError, OAuth2ClientCredentials, OAuth2Token def test_token_is_expired_with_safety_margin() -> None: @@ -74,7 +74,7 @@ async def test_oauth_failed_token_request_raises() -> None: ) flow = _ANBIMA("cid", "wrong") async with httpx.AsyncClient() as http: - with pytest.raises(Exception): # noqa: B017 — AuthError or subclass is fine + with pytest.raises(AuthError): await flow.get_token(http) diff --git a/tests/test_b3_quotes.py b/tests/test_b3_quotes.py new file mode 100644 index 0000000..2a4a022 --- /dev/null +++ b/tests/test_b3_quotes.py @@ -0,0 +1,101 @@ +"""B3 quote tests with yfinance replaced by deterministic fakes.""" + +from __future__ import annotations + +from datetime import datetime +from types import SimpleNamespace +from typing import Any + +import pytest + +from findata.sources.b3 import quotes + + +@pytest.mark.asyncio +async def test_get_quote_uses_yfinance_ticker(monkeypatch: pytest.MonkeyPatch) -> None: + requested_tickers: list[str] = [] + + class FakeTicker: + def __init__(self, ticker: str) -> None: + requested_tickers.append(ticker) + self.info = { + "longName": "Petroleo Brasileiro S.A.", + "currentPrice": 38.42, + "regularMarketChangePercent": 1.25, + "regularMarketOpen": 37.9, + "regularMarketDayHigh": 38.7, + "regularMarketDayLow": 37.8, + "regularMarketVolume": 12_345_678, + "marketCap": 501_000_000_000, + "sector": "Energy", + "currency": "BRL", + } + + monkeypatch.setattr( + quotes, + "_import_yfinance", + lambda: SimpleNamespace(Ticker=FakeTicker), + ) + + result = await quotes.get_quote("petr4") + + assert requested_tickers == ["PETR4.SA"] + assert result == quotes.StockQuote( + ticker="PETR4", + nome="Petroleo Brasileiro S.A.", + preco=38.42, + variacao_dia=1.25, + abertura=37.9, + maxima=38.7, + minima=37.8, + volume=12_345_678, + market_cap=501_000_000_000, + setor="Energy", + moeda="BRL", + ) + + +@pytest.mark.asyncio +async def test_get_history_maps_yfinance_rows(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[tuple[str, str, str]] = [] + + class FakeHistory: + def iterrows(self) -> Any: + yield ( + datetime(2026, 7, 30), + { + "Open": 37.991, + "High": 38.876, + "Low": 37.554, + "Close": 38.432, + "Volume": 9_876_543.0, + }, + ) + + class FakeTicker: + def __init__(self, ticker: str) -> None: + self.ticker = ticker + + def history(self, *, period: str, interval: str) -> FakeHistory: + calls.append((self.ticker, period, interval)) + return FakeHistory() + + monkeypatch.setattr( + quotes, + "_import_yfinance", + lambda: SimpleNamespace(Ticker=FakeTicker), + ) + + result = await quotes.get_history("vale3.sa", period="5d", interval="1h") + + assert calls == [("VALE3.SA", "5d", "1h")] + assert result == [ + quotes.StockHistoryPoint( + date="2026-07-30", + open=37.99, + high=38.88, + low=37.55, + close=38.43, + volume=9_876_543, + ) + ] diff --git a/tests/test_bcb_ptax_focus.py b/tests/test_bcb_ptax_focus.py new file mode 100644 index 0000000..60f37f4 --- /dev/null +++ b/tests/test_bcb_ptax_focus.py @@ -0,0 +1,169 @@ +"""BCB PTAX and Focus source tests (no network; respx-mocked).""" + +from __future__ import annotations + +import re +from datetime import date + +import httpx +import pytest +import respx + +from findata.http_client import clear_cache +from findata.sources.bcb import focus, ptax + + +@pytest.fixture(autouse=True) +def _reset_http_cache() -> None: + clear_cache() + + +_QUOTE = { + "cotacaoCompra": 4.91, + "cotacaoVenda": 4.92, + "dataHoraCotacao": "2024-01-02 13:10:28.762", +} + + +def _mock_odata(endpoint: str, item: dict[str, object]) -> respx.Route: + return respx.get(re.compile(rf"^{re.escape(endpoint)}(?:\?.*)?$")).mock( + return_value=httpx.Response(200, json={"value": [item]}) + ) + + +@respx.mock +async def test_get_ptax_usd_uses_fixed_date_and_parses_quote() -> None: + route = _mock_odata(f"{ptax.BASE_URL}/CotacaoDolarDia(dataCotacao=@dataCotacao)", _QUOTE) + + quotes = await ptax.get_ptax_usd(date(2024, 1, 2)) + + assert route.called + assert route.calls.last.request.url.params["@dataCotacao"] == "'01-02-2024'" + assert quotes[0].model_dump() == { + "cotacao_compra": 4.91, + "cotacao_venda": 4.92, + "data_hora_cotacao": "2024-01-02 13:10:28.762", + } + + +@respx.mock +async def test_get_ptax_usd_period_sends_both_dates() -> None: + endpoint = ( + f"{ptax.BASE_URL}/CotacaoDolarPeriodo(" + "dataInicial=@dataInicial,dataFinalCotacao=@dataFinalCotacao)" + ) + route = _mock_odata(endpoint, _QUOTE) + + quotes = await ptax.get_ptax_usd_period(date(2024, 1, 2), date(2024, 1, 5)) + + params = route.calls.last.request.url.params + assert params["@dataInicial"] == "'01-02-2024'" + assert params["@dataFinalCotacao"] == "'01-05-2024'" + assert quotes[0].cotacao_venda == 4.92 + + +@respx.mock +async def test_get_ptax_currency_normalizes_symbol() -> None: + endpoint = f"{ptax.BASE_URL}/CotacaoMoedaDia(moeda=@moeda,dataCotacao=@dataCotacao)" + route = _mock_odata(endpoint, _QUOTE) + + quotes = await ptax.get_ptax_currency("eur", date(2024, 1, 2)) + + params = route.calls.last.request.url.params + assert params["@moeda"] == "'EUR'" + assert params["@dataCotacao"] == "'01-02-2024'" + assert quotes[0].cotacao_compra == 4.91 + + +@respx.mock +async def test_get_currencies_parses_olinda_fields() -> None: + route = _mock_odata( + f"{ptax.BASE_URL}/Moedas", + {"simbolo": "EUR", "nomeFormatado": "Euro", "tipoMoeda": "B"}, + ) + + currencies = await ptax.get_currencies() + + assert route.called + assert currencies[0].model_dump() == { + "simbolo": "EUR", + "nome": "Euro", + "tipo_moeda": "B", + } + + +_EXPECTATION = { + "Indicador": "IPCA", + "Data": "2024-01-02", + "DataReferencia": "2024", + "Media": 3.91, + "Mediana": 3.90, + "DesvioPadrao": 0.42, + "Minimo": 3.10, + "Maximo": 4.80, + "numeroRespondentes": 123, + "baseCalculo": 0, +} + + +@pytest.mark.parametrize( + ("fetch", "endpoint"), + [ + (focus.get_focus_annual, "ExpectativasMercadoAnuais"), + (focus.get_focus_monthly, "ExpectativaMercadoMensais"), + (focus.get_focus_top5_annual, "ExpectativasMercadoTop5Anuais"), + ], +) +@respx.mock +async def test_get_focus_expectations_parse_and_filter(fetch: object, endpoint: str) -> None: + route = _mock_odata(f"{focus.BASE_URL}/{endpoint}", _EXPECTATION) + + rows = await fetch("ipca", top=5) # type: ignore[operator] + + params = route.calls.last.request.url.params + assert params["$top"] == "5" + assert params["$orderby"] == "Data desc" + assert params["$filter"] == "Indicador eq 'IPCA'" + assert rows[0].model_dump() == { + "indicador": "IPCA", + "data": "2024-01-02", + "data_referencia": "2024", + "media": 3.91, + "mediana": 3.90, + "desvio_padrao": 0.42, + "minimo": 3.10, + "maximo": 4.80, + "numero_respondentes": 123, + "base_calculo": 0, + } + + +@respx.mock +async def test_get_focus_selic_parses_meeting_expectation() -> None: + route = _mock_odata( + f"{focus.BASE_URL}/ExpectativasMercadoSelic", + { + "Indicador": "Selic", + "Data": "2024-01-02", + "Reuniao": "R1/2024", + "Media": 11.75, + "Mediana": 11.75, + "Minimo": 11.50, + "Maximo": 12.00, + }, + ) + + rows = await focus.get_focus_selic(top=5) + + params = route.calls.last.request.url.params + assert params["$top"] == "5" + assert params["$orderby"] == "Data desc" + assert rows[0].model_dump() == { + "indicador": "Selic", + "data": "2024-01-02", + "reuniao": "R1/2024", + "media": 11.75, + "mediana": 11.75, + "minimo": 11.50, + "maximo": 12.00, + } diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..8e2d7fc --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,52 @@ +"""CLI smoke tests with network access mocked.""" + +from __future__ import annotations + +import httpx +import respx +from typer.testing import CliRunner + +from findata import __version__ +from findata.cli import app + +runner = CliRunner() + + +def test_help_exits_successfully() -> None: + result = runner.invoke(app, ["--help"]) + + assert result.exit_code == 0 + assert "bcb" in result.stdout + + +def test_bcb_series_prints_catalog() -> None: + result = runner.invoke(app, ["bcb", "series"]) + + assert result.exit_code == 0 + assert "BCB Series Catalog" in result.stdout + assert "selic" in result.stdout + + +def test_version_exits_successfully() -> None: + result = runner.invoke(app, ["--version"]) + + assert result.exit_code == 0 + assert __version__ in result.stdout + + +@respx.mock +def test_bcb_get_uses_mocked_api() -> None: + url = "https://api.bcb.gov.br/dados/serie/bcdata.sgs.432/dados/ultimos/1" + respx.get(url, params={"formato": "json"}).mock( + return_value=httpx.Response( + 200, + json=[{"data": "30/07/2026", "valor": "15.0000"}], + ) + ) + + result = runner.invoke(app, ["bcb", "get", "selic", "--last", "1"]) + + assert result.exit_code == 0 + assert "BCB: selic" in result.stdout + assert "30/07/2026" in result.stdout + assert "15.0000" in result.stdout diff --git a/tests/test_cvm_companies_financials.py b/tests/test_cvm_companies_financials.py new file mode 100644 index 0000000..1fba43c --- /dev/null +++ b/tests/test_cvm_companies_financials.py @@ -0,0 +1,103 @@ +"""CVM company registration and annual financial statement tests.""" + +from __future__ import annotations + +import io +import re +import zipfile + +import httpx +import pytest +import respx + +from findata.http_client import clear_cache +from findata.sources.cvm.companies import ( + COMPANIES_URL, + _companies_cache, + get_companies, + search_company, +) +from findata.sources.cvm.financials import StatementType, get_dfp + + +@pytest.fixture(autouse=True) +def _clean_caches() -> None: + clear_cache() + _companies_cache.invalidate() + + +_COMPANIES_CSV = ( + "CNPJ_CIA;DENOM_SOCIAL;DENOM_COMERC;CD_CVM;SIT;SETOR_ATIV;CATEG_REG;" + "CONTROLE_ACIONARIO\n" + "33.592.510/0001-54;PETROLEO BRASILEIRO S.A. - PETROBRAS;PETROBRAS;9512;" + "ATIVO;Petróleo e Gás;Categoria A;Estatal\n" + "00.000.000/0001-91;BANCO ANTIGO S.A.;BANCO ANTIGO;1023;CANCELADA;Bancos;" + "Categoria A;Privado\n" +) + + +@respx.mock +async def test_get_companies_active_and_inactive() -> None: + respx.get(COMPANIES_URL).mock( + return_value=httpx.Response(200, content=_COMPANIES_CSV.encode("iso-8859-1")) + ) + + active = await get_companies(only_active=True) + all_companies = await get_companies(only_active=False) + + assert len(active) == 1 + assert active[0].nome_comercial == "PETROBRAS" + assert {company.situacao for company in all_companies} == {"ATIVO", "CANCELADA"} + + +@respx.mock +async def test_search_company_by_social_or_commercial_name() -> None: + respx.get(COMPANIES_URL).mock( + return_value=httpx.Response(200, content=_COMPANIES_CSV.encode("iso-8859-1")) + ) + + by_social_name = await search_company("petroleo") + by_commercial_name = await search_company("banco antigo", only_active=False) + + assert [company.cnpj for company in by_social_name] == ["33.592.510/0001-54"] + assert [company.cnpj for company in by_commercial_name] == ["00.000.000/0001-91"] + + +_DFP_CSV = ( + "CNPJ_CIA;DENOM_CIA;CD_CVM;DT_REFER;VERSAO;CD_CONTA;DS_CONTA;VL_CONTA;" + "MOEDA;ESCALA_MOEDA\n" + "33.592.510/0001-54;PETROBRAS;9512;2024-12-31;1;3.01;Receita de Venda;" + "not-a-number;REAL;MIL\n" + "33.592.510/0001-54;PETROBRAS;9512;2024-12-31;1;3.02;Custo dos Produtos;" + "-250.5;REAL;MIL\n" + "00.000.000/0001-91;BANCO DO BRASIL;1023;2024-12-31;1;3.01;Receita;" + "900;REAL;MIL\n" +) + + +def _make_dfp_zip() -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr( + "dfp_cia_aberta_DRE_con_2024.csv", + _DFP_CSV.encode("iso-8859-1"), + ) + return buffer.getvalue() + + +@respx.mock +async def test_get_dfp_filters_cnpj_and_defaults_invalid_value_to_zero() -> None: + respx.get(re.compile(r"https://.*dfp_cia_aberta_2024\.zip")).mock( + return_value=httpx.Response(200, content=_make_dfp_zip()) + ) + + rows = await get_dfp( + 2024, + statement=StatementType.DRE_CON, + cnpj="33.592.510/0001-54", + ) + + assert len(rows) == 2 + assert all(row.cnpj == "33.592.510/0001-54" for row in rows) + assert rows[0].valor == 0.0 + assert rows[1].valor == -250.5 diff --git a/tests/test_http_client.py b/tests/test_http_client.py index 767e3d0..fc82d46 100644 --- a/tests/test_http_client.py +++ b/tests/test_http_client.py @@ -72,6 +72,14 @@ def test_should_retry_classifies_errors() -> None: exc = httpx.HTTPStatusError("x", request=httpx.Request("GET", "http://x"), response=resp) assert http_client._should_retry(exc) is True + resp_429 = httpx.Response(status_code=429) + exc_429 = httpx.HTTPStatusError( + "x", + request=httpx.Request("GET", "http://x"), + response=resp_429, + ) + assert http_client._should_retry(exc_429) is True + resp_404 = httpx.Response(status_code=404) exc_404 = httpx.HTTPStatusError( "x", @@ -144,3 +152,23 @@ async def test_get_bytes_scopes_cache_by_max_bytes() -> None: with pytest.raises(ValueError, match="download exceeds max_bytes=3"): await http_client.get_bytes("https://example.test/file", max_bytes=3) assert route.call_count == 2 + + +@respx.mock +async def test_get_json_retries_on_429_then_succeeds(monkeypatch: pytest.MonkeyPatch) -> None: + http_client.clear_cache() + route = respx.get("https://example.test/rate").mock( + side_effect=[ + httpx.Response(429), + httpx.Response(200, json={"ok": True}), + ] + ) + + async def no_sleep(_seconds: float) -> None: + return None + + monkeypatch.setattr(asyncio, "sleep", no_sleep) + data = await http_client.get_json("https://example.test/rate") + + assert data == {"ok": True} + assert route.call_count == 2 diff --git a/tests/test_ibge.py b/tests/test_ibge.py new file mode 100644 index 0000000..ad33341 --- /dev/null +++ b/tests/test_ibge.py @@ -0,0 +1,93 @@ +"""IBGE Agregados source tests (no network; respx-mocked).""" + +from __future__ import annotations + +import re + +import httpx +import pytest +import respx + +from findata.http_client import clear_cache +from findata.sources.ibge import indicators + + +@pytest.fixture(autouse=True) +def _reset_http_cache() -> None: + clear_cache() + + +def _ibge_payload(*, category: dict[str, str] | None = None) -> list[dict[str, object]]: + classifications = [] + if category is not None: + classifications = [{"id": "315", "nome": "Geral, grupo", "categoria": category}] + return [ + { + "id": "63", + "variavel": "IPCA - Variação mensal", + "resultados": [ + { + "classificacoes": classifications, + "series": [ + { + "localidade": {"id": "1", "nome": "Brasil"}, + "serie": {"202312": "0.56", "202401": "..."}, + } + ], + } + ], + } + ] + + +@respx.mock +async def test_get_indicator_parses_series_and_missing_value() -> None: + endpoint = f"{indicators.BASE_URL}/7060/periodos/-2/variaveis/63" + route = respx.get(re.compile(rf"^{re.escape(endpoint)}(?:\?.*)?$")).mock( + return_value=httpx.Response(200, json=_ibge_payload()) + ) + + rows = await indicators.get_indicator("ipca_mensal", periods=2) + + assert route.calls.last.request.url.params["localidades"] == "N1[all]" + assert len(rows) == 2 + assert set(rows[0].model_dump()) == { + "periodo", + "valor", + "localidade", + "variavel", + "classificacao", + } + assert rows[0].model_dump() == { + "periodo": "202312", + "valor": 0.56, + "localidade": "Brasil", + "variavel": "IPCA - Variação mensal", + "classificacao": None, + } + assert rows[1].valor is None + + +@respx.mock +async def test_get_ipca_breakdown_sends_groups_and_parses_classification() -> None: + endpoint = f"{indicators.BASE_URL}/7060/periodos/-2/variaveis/63" + route = respx.get(re.compile(rf"^{re.escape(endpoint)}(?:\?.*)?$")).mock( + return_value=httpx.Response( + 200, + json=_ibge_payload(category={"7170": "1.Alimentação e bebidas"}), + ) + ) + + rows = await indicators.get_ipca_breakdown(periods=2, groups=["7170", "7445"]) + + params = route.calls.last.request.url.params + assert params["localidades"] == "N1[all]" + assert params["classificacao"] == "315[7170,7445]" + assert set(rows[0].model_dump()) == { + "periodo", + "valor", + "localidade", + "variavel", + "classificacao", + } + assert rows[0].classificacao == "1.Alimentação e bebidas" diff --git a/tests/test_limits.py b/tests/test_limits.py new file mode 100644 index 0000000..59b1ae2 --- /dev/null +++ b/tests/test_limits.py @@ -0,0 +1,71 @@ +"""Unit and exception-path tests for API rate limiting.""" + +from __future__ import annotations + +import pytest +from fastapi import Request +from limits import parse +from slowapi.wrappers import Limit + +from findata import _limits +from findata.api.app import app + + +def _request(*, headers: list[tuple[bytes, bytes]] | None = None) -> Request: + return Request( + { + "type": "http", + "method": "GET", + "path": "/", + "headers": headers or [], + "client": ("192.0.2.10", 1234), + "server": ("testserver", 80), + "scheme": "http", + "query_string": b"", + "app": app, + } + ) + + +def test_client_id_prefers_first_forwarded_address() -> None: + request = _request(headers=[(b"x-forwarded-for", b"198.51.100.7, 203.0.113.9")]) + assert _limits._client_id(request) == "198.51.100.7" + + +def test_client_id_falls_back_to_remote_address() -> None: + assert _limits._client_id(_request()) == "192.0.2.10" + + +@pytest.mark.parametrize("value", ["1", "true", "YES", "on"]) +def test_limits_enabled_accepts_truthy_values( + monkeypatch: pytest.MonkeyPatch, + value: str, +) -> None: + monkeypatch.setenv("FINDATA_RATE_LIMIT_ENABLED", value) + assert _limits._limits_enabled() is True + + +def test_limits_enabled_rejects_other_values(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FINDATA_RATE_LIMIT_ENABLED", "false") + assert _limits._limits_enabled() is False + + +def test_default_limits_reads_and_splits_environment(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FINDATA_RATE_LIMIT_DEFAULT", " 2/second ; 10/minute; ") + assert _limits._default_limits() == ["2/second", "10/minute"] + + +def test_rate_limit_exception_handler_returns_429_json() -> None: + """Exercise the registered handler without mutating the global app routes.""" + rate_item = parse("1/minute") + limit = Limit(rate_item, _limits._client_id, None, False, None, None, None, 1, False) + request = _request(headers=[(b"x-forwarded-for", b"198.51.100.211")]) + request.state.view_rate_limit = (rate_item, ["198.51.100.211"]) + + response = _limits._rate_limit_exceeded_handler( + request, + _limits.RateLimitExceeded(limit), + ) + + assert response.status_code == 429 + assert b"Rate limit exceeded:" in response.body diff --git a/tests/test_tesouro_bonds.py b/tests/test_tesouro_bonds.py new file mode 100644 index 0000000..40ee520 --- /dev/null +++ b/tests/test_tesouro_bonds.py @@ -0,0 +1,84 @@ +"""Tesouro Direto historical price and rate tests.""" + +from __future__ import annotations + +from datetime import date + +import httpx +import pytest +import respx + +from findata.http_client import clear_cache +from findata.sources.tesouro.bonds import ( + TESOURO_CSV_URL, + _bonds_cache, + get_bond_history, + get_treasury_bonds, + search_bonds, +) + + +@pytest.fixture(autouse=True) +def _clean_caches() -> None: + clear_cache() + _bonds_cache.invalidate() + + +_TESOURO_CSV = ( + "Tipo Titulo;Data Vencimento;Data Base;Taxa Compra Manha;Taxa Venda Manha;" + "PU Compra Manha;PU Venda Manha;PU Base Manha\n" + "Tesouro Selic;01/03/2029;02/01/2024;0,15;0,16;14500,25;14490,10;14510,00\n" + "Tesouro IPCA+;15/05/2035;02/01/2024;5,50;5,60;2500,00;2490,00;2510,00\n" + "Tesouro IPCA+;15/05/2035;03/01/2024;5,45;5,55;2510,00;2500,00;2520,00\n" +) + + +def _mock_tesouro_csv() -> None: + respx.get(TESOURO_CSV_URL).mock( + return_value=httpx.Response(200, content=_TESOURO_CSV.encode("utf-8")) + ) + + +@respx.mock +async def test_get_treasury_bonds_parses_and_filters_rows() -> None: + _mock_tesouro_csv() + + rows = await get_treasury_bonds( + tipo="selic", + start=date(2024, 1, 2), + end=date(2024, 1, 2), + ) + + assert len(rows) == 1 + bond = rows[0] + assert bond.titulo == "Tesouro Selic 2029" + assert bond.dt_vencimento == "2029-03-01" + assert bond.dt_base == "2024-01-02" + assert bond.taxa_compra == 0.15 + assert bond.pu_base == 14510.0 + + +@respx.mock +async def test_get_bond_history_returns_matching_date_range() -> None: + _mock_tesouro_csv() + + rows = await get_bond_history( + "ipca+ 2035", + start=date(2024, 1, 3), + end=date(2024, 1, 3), + ) + + assert len(rows) == 1 + assert rows[0].dt_base == "2024-01-03" + assert rows[0].taxa_venda == 5.55 + assert rows[0].pu_compra == 2510.0 + + +@respx.mock +async def test_search_bonds_returns_unique_sorted_titles() -> None: + _mock_tesouro_csv() + + assert await search_bonds("tesouro") == [ + "Tesouro IPCA+ 2035", + "Tesouro Selic 2029", + ]