diff --git a/.github/workflows/auto_tests.yml b/.github/workflows/auto_tests.yml index c48a52a4..283549a3 100644 --- a/.github/workflows/auto_tests.yml +++ b/.github/workflows/auto_tests.yml @@ -17,7 +17,7 @@ jobs: matrix: include: - suite: server - pytest_args: "-v --cov=mergin --cov-report=lcov mergin/tests" + pytest_args: "-v -n auto --cov=mergin --cov-report=lcov mergin/tests" - suite: migration pytest_args: "-v mergin/test_migrations" diff --git a/development.md b/development.md index d4a8763c..860905a1 100644 --- a/development.md +++ b/development.md @@ -142,3 +142,7 @@ cd server pipenv install --dev --deploy --verbose pipenv run pytest -v --cov=mergin mergin/tests ``` + +Tests can run in parallel with [pytest-xdist](https://pytest-xdist.readthedocs.io), e.g. `pipenv run pytest -n 4 mergin/tests`. +Each worker uses its own database (`_gw`, created automatically) and its own temporary directory. +To run several test sessions at the same time, give each a different `DB_DATABASE`, e.g. `DB_DATABASE=ce pipenv run pytest -n 4 mergin/tests`. diff --git a/server/.test.env b/server/.test.env index 0ab2ce8a..a7af59fc 100644 --- a/server/.test.env +++ b/server/.test.env @@ -26,4 +26,5 @@ SECURITY_PASSWORD_SALT='password' SECURITY_UNLOCK_SALT='unlock' DIAGNOSTIC_LOGS_DIR=/tmp/diagnostic_logs GEVENT_WORKER=0 -OTEL_ENABLED=0 \ No newline at end of file +OTEL_ENABLED=0 +BCRYPT_LOG_ROUNDS=4 diff --git a/server/Pipfile b/server/Pipfile index 552741d0..f82614c5 100644 --- a/server/Pipfile +++ b/server/Pipfile @@ -51,6 +51,7 @@ pytest-cov = "==5.0.0" pylint = "==3.2.6" responses = "==0.21.0" pytest-dotenv= "==0.5.2" +pytest-xdist = "==3.8.0" # sqlite with loadable extensions for tests, pysqlite3-binary wheels are available only for linux pysqlite3-binary = {version = "==0.5.3", markers = "sys_platform == 'linux'"} pysqlite3 = {version = "==0.6.0", markers = "sys_platform != 'linux'"} diff --git a/server/Pipfile.lock b/server/Pipfile.lock index af4c6204..19429fca 100644 --- a/server/Pipfile.lock +++ b/server/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "250ff3b20424d27421a771e1618f66d389b07456cf88e45274d1f9f148ddc069" + "sha256": "32767bfc360446c9796c8edf84ef1496707ec64089a84a8a66750a08b825dd8f" }, "pipfile-spec": 6, "requires": { @@ -2807,6 +2807,14 @@ ], "version": "==0.4.3" }, + "execnet": { + "hashes": [ + "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", + "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec" + ], + "markers": "python_version >= '3.8'", + "version": "==2.1.2" + }, "filelock": { "hashes": [ "sha256:a93c4d93269b339a6af4848342c7e940d0f9928ad95eff64764699e5f1bf8a6a", @@ -2833,11 +2841,11 @@ }, "iniconfig": { "hashes": [ - "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", - "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" + "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", + "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7" ], "markers": "python_version >= '3.10'", - "version": "==2.3.0" + "version": "==2.3.1" }, "isort": { "hashes": [ @@ -3006,6 +3014,15 @@ "index": "pypi", "version": "==0.5.2" }, + "pytest-xdist": { + "hashes": [ + "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", + "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==3.8.0" + }, "python-discovery": { "hashes": [ "sha256:cf87d3627dfb4412437fdd5b13eae402607722998d21567993aedbc59b23c15e", diff --git a/server/conftest.py b/server/conftest.py new file mode 100644 index 00000000..0c098615 --- /dev/null +++ b/server/conftest.py @@ -0,0 +1,31 @@ +# Copyright (C) Lutra Consulting Limited +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial + +import os +import tempfile + + +def isolate_xdist_worker(): + """Give each pytest-xdist worker its own database and temporary directories. + + Must run before app configuration is imported as it is read from env variables, + this conftest is loaded right after test env file and before any tests modules. + """ + worker = os.environ.get("PYTEST_XDIST_WORKER") + if not worker: + return + + db_name = f"{os.environ.get('DB_DATABASE', 'postgres')}_{worker}" + os.environ["DB_DATABASE"] = db_name + tmp_dir = tempfile.gettempdir() + worker_tmp_dir = os.path.join(tmp_dir, db_name) + os.makedirs(worker_tmp_dir, exist_ok=True) + for key, value in list(os.environ.items()): + if value == tmp_dir or value.startswith(tmp_dir + os.sep): + os.environ[key] = worker_tmp_dir + value[len(tmp_dir) :] + os.environ["TMPDIR"] = worker_tmp_dir + tempfile.tempdir = None + + +isolate_xdist_worker() diff --git a/server/mergin/tests/fixtures.py b/server/mergin/tests/fixtures.py index c1a5ab8f..3987d4c5 100644 --- a/server/mergin/tests/fixtures.py +++ b/server/mergin/tests/fixtures.py @@ -4,6 +4,7 @@ import os import shutil +import tempfile import sys import uuid from shutil import copy, move @@ -17,16 +18,24 @@ from ..stats.app import register from ..stats.models import MerginInfo from . import test_project, test_workspace_id, test_project_dir, TMP_DIR -from .utils import login_as_admin, initialize, cleanup, file_info, ListSink +from .utils import ( + login_as_admin, + initialize, + cleanup, + file_info, + ListSink, + clean_db, + create_db, +) from ..sync.files import files_changes_from_upload thisdir = os.path.dirname(os.path.realpath(__file__)) sys.path.append(os.path.join(thisdir, os.pardir)) -@pytest.fixture(scope="function") -def flask_app(request): - """Flask app with fresh db and initialized empty tables""" +@pytest.fixture(scope="session") +def session_app(): + """Flask app shared by all tests, db tables are created only once""" from ..sync.db_events import remove_events application = create_app( @@ -44,24 +53,34 @@ def flask_app(request): application.config["SERVER_NAME"] = "localhost.localdomain" application.config["SERVER_TYPE"] = "ce" application.config["SERVICE_ID"] = str(uuid.uuid4()) - app_context = application.app_context() - app_context.push() - with app_context: + with application.app_context(): + create_db() + # start from scratch in case of previous interrupted run + db.drop_all() db.create_all() - def teardown(): - # clean up db + yield application + + with application.app_context(): db.session.remove() db.drop_all() db.engine.dispose() + # detach db hooks + remove_events() - app_context.pop() - # detach db hooks - remove_events() - request.addfinalizer(teardown) - return application +@pytest.fixture(scope="function") +def flask_app(session_app): + """Flask app with empty db tables""" + app_context = session_app.app_context() + app_context.push() + clean_db() + + yield session_app + + db.session.remove() + app_context.pop() @pytest.fixture(scope="function") @@ -127,41 +146,40 @@ def diff_project(app): """ from .test_project_controller import create_diff_meta - test_gpkg_file = os.path.join(test_project_dir, "test.gpkg") + # work on a copy of testing project files, the source dir is shared by all tests (and xdist workers) + project_dir = tempfile.mkdtemp(dir=TMP_DIR) + shutil.copytree(test_project_dir, project_dir, dirs_exist_ok=True) + test_gpkg_file = os.path.join(project_dir, "test.gpkg") try: geodiff = GeoDiff() project = Project.query.filter_by( name=test_project, workspace_id=test_workspace_id ).first() - update_meta = file_info(test_project_dir, "base.gpkg") - diff_meta_A = create_diff_meta( - "base.gpkg", "inserted_1_A.gpkg", test_project_dir - ) + update_meta = file_info(project_dir, "base.gpkg") + diff_meta_A = create_diff_meta("base.gpkg", "inserted_1_A.gpkg", project_dir) diff_meta_mod = create_diff_meta( - "base.gpkg", "modified_1_geom.gpkg", test_project_dir + "base.gpkg", "modified_1_geom.gpkg", project_dir ) patch = os.path.join(TMP_DIR, "patch") - basefile = os.path.join(test_project_dir, "base.gpkg") + basefile = os.path.join(project_dir, "base.gpkg") copy(basefile, patch) copy(basefile, test_gpkg_file) geodiff.apply_changeset( patch, os.path.join(TMP_DIR, diff_meta_mod["diff"]["path"]) ) - diff_meta_B = create_diff_meta( - "base.gpkg", "inserted_1_B.gpkg", test_project_dir - ) + diff_meta_B = create_diff_meta("base.gpkg", "inserted_1_B.gpkg", project_dir) changes = [ { "added": [], - "removed": [file_info(test_project_dir, "base.gpkg")], + "removed": [file_info(project_dir, "base.gpkg")], "updated": [], }, { - "added": [file_info(test_project_dir, "base.gpkg")], + "added": [file_info(project_dir, "base.gpkg")], "removed": [], "updated": [], }, @@ -182,8 +200,8 @@ def diff_project(app): }, # file renamed, by removing old and upload new - break of history { - "added": [file_info(test_project_dir, "test.gpkg")], - "removed": [file_info(test_project_dir, "base.gpkg")], + "added": [file_info(project_dir, "test.gpkg")], + "removed": [file_info(project_dir, "base.gpkg")], "updated": [], }, {"added": [], "removed": [], "updated": []}, @@ -196,7 +214,7 @@ def diff_project(app): project.storage.project_dir, ver, file_meta["path"] ) os.makedirs(os.path.dirname(new_file), exist_ok=True) - copy(os.path.join(test_project_dir, file_meta["path"]), new_file) + copy(os.path.join(project_dir, file_meta["path"]), new_file) elif change["updated"]: file_meta = change["updated"][0] f_updated = next( @@ -220,7 +238,7 @@ def diff_project(app): ), ) else: - copy(os.path.join(test_project_dir, f_updated.path), patchedfile) + copy(os.path.join(project_dir, f_updated.path), patchedfile) else: # no files uploaded, hence no action needed pass @@ -248,7 +266,7 @@ def diff_project(app): db.session.add(project) db.session.commit() finally: - os.remove(test_gpkg_file) + shutil.rmtree(project_dir) return project diff --git a/server/mergin/tests/test_auth.py b/server/mergin/tests/test_auth.py index 69e3d7d4..dfa2a1ab 100644 --- a/server/mergin/tests/test_auth.py +++ b/server/mergin/tests/test_auth.py @@ -435,7 +435,7 @@ def login_attempt(login, password="dummy"): assert mock_hashpw.call_count + mock_checkpw.call_count == 4 -def test_bcrypt_lazy_rehash(app): +def test_bcrypt_lazy_rehash(app, monkeypatch): """Password is transparently rehashed on login when the cost factor changes.""" import bcrypt from ..auth.app import authenticate @@ -448,7 +448,7 @@ def test_bcrypt_lazy_rehash(app): user.passwd = low_rounds_hash db.session.commit() - app.config["BCRYPT_LOG_ROUNDS"] = 5 + monkeypatch.setitem(app.config, "BCRYPT_LOG_ROUNDS", 5) result = authenticate("rehashuser", "rehashpassword") assert result is not None @@ -963,7 +963,7 @@ def test_api_user_profile(client): assert key in resp.json -def test_update_user(client): +def test_update_user(client, monkeypatch): login_as_admin(client) user = User.query.filter_by(username="mergin").first() data = {"active": True, "is_admin": True} @@ -976,7 +976,9 @@ def test_update_user(client): assert user.active assert user.is_admin - client.application.config["ENABLE_SUPERADMIN_ASSIGNMENT"] = False + monkeypatch.setitem( + client.application.config, "ENABLE_SUPERADMIN_ASSIGNMENT", False + ) data = {"active": False, "is_admin": False} resp = client.patch( url_for("/.mergin_auth_controller_update_user", username=user.username), @@ -994,7 +996,7 @@ def test_update_user(client): assert resp.status_code == 200 assert not user.active - client.application.config["ENABLE_SUPERADMIN_ASSIGNMENT"] = True + monkeypatch.setitem(client.application.config, "ENABLE_SUPERADMIN_ASSIGNMENT", True) user.is_admin = False db.session.add(user) db.session.commit() diff --git a/server/mergin/tests/test_celery.py b/server/mergin/tests/test_celery.py index cb814622..1eba5094 100644 --- a/server/mergin/tests/test_celery.py +++ b/server/mergin/tests/test_celery.py @@ -49,7 +49,7 @@ from ..auth.models import User -def test_send_email(app): +def test_send_email(app, monkeypatch): """Test celery is actually sending emails.""" mail = Mail() email_data = { @@ -59,7 +59,7 @@ def test_send_email(app): "sender": "no_reply@cloudmergin.com", } with mail.record_messages() as outbox: - Configuration.MERGIN_TESTING = True + monkeypatch.setattr(Configuration, "MERGIN_TESTING", True) task = send_email_async.s(**email_data).apply() assert len(outbox) == 1 assert task.status == "SUCCESS" @@ -70,13 +70,13 @@ def test_send_email(app): assert "foo@bar.com" in outbox[0].send_to # turn off testing mode - Configuration.MERGIN_TESTING = False + monkeypatch.setattr(Configuration, "MERGIN_TESTING", False) task = send_email_async.s(**email_data).apply() assert len(outbox) == 2 assert task.status == "SUCCESS" assert current_app.config["MAIL_BCC"] in outbox[1].bcc - Configuration.MERGIN_TESTING = True + monkeypatch.setattr(Configuration, "MERGIN_TESTING", True) del email_data["recipients"] task = send_email_async.s(**email_data).apply() assert task.status == "FAILURE" diff --git a/server/mergin/tests/test_cli.py b/server/mergin/tests/test_cli.py index e500177e..d19637f0 100644 --- a/server/mergin/tests/test_cli.py +++ b/server/mergin/tests/test_cli.py @@ -234,6 +234,7 @@ def test_send_statistics( mock_send_statistics, collect_stats, app, + monkeypatch, ): """Test '_send_statistics' helper""" from mergin.commands import _send_statistics @@ -242,7 +243,7 @@ def test_send_statistics( mock_check_celery.return_value = True mock_save_statistics.return_value = None mock_send_statistics.return_value = None - app.config["COLLECT_STATISTICS"] = 1 if collect_stats else 0 + monkeypatch.setitem(app.config, "COLLECT_STATISTICS", 1 if collect_stats else 0) _send_statistics(app) assert mock_save_statistics.call_count == 1 @@ -279,12 +280,15 @@ def test_check_email( output, runner, app, + monkeypatch, ): """Test 'send-check-email' command""" mock_send_email.return_value = None mock_check_celery.return_value = celery_check - app.config["MAIL_SUPPRESS_SEND"] = suppressed - app.config["MAIL_DEFAULT_SENDER"] = "sender@mergin.com" if sender else None + monkeypatch.setitem(app.config, "MAIL_SUPPRESS_SEND", suppressed) + monkeypatch.setitem( + app.config, "MAIL_DEFAULT_SENDER", "sender@mergin.com" if sender else None + ) args = ["server", "send-check-email"] if recipient: @@ -438,19 +442,20 @@ def test_check_server( output, app, runner, + monkeypatch, ): """Test 'check' server command""" - app.config["SERVER_TYPE"] = edition - app.config["MERGIN_BASE_URL"] = base_url - app.config["CONTACT_EMAIL"] = contact_email + monkeypatch.setitem(app.config, "SERVER_TYPE", edition) + monkeypatch.setitem(app.config, "MERGIN_BASE_URL", base_url) + monkeypatch.setitem(app.config, "CONTACT_EMAIL", contact_email) if service_id is None: - app.config["SERVICE_ID"] = "" + monkeypatch.setitem(app.config, "SERVICE_ID", "") MerginInfo.query.delete() db.session.commit() if service_id == "service_id_config": - app.config["SERVICE_ID"] = "service-id-config" + monkeypatch.setitem(app.config, "SERVICE_ID", "service-id-config") if not tables: table_names_mock.return_value = "" diff --git a/server/mergin/tests/test_config.py b/server/mergin/tests/test_config.py index 494bb438..dc9d9d45 100644 --- a/server/mergin/tests/test_config.py +++ b/server/mergin/tests/test_config.py @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial -def test_config(client): +def test_config(client, monkeypatch): """Test response of app public config""" resp = client.get("/config") assert resp.status_code == 200 @@ -28,19 +28,21 @@ def test_config(client): assert resp.status_code == 200 assert resp.json["server_type"] == "ce" - client.application.config["VERSION"] = "2023.1.2" + monkeypatch.setitem(client.application.config, "VERSION", "2023.1.2") resp = client.get("/config") assert resp.json["major"] == 2023 assert resp.json["minor"] == 1 assert resp.json["fix"] == 2 assert resp.json["server_configured"] is False - client.application.config["MERGIN_BASE_URL"] = "http://localhost:5000" + monkeypatch.setitem( + client.application.config, "MERGIN_BASE_URL", "http://localhost:5000" + ) resp = client.get("/config") assert resp.json["server_configured"] is True assert resp.json["user_self_registration"] is False assert resp.json["build_hash"] == "" - client.application.config["BUILD_HASH"] = "abcd1234" + monkeypatch.setitem(client.application.config, "BUILD_HASH", "abcd1234") resp = client.get("/config") assert resp.json["build_hash"] == "abcd1234" diff --git a/server/mergin/tests/test_controller.py b/server/mergin/tests/test_controller.py index dab45afc..2cefa6b4 100644 --- a/server/mergin/tests/test_controller.py +++ b/server/mergin/tests/test_controller.py @@ -10,10 +10,10 @@ from .utils import Response -def test_healthcheck(client): +def test_healthcheck(client, monkeypatch): # anonymous user client.get(url_for("/.mergin_auth_controller_logout")) - client.application.config["WTF_CSRF_ENABLED"] = True + monkeypatch.setitem(client.application.config, "WTF_CSRF_ENABLED", True) maint_file = current_app.config["MAINTENANCE_FILE"] resp = client.post("/alive") assert resp.status_code == 200 diff --git a/server/mergin/tests/test_db_hooks.py b/server/mergin/tests/test_db_hooks.py index 27aadb5b..4d0fd0bb 100644 --- a/server/mergin/tests/test_db_hooks.py +++ b/server/mergin/tests/test_db_hooks.py @@ -127,7 +127,7 @@ def test_remove_project(client, diff_project): .all() ] file_history = FileHistory.query.filter( - FileHistory.version_id.in_(versions_ids) + FileHistory.version_id.in_(versions_ids), FileHistory.location.isnot(None) ).first() file = os.path.join(project_dir, file_history.location) assert file_history and os.path.exists(file) diff --git a/server/mergin/tests/test_permissions.py b/server/mergin/tests/test_permissions.py index 73bf5ab4..0b790978 100644 --- a/server/mergin/tests/test_permissions.py +++ b/server/mergin/tests/test_permissions.py @@ -27,7 +27,7 @@ ) -def test_project_permissions(client): +def test_project_permissions(client, monkeypatch): owner = add_user("owner", "pwd") test_workspace = create_workspace() project = create_project("test_permissions", test_workspace, owner) @@ -72,17 +72,17 @@ def test_project_permissions(client): assert ProjectPermissions.get_user_project_role(project, user) == ProjectRole.EDITOR # adjust global permissions - Configuration.GLOBAL_READ = True + monkeypatch.setattr(Configuration, "GLOBAL_READ", True) assert ProjectPermissions.Read.check(project, user) assert ProjectPermissions.Edit.check(project, user) assert not ProjectPermissions.Upload.check(project, user) assert not ProjectPermissions.Delete.check(project, user) - Configuration.GLOBAL_WRITE = True + monkeypatch.setattr(Configuration, "GLOBAL_WRITE", True) assert ProjectPermissions.Upload.check(project, user) assert ProjectPermissions.Edit.check(project, user) assert not ProjectPermissions.Delete.check(project, user) assert not ProjectPermissions.All.check(project, user) - Configuration.GLOBAL_ADMIN = True + monkeypatch.setattr(Configuration, "GLOBAL_ADMIN", True) assert ProjectPermissions.Delete.check(project, user) assert ProjectPermissions.All.check(project, user) diff --git a/server/mergin/tests/test_private_project_api.py b/server/mergin/tests/test_private_project_api.py index 56838506..53ee1923 100644 --- a/server/mergin/tests/test_private_project_api.py +++ b/server/mergin/tests/test_private_project_api.py @@ -53,7 +53,7 @@ def test_project_unsubscribe(client, diff_project): assert not diff_project.get_role(user.id) -def test_project_access_request(client): +def test_project_access_request(client, monkeypatch): """Test project access CRUD operations""" user = User.query.filter(User.username == "mergin").first() test_workspace = create_workspace() @@ -105,7 +105,7 @@ def test_project_access_request(client): assert access_request2.requested_by == user2.id # user can not list incoming namespace access requests - Configuration.GLOBAL_ADMIN = False + monkeypatch.setattr(Configuration, "GLOBAL_ADMIN", False) assert ( client.get( f"/app/project/access-request/{test_workspace.name}?page=1&per_page=10", @@ -456,7 +456,7 @@ def test_download_project( assert resp.status_code == 200 -def test_prepare_large_project_fail(client, diff_project): +def test_prepare_large_project_fail(client, diff_project, monkeypatch): """Test asking for too large project is refused""" resp = client.post( url_for( @@ -467,7 +467,7 @@ def test_prepare_large_project_fail(client, diff_project): ) assert resp.status_code == 201 # pretend testing project to be too large by lowering limit - client.application.config["MAX_DOWNLOAD_ARCHIVE_SIZE"] = 10 + monkeypatch.setitem(client.application.config, "MAX_DOWNLOAD_ARCHIVE_SIZE", 10) resp = client.post( url_for( "/app.mergin_sync_private_api_controller_prepare_archive", diff --git a/server/mergin/tests/test_project_controller.py b/server/mergin/tests/test_project_controller.py index 61dcc2e8..3669972b 100644 --- a/server/mergin/tests/test_project_controller.py +++ b/server/mergin/tests/test_project_controller.py @@ -169,7 +169,7 @@ def test_file_history(client, diff_project): assert "location" not in history["v7"] -def test_get_paginated_projects(client): +def test_get_paginated_projects(client, monkeypatch): user = User.query.filter_by(username="mergin").first() test_workspace = create_workspace() for i in range(14): @@ -207,11 +207,14 @@ def test_get_paginated_projects(client): resp_data = json.loads(resp.data) assert len(resp_data.get("projects")) == 5 assert "foo13" in resp_data.get("projects")[-1]["name"] - # tests backward compatibility sort + # tests backward compatibility sort, projects within the same namespace are not ordered resp_alt = client.get( "/v1/project/paginated?page=2&per_page=10&order_by=namespace&descending=false" ) - assert resp_alt.json == resp.json + assert resp_alt.json["count"] == resp.json["count"] + assert [p["namespace"] for p in resp_alt.json["projects"]] == [ + p["namespace"] for p in resp.json["projects"] + ] # try with unknown order parameter resp = client.get( @@ -300,9 +303,9 @@ def test_get_paginated_projects(client): db.session.commit() # reset permissions so new user would be only a guest - Configuration.GLOBAL_READ = False - Configuration.GLOBAL_WRITE = False - Configuration.GLOBAL_ADMIN = False + monkeypatch.setattr(Configuration, "GLOBAL_READ", False) + monkeypatch.setattr(Configuration, "GLOBAL_WRITE", False) + monkeypatch.setattr(Configuration, "GLOBAL_ADMIN", False) # add new user and pretend he created one project (total 15+1) user2 = add_user("user2", "ilovemergin") create_project("created", test_workspace, user2) @@ -323,7 +326,7 @@ def test_get_paginated_projects(client): assert resp.json["projects"][0]["name"] == "foo1" # make user reader of all projects - Configuration.GLOBAL_READ = True + monkeypatch.setattr(Configuration, "GLOBAL_READ", True) resp = client.get("/v1/project/paginated?page=1&per_page=10&flag=created") assert resp.json["count"] == 1 resp = client.get("/v1/project/paginated?page=1&per_page=20&flag=shared") @@ -434,8 +437,8 @@ def test_add_project(client, app, data, expected): project = Project.query.filter_by( name=data["name"].strip(), workspace_id=test_workspace_id ).first() - proj_files = project.files - temp_files = template.files + proj_files = sorted(project.files, key=lambda f: f.path) + temp_files = sorted(template.files, key=lambda f: f.path) assert len(proj_files) == len(temp_files) assert not any( x.checksum != y.checksum and x.path != y.path @@ -1099,7 +1102,7 @@ def test_push_project_start(client, data, expected): assert failure.error_type == "push_start" -def test_push_to_new_project(client): +def test_push_to_new_project(client, monkeypatch): # create blank project p = Project.query.filter_by( name=test_project, workspace_id=test_workspace_id @@ -1110,7 +1113,7 @@ def test_push_to_new_project(client): db.session.add(project) db.session.commit() - current_app.config["BLACKLIST"] = ["test4"] + monkeypatch.setitem(current_app.config, "BLACKLIST", ["test4"]) url = "/v1/project/push/{}/{}".format(test_workspace_name, "blank") data = {"version": "v0", "changes": _get_changes(test_project_dir)} resp = client.post( @@ -1149,8 +1152,8 @@ def test_push_to_new_project(client): assert failure.error_details == "First push should be with v0" -def test_push_integrity_error(client, app): - app.config["LOCKFILE_EXPIRATION"] = 5 +def test_push_integrity_error(client, app, monkeypatch): + monkeypatch.setitem(app.config, "LOCKFILE_EXPIRATION", 5) url = "/v1/project/push/{}/{}".format(test_workspace_name, test_project) changes = _get_changes(test_project_dir) changes["added"] = changes["removed"] = [] @@ -1258,13 +1261,13 @@ def test_stale_upload_takeover(client, app): assert failure.error_details == "Push artefact removed by subsequent push" -def test_exceed_data_limit(client): +def test_exceed_data_limit(client, monkeypatch): project = Project.query.filter_by( name=test_project, workspace_id=test_workspace_id ).first() user_disk_space = sum(p.disk_usage for p in project.creator.projects) # set basic storage that it is fully used - Configuration.GLOBAL_STORAGE = user_disk_space + monkeypatch.setattr(Configuration, "GLOBAL_STORAGE", user_disk_space) url = "/v1/project/push/{}/{}".format(test_workspace_name, test_project) changes = _get_changes(test_project_dir) @@ -1298,7 +1301,11 @@ def test_exceed_data_limit(client): assert resp.status_code == 200 # tight limit again - Configuration.GLOBAL_STORAGE = sum(p.disk_usage for p in project.creator.projects) + monkeypatch.setattr( + Configuration, + "GLOBAL_STORAGE", + sum(p.disk_usage for p in project.creator.projects), + ) db.session.commit() changes["removed"] = changes["updated"] = [] @@ -1326,7 +1333,7 @@ def test_exceed_data_limit(client): headers=json_headers, ) # reset - Configuration.GLOBAL_STORAGE = 104857600 + monkeypatch.setattr(Configuration, "GLOBAL_STORAGE", 104857600) assert resp.status_code == 200 @@ -1349,7 +1356,7 @@ def remove_transaction(transaction_id): shutil.rmtree(upload_dir, ignore_errors=True) -def test_chunk_upload(client, app): +def test_chunk_upload(client, app, monkeypatch): changes = _get_changes(test_project_dir) upload, upload_dir = create_transaction("mergin", changes) chunk_id = upload.changes["added"][0]["chunks"][0] @@ -1365,7 +1372,7 @@ def test_chunk_upload(client, app): assert os.path.exists(os.path.join(upload_dir, "chunks", chunk_id)) # tests to send bigger chunk than allowed - app.config["MAX_CHUNK_SIZE"] = 10 * CHUNK_SIZE + monkeypatch.setitem(app.config, "MAX_CHUNK_SIZE", 10 * CHUNK_SIZE) with open(os.path.join(test_project_dir, "test_dir", "test4.txt"), "rb") as file: data = file.read(11 * CHUNK_SIZE) headers = {"Content-Type": "application/octet-stream"} @@ -1883,7 +1890,7 @@ def test_push_force_update_size_limit(client): @pytest.mark.parametrize("data,username,expected", clone_project_data) -def test_clone_project(client, data, username, expected): +def test_clone_project(client, data, username, expected, monkeypatch): # create new version with diff upload, upload_dir = create_transaction( "mergin", _get_changes_with_diff(test_project_dir) @@ -1897,7 +1904,7 @@ def test_clone_project(client, data, username, expected): if username != "mergin": user = add_user(username, "bar") # allow user to write into workspace, e.g. create project - Configuration.GLOBAL_ADMIN = True + monkeypatch.setattr(Configuration, "GLOBAL_ADMIN", True) # switch default user client.get(url_for("/.mergin_auth_controller_logout")) client.post( @@ -1912,7 +1919,7 @@ def test_clone_project(client, data, username, expected): ).first() user_disk_space = sum(p.disk_usage for p in project.creator.projects) # set basic storage that it is fully used - Configuration.GLOBAL_STORAGE = user_disk_space + monkeypatch.setattr(Configuration, "GLOBAL_STORAGE", user_disk_space) resp = client.post(endpoint, data=json.dumps(data), headers=json_headers) assert resp.status_code == expected if expected == 422: @@ -1960,10 +1967,10 @@ def test_clone_project(client, data, username, expected): # cleanup shutil.rmtree(project.storage.project_dir) - Configuration.GLOBAL_STORAGE = 104857600 + monkeypatch.setattr(Configuration, "GLOBAL_STORAGE", 104857600) -def test_optimize_storage(app, client, diff_project): +def test_optimize_storage(app, client, diff_project, monkeypatch): """Test optimize storage for geopackages which could be restored from diffs Scenarios for test projects: v1/base.gpkg create - basefile (no optimize) @@ -2004,7 +2011,7 @@ def test_optimize_storage(app, client, diff_project): assert os.path.exists(optimize_v4) and os.path.exists(optimize_v6) # remove constraint on file age - SyncConfiguration.FILE_EXPIRATION = 0 + monkeypatch.setattr(SyncConfiguration, "FILE_EXPIRATION", 0) optimize_storage(diff_project.id) assert not (os.path.exists(optimize_v4) and os.path.exists(optimize_v6)) # we keep latest file, basefiles must stay (either very first one, or any other with forced update) @@ -2329,7 +2336,7 @@ def test_orphan_project(client): assert not next((p for p in created_projects if p.name == "orphan"), None) -def test_inactive_project(client, diff_project): +def test_inactive_project(client, diff_project, monkeypatch): """Project set for removal is not listed and can not be updated""" user = add_user("tests", "tests") diff_project.set_role(user.id, ProjectRole.OWNER) @@ -2406,7 +2413,7 @@ def test_inactive_project(client, diff_project): assert resp.status_code == 404 # create project with the same name - Configuration.GLOBAL_ADMIN = True + monkeypatch.setattr(Configuration, "GLOBAL_ADMIN", True) data = {"name": diff_project.name} resp = client.post( f"/v1/project/{diff_project.workspace.name}", diff --git a/server/mergin/tests/test_public_api_v2.py b/server/mergin/tests/test_public_api_v2.py index 76fc6c26..66fa6507 100644 --- a/server/mergin/tests/test_public_api_v2.py +++ b/server/mergin/tests/test_public_api_v2.py @@ -145,7 +145,7 @@ def test_rename_project(client): assert response.json["code"] == "InvalidProjectName" -def test_project_members(client): +def test_project_members(client, monkeypatch): """Test CRUD endpoints for direct project members""" project = Project.query.filter_by( workspace_id=test_workspace_id, name=test_project @@ -170,9 +170,9 @@ def test_project_members(client): assert response.json["id"] == user.id assert response.json["project_role"] == role - Configuration.GLOBAL_READ = 0 - Configuration.GLOBAL_WRITE = 0 - Configuration.GLOBAL_ADMIN = 0 + monkeypatch.setattr(Configuration, "GLOBAL_READ", 0) + monkeypatch.setattr(Configuration, "GLOBAL_WRITE", 0) + monkeypatch.setattr(Configuration, "GLOBAL_ADMIN", 0) response = client.get(url) assert len(response.json) == 2 member = next(u for u in response.json if u["id"] == user.id) @@ -192,7 +192,7 @@ def test_project_members(client): assert len(response.json) == 1 # test access only by workspace role - Configuration.GLOBAL_READ = 1 + monkeypatch.setattr(Configuration, "GLOBAL_READ", 1) response = client.get(url) member = next(u for u in response.json if u["id"] == user.id) assert not member["project_role"] @@ -201,7 +201,7 @@ def test_project_members(client): # access provided by workspace role cannot be removed directly response = client.delete(url + f"/{user.id}") assert response.status_code == 404 - Configuration.GLOBAL_READ = 0 + monkeypatch.setattr(Configuration, "GLOBAL_READ", 0) def test_file_diff_download(client, diff_project): @@ -731,9 +731,15 @@ def test_project_version_delta_changes(client, diff_project: Project): delta = diff_project.get_delta_changes(8, latest_version.name + 6) assert len(delta) == 2 # file history in 9.th version is basefile - fh = FileHistory.query.filter_by( - project_version_name=latest_version.name - 1 - ).first() + fh = ( + FileHistory.query.join(ProjectFilePath) + .filter( + ProjectFilePath.project_id == diff_project.id, + ProjectFilePath.path == "test.gpkg", + FileHistory.project_version_name == latest_version.name - 1, + ) + .first() + ) # testing constistency of db entries FileDiff and ProjectVersionDelta test_gpkg_checkpoint = FileDiff.query.filter_by(basefile_id=fh.id, rank=1).first() assert test_gpkg_checkpoint @@ -1187,15 +1193,14 @@ def test_create_version_permanent_error_takes_priority(client): assert project.latest_version == 1 -def test_upload_chunk(client): +def test_upload_chunk(client, monkeypatch): """Test pushing a chunk to a project""" project = Project.query.filter_by( workspace_id=test_workspace_id, name=test_project ).first() url = f"/v2/projects/{project.id}/chunks" - client.application.config["MAX_CHUNK_SIZE"] = ( - 1024 # Set a small max chunk size for testing - ) + # Set a small max chunk size for testing + monkeypatch.setitem(client.application.config, "MAX_CHUNK_SIZE", 1024) max_chunk_size = client.application.config["MAX_CHUNK_SIZE"] response = client.post( diff --git a/server/mergin/tests/test_statistics.py b/server/mergin/tests/test_statistics.py index 189b04b0..f02d664f 100644 --- a/server/mergin/tests/test_statistics.py +++ b/server/mergin/tests/test_statistics.py @@ -18,7 +18,7 @@ from .utils import Response, add_user, create_project, create_workspace -def test_send_statistics(app, caplog): +def test_send_statistics(app, caplog, monkeypatch): """Test job to send usage statistics. Test opt-out, repeated request and 3rd party service errors. """ @@ -28,8 +28,8 @@ def test_send_statistics(app, caplog): with patch("requests.post") as mock: mock.return_value = Response(True, {}) - app.config["COLLECT_STATISTICS"] = False - app.config["CONTACT_EMAIL"] = "test@example.com" + monkeypatch.setitem(app.config, "COLLECT_STATISTICS", False) + monkeypatch.setitem(app.config, "CONTACT_EMAIL", "test@example.com") user = add_user() admin = User.query.filter_by(username="mergin").first() # create new project @@ -42,7 +42,7 @@ def test_send_statistics(app, caplog): assert not info.last_reported # success - app.config["COLLECT_STATISTICS"] = True + monkeypatch.setitem(app.config, "COLLECT_STATISTICS", True) task = send_statistics.s().apply() assert task.status == "SUCCESS" ts = info.last_reported @@ -121,10 +121,10 @@ def test_send_statistics(app, caplog): assert info.last_reported -def test_save_statistics(app, client): +def test_save_statistics(app, client, monkeypatch): """Test save statistics celery job""" info = MerginInfo.query.first() - app.config["CONTACT_EMAIL"] = "test@example.com" + monkeypatch.setitem(app.config, "CONTACT_EMAIL", "test@example.com") assert MerginStatistics.query.count() == 0 save_statistics.s().apply() assert MerginStatistics.query.count() == 1 @@ -134,7 +134,7 @@ def test_save_statistics(app, client): assert stats.data == asdict(stats_json_data) -def test_download_report(app, client): +def test_download_report(app, client, monkeypatch): """Test download report endpoint""" url = "/app/admin/report" resp = client.get(url) @@ -144,7 +144,7 @@ def test_download_report(app, client): resp = client.get(f"{url}?date_from=2021-01-01T00:00:00&date_to=2021-01-01") assert resp.status_code == 400 - app.config["CONTACT_EMAIL"] = "test@example.com" + monkeypatch.setitem(app.config, "CONTACT_EMAIL", "test@example.com") save_statistics.s().apply() resp = client.get( f"{url}?date_from=2021-01-01&date_to={datetime.now(timezone.utc).strftime('%Y-%m-%d')}" diff --git a/server/mergin/tests/test_utils.py b/server/mergin/tests/test_utils.py index 288577b0..7e96cabf 100644 --- a/server/mergin/tests/test_utils.py +++ b/server/mergin/tests/test_utils.py @@ -252,9 +252,9 @@ def test_has_trailing_space(path, path_platform, result): assert has_trailing_space(path) is result -def test_get_x_accell_uri(client): +def test_get_x_accell_uri(client, monkeypatch): """Test get_x_accell_uri""" - client.application.config["LOCAL_PROJECTS"] = "/data/" + monkeypatch.setitem(client.application.config, "LOCAL_PROJECTS", "/data/") # Input URL parts url_parts = ("/data", "archive", "project1", "file.txt") # Expected result diff --git a/server/mergin/tests/test_workspace.py b/server/mergin/tests/test_workspace.py index 1127b414..f1b1df0e 100644 --- a/server/mergin/tests/test_workspace.py +++ b/server/mergin/tests/test_workspace.py @@ -14,7 +14,7 @@ from .utils import add_user, login, create_project -def test_workspace_implementation(client): +def test_workspace_implementation(client, monkeypatch): """Test global implementation of workspace""" handler = GlobalWorkspaceHandler() user = add_user("user", "password") @@ -23,9 +23,9 @@ def test_workspace_implementation(client): ws = handler.get_by_name(Configuration.GLOBAL_WORKSPACE) assert ws.storage == Configuration.GLOBAL_STORAGE assert ws.is_active - Configuration.GLOBAL_READ = False - Configuration.GLOBAL_WRITE = False - Configuration.GLOBAL_ADMIN = False + monkeypatch.setattr(Configuration, "GLOBAL_READ", False) + monkeypatch.setattr(Configuration, "GLOBAL_WRITE", False) + monkeypatch.setattr(Configuration, "GLOBAL_ADMIN", False) assert ws.get_user_role(user) == WorkspaceRole.GUEST assert not ws.user_has_permissions(user, "owner") assert not ws.user_has_permissions(user, "read") @@ -36,13 +36,13 @@ def test_workspace_implementation(client): assert handler.list_active()[0].name == ws.name assert len(handler.list_user_workspaces(user.username)) == 1 assert handler.list_user_workspaces(user.username)[0].name == ws.name - Configuration.GLOBAL_READ = True + monkeypatch.setattr(Configuration, "GLOBAL_READ", True) assert ws.user_has_permissions(user, "read") assert not ws.user_has_permissions(user, "write") # admin is counted as editor assert handler.server_editors_count() == 1 # change global flag to enable user push to any project - Configuration.GLOBAL_WRITE = True + monkeypatch.setattr(Configuration, "GLOBAL_WRITE", True) assert ws.user_has_permissions(user, "write") assert ws.user_has_permissions(user, "read") assert handler.server_editors_count() == 2 @@ -55,7 +55,7 @@ def test_workspace_implementation(client): assert not ws.user_has_permissions(user, "admin") assert not ws.user_has_permissions(user, "owner") - Configuration.GLOBAL_ADMIN = True + monkeypatch.setattr(Configuration, "GLOBAL_ADMIN", True) # create project with dummy file to count for workspace usage project = create_project("test_permissions", ws, user) latest_version = project.get_latest_version() @@ -98,7 +98,7 @@ def test_workspace_implementation(client): assert handler.monthly_contributors_count() == 2 -def test_workspace(client): +def test_workspace(client, monkeypatch): """Test get global workspace""" resp = client.get("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/v1/workspaces") assert len(resp.json) == 1 @@ -113,9 +113,9 @@ def test_workspace(client): assert client.get("/v1/workspace/2").status_code == 404 # login as another user - Configuration.GLOBAL_READ = False - Configuration.GLOBAL_WRITE = False - Configuration.GLOBAL_ADMIN = False + monkeypatch.setattr(Configuration, "GLOBAL_READ", False) + monkeypatch.setattr(Configuration, "GLOBAL_WRITE", False) + monkeypatch.setattr(Configuration, "GLOBAL_ADMIN", False) user = add_user("user", "password") login(client, "user", "password") resp = client.get("/v1/workspace/1") diff --git a/server/mergin/tests/utils.py b/server/mergin/tests/utils.py index 52b3e970..8341fbc9 100644 --- a/server/mergin/tests/utils.py +++ b/server/mergin/tests/utils.py @@ -15,6 +15,7 @@ import os from dateutil.tz import tzlocal from pygeodiff import GeoDiff +from sqlalchemy import create_engine, text from ..auth.models import User from ..sync.utils import generate_location, generate_checksum @@ -33,6 +34,45 @@ CHUNK_SIZE = 1024 +def create_db() -> None: + """Create database for tests if it does not exist yet, e.g. a separate one for each xdist worker""" + url = db.engine.url + engine = create_engine(url.set(database="postgres"), isolation_level="AUTOCOMMIT") + with engine.connect() as conn: + exists = conn.execute( + text("SELECT 1 FROM pg_database WHERE datname = :name"), + {"name": url.database}, + ).scalar() + if not exists: + conn.execute(text(f'CREATE DATABASE "{url.database}"')) + engine.dispose() + + +def clean_db() -> None: + """Delete all rows from db tables and restart their sequences, so tests can rely on ids. + Tables are processed in reversed dependency order, so referencing rows are deleted first. + """ + tables = [ + f'"{t.schema or "public"}"."{t.name}"' + for t in reversed(db.metadata.sorted_tables) + ] + # delete is much faster than truncate for tables with only few rows. + db.session.execute(text("; ".join(f"DELETE FROM {t}" for t in tables))) + # restart sequences of serial and identity columns + db.session.execute( + text( + """ + SELECT setval(s.oid, 1, false) + FROM pg_class s + JOIN pg_depend d ON d.objid = s.oid AND d.classid = 'pg_class'::regclass AND d.deptype IN ('a', 'i') + WHERE s.relkind = 'S' AND d.refobjid = ANY(CAST(:tables AS regclass[])) + """ + ), + {"tables": tables}, + ) + db.session.commit() + + def add_user(username="random", password="random", is_admin=False) -> User: """Helper function to create not-privileged user. Associated user workspace is created with db hook.