""" This module contains fixtures for setting up a test database for integration tests. The test database is a copy of the public database, but empty. This allows tests to be performed without affecting the public database, but using the same schema and structure. The auto-increment values for all tables are reset to 1 after the schema is imported. """ import os import subprocess import pymysql import pytest from src.backend import logger from src.backend.environment_vars import MYSQL_CREDENTIALS logger = logger.new_logger(__name__) # Database configuration constants SOURCE_DB_NAME: str = "public" TEST_DB_NAME: str = "test" # Paths to temporary files which are necessary for the fixtures. These files # will be created/overwritten during the tests and removed afterwards. It is # strongly recommended to place these files in the /tmp directory. TEST_SCHEMA_FILE: str = "/tmp/__test_schema.sql" MYSQL_CONFIG_FILE: str = "/tmp/.my.cnf" # Database credentials DB_USER: str = MYSQL_CREDENTIALS["user"] DB_PASS: str = MYSQL_CREDENTIALS["password"] DB_HOST: str = MYSQL_CREDENTIALS["host"] def _new_db_connection(): return pymysql.connect(user=DB_USER, password=DB_PASS, host=DB_HOST) @pytest.fixture(scope="session") def use_mysql_config_file(): """This fixture creates a MySQL configuration file that can be used to connect to the database without specifying the credentials on the command line. The reason for this is to avoid exposing the credentials in the command history, and additionally to prevent the console warning about the password being visible in the command line if providing the credentials directly. The configuration file is removed after the tests are done. """ # Create a MySQL configuration file with the credentials. The format must # be strictly compliant with the MySQL configuration file format or # there will be an error. content: str = f"""[client] user = {DB_USER} password = "{DB_PASS}" host = {DB_HOST} """ with open(MYSQL_CONFIG_FILE, "w") as file: file.write(content) # Secure the configuration file by setting the permissions to read # and write for the user only os.chmod(MYSQL_CONFIG_FILE, 0o600) logger.debug(f"Created MySQL configuration file: {MYSQL_CONFIG_FILE}") yield MYSQL_CONFIG_FILE try: os.remove(MYSQL_CONFIG_FILE) logger.debug(f"Cleaned up MySQL configuration file: {MYSQL_CONFIG_FILE}") except FileNotFoundError: logger.debug(f"Configuration file no longer exists: {MYSQL_CONFIG_FILE}") @pytest.fixture(scope="session") def dump_db_schema(use_mysql_config_file): """This fixture dumps the schema of the public database, without data. The schema is saved to a file that can be used to import the schema into a new test database. The schema file is removed after the tests are done. """ try: dump_cmd = [ "mysqldump", f"--defaults-file={use_mysql_config_file}", "--no-data", SOURCE_DB_NAME, ] with open(TEST_SCHEMA_FILE, "w") as file: process = subprocess.run( dump_cmd, stdout=file, stderr=subprocess.PIPE, text=True, check=False ) if process.returncode != 0: pytest.fail(f"Failed to dump schema: {process.stderr}") except subprocess.CalledProcessError as ex: pytest.fail(f"Failed to dump schema: {ex}") logger.debug( f"Dumped schema of database '{SOURCE_DB_NAME}' to file: {TEST_SCHEMA_FILE}" ) yield # Clean up test schema file try: os.remove(TEST_SCHEMA_FILE) logger.debug(f"Cleaned up schema file: {TEST_SCHEMA_FILE}") except FileNotFoundError: logger.debug(f"Schema file no longer exists: {TEST_SCHEMA_FILE}") def _drop_test_db(): """Drop the test database if it exists.""" with _new_db_connection() as conn: with conn.cursor() as cursor: cursor.execute(f"DROP DATABASE IF EXISTS {TEST_DB_NAME}") conn.commit() @pytest.fixture def use_test_db(mocker, dump_db_schema): """This fixture creates a new test database and uses it for the tests. This ensures that tests can be performed without affecting the public database but using the same schema and structure. The data from the public database is not copied to the test database, instead the database starts empty. The test database is dropped after each test run. """ with _new_db_connection() as conn: try: with conn.cursor() as cursor: # Drop test database if it exists and create a fresh one cursor.execute(f"DROP DATABASE IF EXISTS {TEST_DB_NAME}") cursor.execute(f"CREATE DATABASE {TEST_DB_NAME}") conn.commit() # Import the schema into the test database import_cmd = [ "mysql", f"--defaults-file={MYSQL_CONFIG_FILE}", TEST_DB_NAME, ] with open(TEST_SCHEMA_FILE, "r") as file: process = subprocess.run( import_cmd, stdin=file, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False, ) if process.returncode != 0: pytest.fail( f"Failed to import schema into test database: {process.stderr}" ) # Use the new database cursor.execute(f"USE {TEST_DB_NAME}") # Reset auto-increment values for all tables. This is necessary because # the schema dump includes the auto-increment values from the source # database. cursor.execute("SHOW TABLES") tables = cursor.fetchall() for (table_name,) in tables: cursor.execute(f"ALTER TABLE `{table_name}` AUTO_INCREMENT = 1") conn.commit() except (pymysql.Error, Exception) as ex: pytest.fail(f"Failed to configure the test database: {ex}") fn = pymysql.connect # Keep a reference to the original function def connect_to_test_db(*args, **kwargs): """Connect to the test database.""" kwargs["database"] = TEST_DB_NAME return fn(*args, **kwargs) # Patch pymysql.connect to always connect to the test database # during the lifetime of the fixture. mocker.patch.object(pymysql, "connect", side_effect=connect_to_test_db) yield TEST_DB_NAME _drop_test_db() # Export all functions not starting with an underscore __all__ = [name for name in locals() if not name.startswith("_")]