diff --git a/.docker_files/main/__manifest__.py b/.docker_files/main/__manifest__.py index 4f69dc9d..4ff45bbf 100644 --- a/.docker_files/main/__manifest__.py +++ b/.docker_files/main/__manifest__.py @@ -17,6 +17,7 @@ "attachment_minio", "lang_fr_activated", "mail_template_default", + "test_http_request", ], "installable": True, } diff --git a/Dockerfile b/Dockerfile index 0d8ed1d9..d6c10a37 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,7 @@ COPY admin_light_calendar /mnt/extra-addons/admin_light_calendar COPY attachment_minio /mnt/extra-addons/attachment_minio COPY lang_fr_activated /mnt/extra-addons/lang_fr_activated COPY mail_template_default /mnt/extra-addons/mail_template_default +COPY test_http_request /mnt/extra-addons/test_http_request COPY .docker_files/main /mnt/extra-addons/main COPY .docker_files/odoo.conf /etc/odoo diff --git a/test_http_request/README.rst b/test_http_request/README.rst new file mode 100644 index 00000000..78d56041 --- /dev/null +++ b/test_http_request/README.rst @@ -0,0 +1,50 @@ +Test HTTP Request +----------------- +A technical Odoo module to help testing HTTP requests/controllers. + +This module does not need to be in the dependencies of other modules that use it. +However, if installed, it will have no impact on a production instance. + +Context +------- +Testing HTTP controllers in Odoo is not a trivial task. +Therefore, many developpers do not automate the tests of their controllers. + +This modules intends to offer tools to make the task easier. + +Usage +----- + +Mocking HTTP Requests +~~~~~~~~~~~~~~~~~~~~~ +The first tool this module provides is a way to mock Odoo HTTP requests. + +In your module, you may use the ``mock_odoo_request`` to simulate the request: + +.. code-block:: python + + from odoo.addons.test_http_request.common import mock_odoo_request + from odoo.tests.common import TransactionCase + from ..controllers.main import MyController + + + class TestSomething(TransactionCase): + + def test_some_method(self): + with mock_odoo_request(self.env): + result = MyController().do_something() + + assert result == ... + + +The ``mock_odoo_request`` function is a context manager. +Behind the scene, when calling it, the following technical work is done: + +* An HTTP session is created. +* An environ is created. +* An httprequest object is added to the werkzeug stack. +* The httprequest object is bound to the given ``Odoo Environment`` object. + +Contributors +------------ +* Numigi (tm) and all its contributors (https://bit.ly/numigiens) diff --git a/test_http_request/__init__.py b/test_http_request/__init__.py new file mode 100644 index 00000000..d35cc4e8 --- /dev/null +++ b/test_http_request/__init__.py @@ -0,0 +1,4 @@ +# © 2023 Numigi (tm) and all its contributors (https://bit.ly/numigiens) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from . import tests diff --git a/test_http_request/__manifest__.py b/test_http_request/__manifest__.py new file mode 100644 index 00000000..2dd21d17 --- /dev/null +++ b/test_http_request/__manifest__.py @@ -0,0 +1,16 @@ +# © 2023 Numigi (tm) and all its contributors (https://bit.ly/numigiens) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +{ + 'name': 'Test HTTP Request', + 'version': '16.0.1.0.0', + 'author': 'Numigi', + 'maintainer': 'Numigi', + 'license': 'LGPL-3', + 'category': 'Other', + 'summary': 'Technical module for testing http requests.', + 'depends': [ + 'base', + ], + 'installable': True, +} diff --git a/test_http_request/common.py b/test_http_request/common.py new file mode 100644 index 00000000..d63df087 --- /dev/null +++ b/test_http_request/common.py @@ -0,0 +1,174 @@ +# © 2023 Numigi (tm) and all its contributors (https://bit.ly/numigiens) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import json +import werkzeug +from contextlib import contextmanager +from io import BytesIO +from odoo.addons.http_routing.models.ir_http import url_for +from odoo.api import Environment +from odoo.http import HttpDispatcher, JsonRPCDispatcher, _request_stack, Session +from odoo.tools import config +from typing import Optional, Union +from odoo.http import FilesystemSessionStore +from werkzeug.datastructures import ImmutableOrderedMultiDict +from werkzeug.test import EnvironBuilder +from werkzeug.urls import url_encode +from werkzeug.wrappers.request import Request + + +class _MockOdooRequestMixin: + + @staticmethod + def redirect(url, code=302): + """Add the `redirect` method to the request. + + This method is added programatically by the module http_routing: + odoo/addons/http_routing/models/ir_http.py (method _dispatch) + """ + return werkzeug.utils.redirect(url_for(url), code) + + @property + def website(self): + """Add the `website` property to the request. + + The attribute is added programatically by the module website: + odoo/addons/website/models/ir_http.py (method _add_dispatch_parameters) + + The attribute is added as a property so that the request object + can be used even if the module website is not installed. + """ + return self.env['website'].get_current_website() + + def __exit__(self, exc_type, exc_value, traceback): + """Prevent commiting the transaction when exiting the HTTP request. + + Since the request uses the same cursor as the test fixture, + the cursor must not be commited when exiting the request. + """ + _request_stack.pop() + + def __enter__(self): + """Push the request to the request stack.""" + _request_stack.push(self) + return self + + +class _MockOdooHttpRequest(_MockOdooRequestMixin, HttpDispatcher): + pass + + +class _MockOdooJsonRequest(_MockOdooRequestMixin, JsonRPCDispatcher): + pass + + +def _make_environ_form_data_stream(data: dict) -> BytesIO: + """Make the form data stream for an Odoo http request. + + Odoo uses ImmutableOrderedMultiDict to store url encoded form + data instead of the default ImmutableMultiDict in werkzeug. + + The test utility :class:`werkzeug.test.EnvironBuilder` uses + MultiDict to store form data. + + This must be ajusted in order reproduce properly the behavior of Odoo. + """ + encoded_data = url_encode(data).encode("ascii") + return BytesIO(encoded_data) + + +def _make_environ( + method: str = 'POST', + headers: Optional[dict] = None, + data: Optional[dict] = None, + routing_type: str = 'http', +): + """Make an environ for the given request parameters.""" + assert routing_type in ('http', 'json') + environ_builder = EnvironBuilder( + method=method, + data=json.dumps(data or {}) if routing_type == 'json' else data, + headers=headers, + content_type=( + 'application/json' if routing_type == 'json' else + 'application/x-www-form-urlencoded' + ) + ) + environ = environ_builder.get_environ() + + if routing_type == 'http' and data: + environ['wsgi.input'] = _make_environ_form_data_stream(data) + return environ + + +def _set_request_storage_class(httprequest: Request): + """Set the data structure used to store form data. + + This is done in the method Root.dispatch of odoo/http.py. + """ + httprequest.parameter_storage_class = ImmutableOrderedMultiDict + + +def _make_werkzeug_request(environ: dict) -> Request: + """Make a werkzeug request from the given environ.""" + httprequest = Request(environ) + _set_request_storage_class(httprequest) + return httprequest + + +def _make_filesystem_session(env: Environment) -> Session: + session_store = FilesystemSessionStore( + config.session_dir, session_class=Session, renew_missing=True) + session = session_store.new() + session.db = env.cr.dbname + session.uid = env.uid + session.context = env.context + return session + + +def _make_odoo_request( + werkzeug_request: Request, env: Environment, routing_type: str +) -> Union[_MockOdooHttpRequest, _MockOdooJsonRequest]: + """Make an Odoo request from the given werkzeug request.""" + odoo_request_cls = ( + _MockOdooJsonRequest if routing_type == 'json' else + _MockOdooHttpRequest + ) + odoo_request = odoo_request_cls(werkzeug_request) + odoo_request._env = env + odoo_request._cr = env.cr + odoo_request._uid = env.uid + odoo_request._context = env.context + return odoo_request + + +@contextmanager +def mock_odoo_request( + env: Environment, + method: str = 'POST', + headers: Optional[dict] = None, + data: Optional[dict] = None, + routing_type: str = 'http', +): + """Mock an Odoo HTTP request. + + This methods builds an HTTPRequest object and adds it to + the local stack of the application. + + The Odoo environment of the test fixture is injected to the + request so that objects created in the fixture are available + for controllers. + + :param env: the odoo environment to bind with the request. + :param method: the HTTP method called during the request. + :param headers: the request headers. + :param data: an optional dict to be serialized as json or url-encoded data. + :param routing_type: whether to use an http (x-www-form-urlencoded) or json request. + """ + environ = _make_environ(method, headers, data, routing_type) + werkzeug_request = _make_werkzeug_request(environ) + werkzeug_request.session = _make_filesystem_session(env) + odoo_request = _make_odoo_request(werkzeug_request, env, routing_type) + + with odoo_request: + yield odoo_request diff --git a/test_http_request/static/description/icon.png b/test_http_request/static/description/icon.png new file mode 100644 index 00000000..92a86b10 Binary files /dev/null and b/test_http_request/static/description/icon.png differ diff --git a/test_http_request/tests/__init__.py b/test_http_request/tests/__init__.py new file mode 100644 index 00000000..6ff5f5a6 --- /dev/null +++ b/test_http_request/tests/__init__.py @@ -0,0 +1,4 @@ +# © 2023 Numigi (tm) and all its contributors (https://bit.ly/numigiens) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from . import test_mock_http_request diff --git a/test_http_request/tests/test_mock_http_request.py b/test_http_request/tests/test_mock_http_request.py new file mode 100644 index 00000000..9d4de955 --- /dev/null +++ b/test_http_request/tests/test_mock_http_request.py @@ -0,0 +1,65 @@ +# © 2023 Numigi (tm) and all its contributors (https://bit.ly/numigiens) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import json +from collections import OrderedDict +from ddt import data, ddt, unpack +from odoo.http import request +from odoo.tests import common +from werkzeug.datastructures import ImmutableMultiDict +from werkzeug.urls import url_encode +from ..common import mock_odoo_request + + +@ddt +class TestMockHttpRequest(common.TransactionCase): + + def setUp(self): + super().setUp() + self.data = OrderedDict([ + ('firstname', 'John'), + ('lastname', 'Doe'), + ]) + + def test_env_propagated_to_request(self): + with mock_odoo_request(self.env, data=self.data): + assert request._env == self.env + + def test_method_propagated_to_request(self): + method = 'PATCH' + with mock_odoo_request(self.env, data=self.data, method=method): + assert request.request.method == method + + def test_headers_propagated_to_request(self): + header_key = 'Some-Header' + header_value = 'some value' + headers = { + header_key: header_value, + } + with mock_odoo_request(self.env, data=self.data, headers=headers): + assert request.request.headers[header_key] == header_value + + @data( + ('http', 'application/x-www-form-urlencoded'), + ('json', 'application/json'), + ) + @unpack + def test_content_type(self, routing_type, content_type): + with mock_odoo_request(self.env, data=self.data, routing_type=routing_type): + assert request.request.content_type == content_type + + def test_if_http_routing__data_contained_in_request_form(self): + with mock_odoo_request(self.env, data=self.data): + assert request.request.form == ImmutableMultiDict(self.data) + assert not request.request.data + + def test_if_json_routing__data_contained_in_request_data(self): + json_data = json.dumps(self.data).encode() + with mock_odoo_request(self.env, data=self.data, routing_type='json'): + assert not request.request.form + assert request.request.data == json_data + + def test_form_data_propagated_with_correct_key_order(self): + data = OrderedDict([(str(i), str(i)) for i in range(10)]) + with mock_odoo_request(self.env, data=data): + assert url_encode(request.request.form) == url_encode(data)