"""FRED connector — offline tests on recorded /series + /series/observations payloads, plus a live smoke test.""" from __future__ import annotations import json from datetime import date import pytest from countryatlas.connectors import fred as fred_mod from countryatlas.connectors._util import ConnectorError from countryatlas.connectors.fred import FREDConnector, parse_fred_datetime, redact from countryatlas.models import IndicatorSourceSpec from tests.connectors._helpers import make_response, raw_from_file KEY = "0123456789abcdef0123456789abcdef" def _series_meta(fixtures_dir, code): return json.load(open(fixtures_dir / "fred" / f"series_{code}.json"))["seriess"][0] @pytest.fixture def conn(monkeypatch): monkeypatch.setattr(fred_mod.settings, "fred_api_key", KEY) c = FREDConnector() c.MIN_SPACING_S = 0.0 # no pacing in unit tests return c def test_redaction_and_datetime_parsing(): assert redact(f"https://x/fred/series?series_id=A&api_key={KEY}&file_type=json") == \ "https://x/fred/series?series_id=A&api_key=&file_type=json" dt = parse_fred_datetime("2026-09-01 15:16:43-05") assert dt is not None and dt.utcoffset().total_seconds() == -5 * 3600 and dt.year == 2026 assert parse_fred_datetime("2026-09-01").tzinfo is not None def test_missing_key_raises(monkeypatch): monkeypatch.setattr(fred_mod.settings, "fred_api_key", None) c = FREDConnector() with pytest.raises(ConnectorError, match="FRED_API_KEY"): c.get("https://api.stlouisfed.org/fred/series", params={"series_id": "FEDFUNDS"}) def test_fetch_adds_key_aggregates_weekly_and_redacts_url(conn, fixtures_dir, monkeypatch): series = (fixtures_dir / "fred" / "series_MORTGAGE30US.json").read_bytes() obs = (fixtures_dir / "fred" / "obs_MORTGAGE30US_m.json").read_bytes() calls: list[dict] = [] # patch the underlying Connector.get (the FRED override adds the key + pacing on top of it) import countryatlas.connectors.base as base_mod def _base_get(self, url, params=None, headers=None): calls.append({"url": url, "params": dict(params or {})}) body = series if url.endswith("/series") else obs return make_response(url, body, "application/json", params) monkeypatch.setattr(base_mod.Connector, "get", _base_get) spec = IndicatorSourceSpec(indicator_id="mortgage-rate", connector="fred", dataset="FRED", code="MORTGAGE30US", countries=["USA"], frequency="M", priority=1) p = conn.fetch(spec) assert calls[0]["params"]["api_key"] == KEY and calls[0]["params"]["file_type"] == "json" assert calls[1]["params"]["frequency"] == "m" and calls[1]["params"]["aggregation_method"] == "avg" assert calls[1]["params"]["observation_start"] == "1950-01-01" assert KEY not in p.url and "api_key=" in p.url assert KEY not in json.dumps(p.meta) assert p.source_updated_at is not None and p.source_updated_at.year == 2026 assert p.meta["series_meta"]["frequency_short"] == "W" rows = conn.normalize(p, spec) assert [(r.period.isoformat(), r.value) for r in rows] == [("2026-05-01", 6.44), ("2026-06-01", 6.49), ("2026-07-01", 6.54), ("2026-08-01", 6.67)] assert all(r.frequency == "M" and r.country_id == "USA" for r in rows) # the "." (incomplete Sept) is skipped assert rows[0].metadata["aggregated_from"] == "W" def test_normalize_monthly_series_and_missing_dot(conn, fixtures_dir): spec = IndicatorSourceSpec(indicator_id="policy-rate", connector="fred", dataset="FRED", code="FEDFUNDS", countries=["USA"], frequency="M", priority=2) raw = raw_from_file("fred", "FRED", "FEDFUNDS", fixtures_dir / "fred" / "obs_FEDFUNDS.json", "application/json", series_meta=_series_meta(fixtures_dir, "FEDFUNDS"), request={"series_id": "FEDFUNDS"}) rows = conn.normalize(raw, spec) assert len(rows) == 6 and rows[0].period == date(2024, 1, 1) and rows[0].frequency == "M" assert rows[0].unit == "% per annum" and rows[0].value == pytest.approx(5.33) # inject a missing value doc = json.loads(raw.body) doc["observations"].append({"date": "2024-07-01", "value": "."}) raw.body = json.dumps(doc).encode() assert len(conn.normalize(raw, spec)) == 6 def test_yoy_pct_transform_computes_inflation(conn, fixtures_dir): spec = IndicatorSourceSpec(indicator_id="inflation", connector="fred", dataset="FRED", code="CPIAUCSL", countries=["USA"], frequency="M", priority=3, transform="yoy_pct") raw = raw_from_file("fred", "FRED", "CPIAUCSL", fixtures_dir / "fred" / "obs_CPIAUCSL.json", "application/json", series_meta=_series_meta(fixtures_dir, "CPIAUCSL"), request={"series_id": "CPIAUCSL"}) obs = {o["date"]: float(o["value"]) for o in json.loads(raw.body)["observations"]} rows = conn.normalize(raw, spec) # 2024 has no predecessor in the fixture → only 2025-01..03 remain assert [r.period.isoformat() for r in rows] == ["2025-01-01", "2025-02-01", "2025-03-01"] expected = (obs["2025-01-01"] / obs["2024-01-01"] - 1) * 100 assert rows[0].value == pytest.approx(expected) assert rows[0].unit == "annual %" and rows[0].metadata["transform"] == "yoy_pct" def test_bis_series_derives_country_and_rebases(conn, fixtures_dir): spec = IndicatorSourceSpec(indicator_id="real-house-price-index", connector="fred", dataset="FRED", code="QCAR628BIS", frequency="Q", priority=3, transform="rebase:2015") assert FREDConnector.country_for(spec) == "CAN" assert FREDConnector.country_for(spec.model_copy(update={"code": "QXMR628BIS"})) is None # euro area raw = raw_from_file("fred", "FRED", "QCAR628BIS", fixtures_dir / "fred" / "obs_QCAR628BIS.json", "application/json", series_meta=_series_meta(fixtures_dir, "QCAR628BIS"), request={"series_id": "QCAR628BIS"}) rows = conn.normalize(raw, spec) assert all(r.country_id == "CAN" and r.frequency == "Q" for r in rows) y2015 = [r.value for r in rows if r.year == 2015] assert len(y2015) == 4 and sum(y2015) / 4 == pytest.approx(100.0) assert rows[0].period == date(2015, 1, 1) and rows[1].period == date(2015, 4, 1) assert rows[0].unit == "index (2015 = 100)" def test_quarterly_and_annual_periods(conn, fixtures_dir): meta = _series_meta(fixtures_dir, "FEDFUNDS") body = json.dumps({"observations": [{"date": "2025-04-01", "value": "65.1"}, {"date": "2025-07-01", "value": "65.3"}]}).encode() raw = raw_from_file("fred", "FRED", "RHORUSQ156N", fixtures_dir / "fred" / "obs_FEDFUNDS.json", "application/json", series_meta={**meta, "frequency_short": "Q"}, request={}) raw.body = body spec = IndicatorSourceSpec(indicator_id="homeownership-rate", connector="fred", dataset="FRED", code="RHORUSQ156N", countries=["USA"], frequency="Q") rows = conn.normalize(raw, spec) assert [(r.period, r.frequency) for r in rows] == [(date(2025, 4, 1), "Q"), (date(2025, 7, 1), "Q")] raw.meta["series_meta"]["frequency_short"] = "A" raw.body = json.dumps({"observations": [{"date": "2024-01-01", "value": "83730"}]}).encode() spec = spec.model_copy(update={"indicator_id": "median-household-income", "code": "MEHOINUSA672N", "frequency": "A"}) rows = conn.normalize(raw, spec) assert rows[0].period == date(2024, 1, 1) and rows[0].frequency == "A" and rows[0].year == 2024 @pytest.mark.live def test_live_fedfunds(monkeypatch): import os if not os.environ.get("FRED_API_KEY"): pytest.skip("FRED_API_KEY not set") monkeypatch.setattr(fred_mod.settings, "fred_api_key", os.environ["FRED_API_KEY"]) conn = FREDConnector() spec = IndicatorSourceSpec(indicator_id="policy-rate", connector="fred", dataset="FRED", code="FEDFUNDS", countries=["USA"], frequency="M", params={"observation_start": "2024-01-01"}) rows = conn.normalize(conn.fetch(spec), spec) assert len(rows) >= 20 and rows[0].country_id == "USA"