diff --git a/agenda/fx.py b/agenda/fx.py index 9a65338..28f7e26 100644 --- a/agenda/fx.py +++ b/agenda/fx.py @@ -150,7 +150,7 @@ def get_rates_exchangerate_host(config: flask.config.Config) -> dict[str, Decima currency_string = ",".join(sorted(currencies)) file_suffix = f"{currency_string}_to_GBP.json" existing_data = os.listdir(fx_dir) - existing_files = [f for f in existing_data if f.endswith(file_suffix)] + existing_files = [f for f in existing_data if f[17:] == file_suffix] latest_attempt = _latest_file(fx_dir, existing_files, currencies, valid_only=False) latest_valid = _latest_file(fx_dir, existing_files, currencies, valid_only=True) @@ -222,9 +222,17 @@ def get_rates(config: flask.config.Config) -> dict[str, Decimal]: valid_cache_files = [ f for f in existing_data - if f.endswith(legacy_file_suffix) or f.endswith(frankfurter_file_suffix) + if f[17:] in (legacy_file_suffix, frankfurter_file_suffix) ] - attempt_files = [f for f in existing_data if f.endswith(frankfurter_file_suffix)] + attempt_files = [f for f in existing_data if f[17:] == frankfurter_file_suffix] + # Cache filenames already record the requested currencies. Compare against + # the newest request, including failed attempts, so failures retain backoff. + all_rate_files = [f for f in existing_data if f.endswith("_to_GBP.json")] + latest_request = max(all_rate_files) if all_rate_files else None + currencies_changed = latest_request is not None and latest_request[17:] not in ( + legacy_file_suffix, + frankfurter_file_suffix, + ) latest_attempt = _latest_file(fx_dir, attempt_files, currencies, valid_only=False) latest_source_valid = _latest_file( @@ -238,7 +246,7 @@ def get_rates(config: flask.config.Config) -> dict[str, Decimal]: if config["OFFLINE_MODE"]: return read_cached_rates(latest_valid_path, currencies) - if latest_source_valid is not None: + if latest_source_valid is not None and not currencies_changed: recent = _fx_cache_datetime(latest_source_valid) delta = now - recent @@ -247,7 +255,7 @@ def get_rates(config: flask.config.Config) -> dict[str, Decimal]: os.path.join(fx_dir, latest_source_valid), currencies ) - if latest_attempt is not None: + if latest_attempt is not None and not currencies_changed: recent_attempt = _fx_cache_datetime(latest_attempt) attempt_delta = now - recent_attempt if attempt_delta < timedelta(hours=failure_retry_hours): diff --git a/tests/test_fx_currency_changes.py b/tests/test_fx_currency_changes.py new file mode 100644 index 0000000..54e079e --- /dev/null +++ b/tests/test_fx_currency_changes.py @@ -0,0 +1,93 @@ +"""Currency configuration changes must bypass the FX cache interval.""" + +import json +from datetime import datetime +from pathlib import Path +from unittest.mock import patch + +import flask +import pytest + +from agenda.fx import get_rates + + +@pytest.mark.parametrize( + ("previous", "current", "fetch"), + [ + (["USD"], ["USD", "EUR"], True), + (["EUR", "USD"], ["USD"], True), + (["EUR", "USD"], ["USD", "EUR"], False), + ], +) +def test_currency_changes( + tmp_path: Path, previous: list[str], current: list[str], fetch: bool +) -> None: + """Fetch additions/removals even if an older matching cache is still fresh.""" + fx_dir = tmp_path / "fx" + fx_dir.mkdir() + + def cache(timestamp: str, currencies: list[str]) -> None: + suffix = ",".join(sorted(currencies)) + filename = fx_dir / f"{timestamp}_frankfurter_{suffix}_to_GBP.json" + filename.write_text( + json.dumps([{"quote": c, "rate": 1.25} for c in currencies]) + ) + + cache("2026-10-01_10:00", current) + cache("2026-10-01_11:00", previous) + config = flask.config.Config( + "/", + { + "CURRENCIES": current, + "DATA_DIR": str(tmp_path), + "OFFLINE_MODE": False, + "FX_CACHE_TTL_HOURS": 12, + }, + ) + with ( + patch("agenda.fx.datetime") as clock, + patch("agenda.fx.httpx.Client") as client, + ): + clock.now.return_value = datetime(2026, 10, 1, 12) + clock.strptime.side_effect = datetime.strptime + get = client.return_value.__enter__.return_value.get + get.return_value.text = json.dumps([{"quote": c, "rate": 1.3} for c in current]) + result = get_rates(config) + assert set(result) == set(current) + assert get.call_count == int(fetch) + # The same configuration must use the newly fetched cache. + get_rates(config) + assert get.call_count == int(fetch) + + +def test_changed_currencies_preserve_failure_backoff(tmp_path: Path) -> None: + """A failed fetch for the new list should not be retried on every cron run.""" + fx_dir = tmp_path / "fx" + fx_dir.mkdir() + (fx_dir / "2026-10-01_11:00_frankfurter_EUR,USD_to_GBP.json").write_text( + json.dumps([{"quote": "EUR", "rate": 1.15}, {"quote": "USD", "rate": 1.25}]) + ) + config = flask.config.Config( + "/", {"CURRENCIES": ["USD"], "DATA_DIR": str(tmp_path), "OFFLINE_MODE": False} + ) + with ( + patch("agenda.fx.datetime") as clock, + patch("agenda.fx.httpx.Client") as client, + ): + clock.now.return_value = datetime(2026, 10, 1, 12) + clock.strptime.side_effect = datetime.strptime + get = client.return_value.__enter__.return_value.get + get.return_value.text = "[]" + assert get_rates(config) == {} + assert get_rates(config) == {} + assert get.call_count == 1 + + +def test_currency_changes_respect_offline_mode(tmp_path: Path) -> None: + """Changing configuration must not trigger network requests when offline.""" + config = flask.config.Config( + "/", {"CURRENCIES": ["USD"], "DATA_DIR": str(tmp_path), "OFFLINE_MODE": True} + ) + with patch("agenda.fx.httpx.Client") as client: + assert get_rates(config) == {} + client.assert_not_called()