How to test a flask_tryton route

Hello,

I’d like to integrate some tests in a Tryton Flask application, using flask_tryton.

I can’t quite find how to manage having a “test” transaction and the “route” transaction… since the route transaction asserts on None values… Has someone integrated any test like this or could give any pointers? Thank you

❯ python -m unittest discover -s tests
/Users/hodeinavarro/.local/share/ack-eus/environments/zafra/lib/python3.11/site-packages/passlib/utils/__init__.py:854: DeprecationWarning: 'crypt' is deprecated and slated for removal in Python 3.13
  from crypt import crypt as _crypt
<frozen importlib._bootstrap>:1049: ImportWarning: PluginImportFixer.find_spec() not found; falling back to find_module()
<frozen importlib._bootstrap>:283: DeprecationWarning: the load_module() method is deprecated and slated for removal in Python 3.12; use exec_module() instead
.F........................
======================================================================
FAIL: test_login (test_application.B2BApplicationTestCase.test_login)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/hodeinavarro/.local/share/ack-eus/environments/zafra/lib/python3.11/site-packages/trytond/tests/test_tryton.py", line 209, in wrapper
    result = func(*args, **kwargs)
             ^^^^^^^^^^^^^^^^^^^^^
  File "/Users/hodeinavarro/Developer/ack-eus/tryton/applications/b2b/tests/test_application.py", line 92, in test_login
    response = self.client.post('/login', data={
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/hodeinavarro/.local/share/ack-eus/environments/zafra/lib/python3.11/site-packages/werkzeug/test.py", line 1145, in post
    return self.open(*args, **kw)
           ^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/hodeinavarro/.local/share/ack-eus/environments/zafra/lib/python3.11/site-packages/flask/testing.py", line 223, in open
    response = super().open(
               ^^^^^^^^^^^^^
  File "/Users/hodeinavarro/.local/share/ack-eus/environments/zafra/lib/python3.11/site-packages/werkzeug/test.py", line 1094, in open
    response = self.run_wsgi_app(request.environ, buffered=buffered)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/hodeinavarro/.local/share/ack-eus/environments/zafra/lib/python3.11/site-packages/werkzeug/test.py", line 961, in run_wsgi_app
    rv = run_wsgi_app(self.application, environ, buffered=buffered)
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/hodeinavarro/.local/share/ack-eus/environments/zafra/lib/python3.11/site-packages/werkzeug/test.py", line 1242, in run_wsgi_app
    app_rv = app(environ, start_response)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/hodeinavarro/.local/share/ack-eus/environments/zafra/lib/python3.11/site-packages/flask/app.py", line 2548, in __call__
    return self.wsgi_app(environ, start_response)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/hodeinavarro/.local/share/ack-eus/environments/zafra/lib/python3.11/site-packages/flask/app.py", line 2528, in wsgi_app
    response = self.handle_exception(e)
               ^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/hodeinavarro/.local/share/ack-eus/environments/zafra/lib/python3.11/site-packages/flask/app.py", line 2525, in wsgi_app
    response = self.full_dispatch_request()
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/hodeinavarro/.local/share/ack-eus/environments/zafra/lib/python3.11/site-packages/flask/app.py", line 1822, in full_dispatch_request
    rv = self.handle_user_exception(e)
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/hodeinavarro/.local/share/ack-eus/environments/zafra/lib/python3.11/site-packages/flask/app.py", line 1820, in full_dispatch_request
    rv = self.dispatch_request()
         ^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/hodeinavarro/.local/share/ack-eus/environments/zafra/lib/python3.11/site-packages/flask/app.py", line 1796, in dispatch_request
    return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/hodeinavarro/.local/share/ack-eus/environments/zafra/lib/python3.11/site-packages/flask_tryton.py", line 37, in wrapper
    return func(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^
  File "/Users/hodeinavarro/.local/share/ack-eus/environments/zafra/lib/python3.11/site-packages/flask_tryton.py", line 196, in wrapper
    with Transaction().start(database, transaction_user,
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/hodeinavarro/.local/share/ack-eus/environments/zafra/lib/python3.11/site-packages/trytond/transaction.py", line 112, in start
    assert self.user is None
           ^^^^^^^^^^^^^^^^^
AssertionError

----------------------------------------------------------------------
Ran 26 tests in 77.532s

FAILED (failures=1)
b2b/application/__init__.py
from flask import Flask

def create_app(config=None):
    app = Flask(__name__)
    if config is not None:
        app.config.from_pyfile(config)

    from .tryton import tryton
    tryton.init_app(app)

    from .views import view

    app.register_blueprint(view)

    return app
b2b/application/views.py
from functools import wraps

from flask import Blueprint, redirect, render_template, request, url_for
from werkzeug.wrappers import Response as BaseResponse

from .tryton import tryton

view = Blueprint('view', __name__, 'static', '/', 'templates')


def protected(f):
    @wraps(f)
    def wrapper(*args, **kwargs):
        LOGIN_RESPONSE = redirect(url_for('view.login', r=request.path))

        key = request.cookies.get('session')
        if key is None:
            return LOGIN_RESPONSE
        return validate_session(key, f(*args, **kwargs), LOGIN_RESPONSE)
    return wrapper


@tryton.transaction()
def validate_session(key: str, success_response: BaseResponse,
        LOGIN_RESPONSE: BaseResponse) -> BaseResponse:
    WebUserSession = tryton.pool.get('web.user.session')
    user = WebUserSession.get_user(key)
    if user is None:
        response = LOGIN_RESPONSE
        response.delete_cookie('session')
        return response
    return success_response


def authenticate(username: str, password: str) -> BaseResponse:
    WebUser = tryton.pool.get('web.user')
    WebUserSession = tryton.pool.get('web.user.session')

    user = WebUser.authenticate(username, password)

    if not user:
        # TODO: Add flash message
        return redirect(url_for('view.login', r=request.args.get('r')))

    session = user.new_session()
    response = redirect(request.args.get('r') or url_for('view.index'))
    response.set_cookie('session', session, max_age=WebUserSession.timeout())
    return response


@view.route('/login', methods={'GET', 'POST'})
@tryton.transaction()
def login():
    if request.method == 'POST':
        username = request.form.get('username')
        password = request.form.get('password')

        if username and password:
            return authenticate(username, password)
        else:
            # TODO: Add flash message
            return redirect(url_for('view.login', r=request.args.get('r')))

    return render_template('login.html')


@view.route('/')
@protected
def index():
    return 'Hello, World!'
b2b/application/config.test.py
from application.config import get_secret_key, get_tryton_database

TESTING = True
SECRET_KEY = get_secret_key()
TRYTON_DATABASE = get_tryton_database()

b2b/tests/test_application.py

import unittest

from application import create_app
from trytond.config import config
from trytond.pool import Pool
from trytond.tests.test_tryton import (activate_module, drop_db,
    with_transaction)


class B2BApplicationTestCase(unittest.TestCase):
    "Test B2B application"
    module = 'b2b'
    extras = []
    language = 'es'

    @classmethod
    def setUpClass(cls):
        drop_db()
        modules = [cls.module]
        if cls.extras:
            modules.extend(cls.extras)
        activate_module(modules, lang=cls.language)
        super().setUpClass()

    def setUp(self):
        self.app = create_app('config.test.py')
        self.client = self.app.test_client()

    @classmethod
    def tearDownClass(cls):
        super().tearDownClass()
        drop_db()

    @with_transaction()
    def test_login(self):
        user = 'test'
        password = 'test'.zfill(int(config.get('password', 'length')))

        pool = Pool()
        WebUser = pool.get('web.user')

        WebUser.create([{
            'email': user,
            'password': password
        }])

        response = self.client.post('/login', data={
            'username': user,
            'password': password
        })

        self.assertEqual(response.status_code, 302)
        self.assertEqual(response.location, '/')

I guess the flask application must be configured to have a TRYTON_DATABASE and TRYTON_USER set.

TRYTON_DATABASE is set on create_app function.
TRYTON_USER is set to 0 as default correctly when initialising a Tryton app.

When testing those are also set alongside TESTING = True.

In fact, the problem comes because there is a TRYTON_USER set on the Flask application, since the transaction was started on test_application.py with @with_transaction() and then again tries to start a new one on login route with @tryton.transaction().

So my problem exact problem is that I want to create a web user on test_application.py and then authenticate it on the application, being able to rollback the creation because subsequent tests will not allow me to have a WebUser with same email.

I’m thinking on creating a custom decorator to set up all necessary Tryton information before tests, commit, do the test and then delete all data created on the test db, but wanted to hear if there could be a nicer way to do it.

A possible solution would be to make the transaction decorator re-entrant as long as it is for the same database. In case a transaction has already started, it could just ensure to set the proper parameter like the user and the context.

I’m kind of lost with transactions when working outside my usual workflow…

Do you mean that the Transaction() should join instead of start if the Transaction().database.name equals the TRYTON_DATABASE env var in flask_tryton?

This should be handled in flask_tryton as a feature request, right?
What could I try to test in order to submit a change?

Yes if the transaction has already started, then we should not start a new one.

Yes.

Your test case for example. Indeed this should allow to implement tests for flask-tryton.

By the way we are in the process to also migrate to Heptapod but there is not dead line.