From 62d4573b9213213352dcb1d3a3df32b02f2c7341 Mon Sep 17 00:00:00 2001 From: Claude Perrin Date: Sat, 20 Jun 2026 08:01:14 +0200 Subject: [PATCH 1/2] [14.0][FIX] currency_rate_update_xe: send User-Agent, raise on HTTP error, mock tests xe.com now returns HTTP 403 for requests without a User-Agent, and HTTP 404 for the dated endpoint (?date=YYYY-MM-DD) when the date is today or in the future. The scheduled run always ends on today, so test_cron / test_wizard failed with "0 != 1" (no rate produced). Backport to 14.0 of the fix from #216 (16.0) and #218 (17.0): - _request_data: send a User-Agent header, add a request timeout and raise_for_status(), so an HTTP error surfaces as a clean UserError instead of being silently parsed as empty HTML; - _get_historical_rate: fall back to the latest endpoint (no ?date=) for today and future days, as _get_latest_rate already does. The tests are also made hermetic: the HTTP layer is mocked (no real call to xe.com), covering the latest/historical fallback, the HTML parsing and the HTTP-error path. The test no longer depends on the live website, which is the root cause of the recurring CI failures raised in the comments of #216 and #218. Signed-off-by: Claude Perrin --- currency_rate_update_xe/__manifest__.py | 2 +- .../models/res_currency_rate_provider_XE.py | 20 ++++- .../tests/test_currency_rate_update_xe.py | 80 ++++++++++++++++++- 3 files changed, 97 insertions(+), 5 deletions(-) diff --git a/currency_rate_update_xe/__manifest__.py b/currency_rate_update_xe/__manifest__.py index 821f85fd2..0e64a616b 100644 --- a/currency_rate_update_xe/__manifest__.py +++ b/currency_rate_update_xe/__manifest__.py @@ -3,7 +3,7 @@ { "name": "Currency Rate Update: XE.com", - "version": "14.0.1.0.0", + "version": "14.0.1.0.1", "category": "Financial Management/Configuration", "summary": "Update exchange rates using XE.com", "author": "Tecnativa, Odoo Community Association (OCA)", diff --git a/currency_rate_update_xe/models/res_currency_rate_provider_XE.py b/currency_rate_update_xe/models/res_currency_rate_provider_XE.py index b18a2abc2..008529973 100644 --- a/currency_rate_update_xe/models/res_currency_rate_provider_XE.py +++ b/currency_rate_update_xe/models/res_currency_rate_provider_XE.py @@ -241,8 +241,16 @@ def _get_historical_rate( """Get all the exchange rates from 'date_from' to 'date_to'""" content = {} current_date = date_from + today = date.today() while current_date <= date_to: - url = f"{base_url}/?from={base_currency}&date={current_date.strftime('%Y-%m-%d')}" + if current_date >= today: + # XE.com returns HTTP 404 for ``?date=YYYY-MM-DD`` when the + # requested date is today or in the future; fall back to the + # latest endpoint (same URL as ``_get_latest_rate``). + url = f"{base_url}/?from={base_currency}" + else: + day = current_date.strftime("%Y-%m-%d") + url = f"{base_url}/?from={base_currency}&date={day}" data = self._request_data(url) content[current_date] = self._parse_data(data, currencies) current_date += timedelta(days=1) @@ -253,7 +261,15 @@ def _request_data( url, ): try: - return requests.request("GET", url) + # XE.com returns HTTP 403 to requests without a User-Agent header. + response = requests.request( + "GET", + url, + timeout=10, + headers={"User-Agent": "Mozilla/5.0"}, + ) + response.raise_for_status() + return response except Exception as e: raise UserError( _("Couldn't fetch data. Please contact your administrator.") diff --git a/currency_rate_update_xe/tests/test_currency_rate_update_xe.py b/currency_rate_update_xe/tests/test_currency_rate_update_xe.py index 04535a7e0..21e7f98f3 100644 --- a/currency_rate_update_xe/tests/test_currency_rate_update_xe.py +++ b/currency_rate_update_xe/tests/test_currency_rate_update_xe.py @@ -1,10 +1,44 @@ # Copyright 2023 Tecnativa - Ernesto Tejeda # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from unittest import mock + +import requests +from dateutil.relativedelta import relativedelta from odoo import fields +from odoo.exceptions import UserError from odoo.tests import common +from ..models import res_currency_rate_provider_XE as xe_module + +_PROVIDER = ( + "odoo.addons.currency_rate_update_xe.models" + ".res_currency_rate_provider_XE.ResCurrencyRateProviderXE" +) +_REQUEST_DATA = _PROVIDER + "._request_data" + +# Minimal XE.com currency-table HTML matching the provider's xpath +# (//div[@id='table-section']//tbody/tr ; th = currency code ; td[2] = rate). +_XE_HTML = b""" +
+ + +
USDUS Dollar0.920000
GBPBritish Pound1.170000
+""" + + +class _FakeResponse: + """Minimal stand-in for a ``requests`` response (no real network call).""" + + def __init__(self, content=_XE_HTML, status_code=200): + self.content = content + self.status_code = status_code + + def raise_for_status(self): + if self.status_code >= 400: + raise requests.exceptions.HTTPError(self.status_code) + class TestResCurrencyRateProviderXE(common.SavepointCase): @classmethod @@ -35,7 +69,12 @@ def setUpClass(cls): cls.CurrencyRate.search([]).unlink() def test_cron(self): - self.xe_provider._scheduled_update() + # Pretend the provider already ran yesterday so the scheduled update + # only fetches today's rate (via the latest endpoint). Hermetic: the + # HTTP layer is mocked, no real call to xe.com. + self.xe_provider.last_successful_run = self.today - relativedelta(days=1) + with mock.patch(_REQUEST_DATA, return_value=_FakeResponse()): + self.xe_provider._scheduled_update() rates = self.CurrencyRate.search([]) self.assertEqual(len(rates), 1) self.assertEqual(rates.currency_id, self.usd_currency) @@ -46,7 +85,44 @@ def test_wizard(self): .with_context(default_provider_ids=[(6, False, self.xe_provider.ids)]) .create({}) ) - wizard.action_update() + with mock.patch(_REQUEST_DATA, return_value=_FakeResponse()): + wizard.action_update() rates = self.CurrencyRate.search([]) self.assertEqual(len(rates), 1) self.assertEqual(rates.currency_id, self.usd_currency) + + def test_parse_data(self): + # The HTML table is parsed into a {currency: rate} mapping, filtered + # to the requested currencies only. + rates = self.xe_provider._parse_data(_FakeResponse(), ["USD"]) + self.assertEqual(rates, {"USD": 0.92}) + + def test_today_falls_back_to_latest_endpoint(self): + # XE.com returns HTTP 404 for ``?date=``; over a range that ends + # today, the provider must use the dated endpoint for past days and + # fall back to the latest endpoint (no ``date=``) for today. + captured = [] + + def _capture(url): + captured.append(url) + return _FakeResponse() + + yesterday = self.today - relativedelta(days=1) + with mock.patch(_REQUEST_DATA, side_effect=_capture): + self.xe_provider._obtain_rates("EUR", ["USD"], yesterday, self.today) + self.assertEqual(len(captured), 2) + self.assertIn("date=", captured[0]) # past day -> dated endpoint + self.assertNotIn("date=", captured[1]) # today -> latest fallback + + def test_http_error_raises_user_error(self): + # An HTTP error status (e.g. 403/404) surfaces as a clean UserError + # instead of a raw traceback. + with mock.patch.object( + xe_module.requests, + "request", + return_value=_FakeResponse(status_code=404), + ): + with self.assertRaises(UserError): + self.xe_provider._request_data( + "http://www.xe.com/currencytables/?from=EUR" + ) From 3ad6119b94ffc13fa9184947d130925bebe2d1d0 Mon Sep 17 00:00:00 2001 From: Claude Perrin Date: Sat, 20 Jun 2026 08:03:17 +0200 Subject: [PATCH 2/2] [14.0] pre-commit: bump setuptools-odoo to 3.3.2 to fix pkg_resources ImportError setuptools-odoo 3.1.8 imports the removed `pkg_resources` module, so the `setuptools-odoo-make-default` and `setuptools-odoo-get-requirements` hooks fail with `ModuleNotFoundError: No module named 'pkg_resources'` on recent setuptools (>= 81). This makes the pre-commit check red on every PR currently opened against this branch. Bump the hook to 3.3.2, same as #217 for 16.0. Signed-off-by: Claude Perrin --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f67bb1410..9b5fc18bc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -140,7 +140,7 @@ repos: - --settings=. exclude: /__init__\.py$ - repo: https://github.com/acsone/setuptools-odoo - rev: 3.1.8 + rev: 3.3.2 hooks: - id: setuptools-odoo-make-default - id: setuptools-odoo-get-requirements