diff --git a/app/lib/settings.py b/app/lib/settings.py index 6589fb6..e3ceaa0 100644 --- a/app/lib/settings.py +++ b/app/lib/settings.py @@ -91,6 +91,7 @@ class TestingSettings(BaseEnvSettings): DB_HOST: str = "localhost" DB_PORT: int = 5432 + DB_TEMPLATE_NAME: str = "db-template-name" DB_NAME: str = "test_db-name" DB_USER: str = "db-user" DB_PASSWORD: str = "db-password" diff --git a/app/lib/sqlalchemy_plugin.py b/app/lib/sqlalchemy_plugin.py index cf18e69..75f1c2c 100644 --- a/app/lib/sqlalchemy_plugin.py +++ b/app/lib/sqlalchemy_plugin.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import dataclass from typing import TYPE_CHECKING, cast from uuid import UUID @@ -13,7 +14,12 @@ from litestar.contrib.sqlalchemy.plugins.init.config.common import ( ) from litestar.utils import delete_litestar_scope_state, get_litestar_scope_state from sqlalchemy import event -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) from sqlalchemy.pool import NullPool from app.lib import settings @@ -37,25 +43,47 @@ def _default(val: Any) -> str: raise TypeError() -db_connection_url = sqlalchemy.engine.URL.create( - drivername="postgresql+asyncpg", - username=settings.db.USER, - password=settings.db.PASSWORD, - host=settings.db.HOST, - port=settings.db.PORT, - database=settings.db.NAME, +@dataclass +class DBConnectionSettings: + username: str + password: str + host: str + port: int + database: str + + +def create_db_engine(connection_settings: DBConnectionSettings) -> AsyncEngine: + db_connection_url = sqlalchemy.engine.URL.create( + drivername="postgresql+asyncpg", + username=connection_settings.username, + password=connection_settings.password, + host=connection_settings.host, + port=connection_settings.port, + database=connection_settings.database, + ) + return create_async_engine( + db_connection_url, + echo=settings.db.ECHO, + echo_pool=settings.db.ECHO_POOL, + json_serializer=msgspec.json.Encoder(enc_hook=_default), + max_overflow=settings.db.POOL_MAX_OVERFLOW, + pool_size=settings.db.POOL_SIZE, + pool_timeout=settings.db.POOL_TIMEOUT, + poolclass=NullPool if settings.db.POOL_DISABLE else None, + ) + + +engine = create_db_engine( + connection_settings=DBConnectionSettings( + username=settings.db.USER, + password=settings.db.PASSWORD, + host=settings.db.HOST, + port=settings.db.PORT, + database=settings.db.NAME, + ) ) -engine = create_async_engine( - db_connection_url, - echo=settings.db.ECHO, - echo_pool=settings.db.ECHO_POOL, - json_serializer=msgspec.json.Encoder(enc_hook=_default), - max_overflow=settings.db.POOL_MAX_OVERFLOW, - pool_size=settings.db.POOL_SIZE, - pool_timeout=settings.db.POOL_TIMEOUT, - poolclass=NullPool if settings.db.POOL_DISABLE else None, -) + """Configure via DatabaseSettings. Overrides default JSON diff --git a/app/lib/test_extras/__init__.py b/app/lib/test_extras/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/lib/test_extras/db_plugins.py b/app/lib/test_extras/db_plugins.py new file mode 100644 index 0000000..7eba787 --- /dev/null +++ b/app/lib/test_extras/db_plugins.py @@ -0,0 +1,107 @@ +from asyncio import current_task +from typing import AsyncGenerator, Callable, Generator + +import pytest +from _pytest.fixtures import FixtureRequest +from _pytest.tmpdir import TempPathFactory +from sqlalchemy import create_engine +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_scoped_session +from sqlalchemy.orm import sessionmaker + +import settings_test +from televend_core.databases.televend_repositories.mapper import start_televend_mappers +from televend_core.databases.televend_repositories.repository import ( + TelevendRepositoryManager, +) +from televend_core.test_extras import factory_boy_utils +from televend_core.test_extras.database_utils import ( + DatabaseConfig, + configure_database, + generate_async_engine, + generate_test_database_name, + xdist_lock_pytest, +) + +TELEVEND_TEMPLATE_DATABASE_CONFIG = DatabaseConfig( + engine="postgresql", + user=settings_test.POSTGRES_TEST_USER, + password=settings_test.POSTGRES_TEST_PASSWORD, + name="test_televend", + host=settings_test.POSTGRES_HOST, + port=settings_test.POSTGRES_PORT, +) + + +@pytest.fixture(scope="session") +def televend_fixture_setup(worker_id: int, tmp_path_factory: TempPathFactory) -> None: + start_televend_mappers("selectin") + + # get the temp directory shared by all workers + # When xdist is disabled (running with -n0 for example), then worker_id will return "master" + if worker_id == "master": + root_tmp_dir = tmp_path_factory.getbasetemp() + else: + root_tmp_dir = tmp_path_factory.getbasetemp().parent + + file_path = root_tmp_dir / "televend_fixture.txt" + lock_path = file_path.with_suffix(".lock") + + xdist_lock_pytest( + file_path=file_path, + lock_path=lock_path, + fn=lambda: configure_database( + connection_string=TELEVEND_TEMPLATE_DATABASE_CONFIG.to_connection_string(), + database_type="cloud", + ), + ) + + +@pytest.fixture(scope="function") +def televend_test_database_name(request: FixtureRequest) -> Generator[str, None, None]: + test_database_name = generate_test_database_name(test_name=request.node.originalname) + yield test_database_name + + +@pytest.fixture(scope="function") +async def async_televend_engine( + televend_fixture_setup: Callable, televend_test_database_name: str +) -> AsyncGenerator[AsyncEngine, None]: + async for async_engine in generate_async_engine( + template_database_config=TELEVEND_TEMPLATE_DATABASE_CONFIG, + test_database_name=televend_test_database_name, + ): + yield async_engine + + +@pytest.fixture(scope="function") +async def async_televend_session( + async_televend_engine: AsyncEngine, +) -> AsyncGenerator[AsyncSession, None]: + # Prepare a new, clean async_session, one sync async_session for factory boy and one async for normal usage + sync_engine = create_engine( + url=async_televend_engine.url.set(drivername="postgresql"), pool_size=1, max_overflow=1 + ) + factory_boy_utils.TelevendSession.configure(bind=sync_engine) + + televend_async_session_factory = sessionmaker( + bind=async_televend_engine, expire_on_commit=False, class_=AsyncSession + ) + televend_async_session = async_scoped_session( + televend_async_session_factory, scopefunc=current_task + ) + session = televend_async_session() + yield session + + factory_boy_utils.TelevendSession.remove() + await session.rollback() # to avoid coroutine 'Transaction.rollback' was never awaited warning + await televend_async_session.remove() + + +@pytest.fixture(scope="function") +def televend_repository_manager( + async_televend_session: AsyncSession, async_televend_engine: AsyncEngine +) -> TelevendRepositoryManager: + return TelevendRepositoryManager( + async_session=async_televend_session, + async_engine=async_televend_engine, + ) diff --git a/app/lib/test_extras/db_setup.py b/app/lib/test_extras/db_setup.py new file mode 100644 index 0000000..0b8fe63 --- /dev/null +++ b/app/lib/test_extras/db_setup.py @@ -0,0 +1,93 @@ +from typing import Protocol + +import asyncpg +import sqlalchemy +from asyncpg import Connection, DuplicateDatabaseError, InvalidCatalogNameError + +from migrate import DatabaseConfig, migrate + + +class TestingSettingsInitOptions(Protocol): + DB_HOST: str + DB_PORT: int + DB_TEMPLATE_NAME: str + DB_NAME: str + DB_USER: str + DB_PASSWORD: str + DROP_DATABASE_BEFORE_TESTS: bool + DROP_DATABASE_AFTER_TESTS: bool + + +class TestingDatabaseSetup: + def __init__(self, options: TestingSettingsInitOptions): + self.options = options + + db_connection_url = sqlalchemy.engine.URL.create( + drivername="postgresql", + username=self.options.DB_USER, + password=self.options.DB_PASSWORD, + host=self.options.DB_HOST, + port=self.options.DB_PORT, + ) + self.connection_str = db_connection_url.render_as_string(hide_password=False) + + async def _create_template_db(self, conn: Connection): + query = f"CREATE DATABASE {self.options.DB_TEMPLATE_NAME}" + try: + await conn.execute(query) + except DuplicateDatabaseError: + ... + + async def _drop_template_db(self, conn: Connection): + query = f"DROP DATABASE {self.options.DB_TEMPLATE_NAME}" + try: + await conn.execute(query) + except InvalidCatalogNameError: + ... + + async def _create_test_db(self, conn: Connection): + query = f"CREATE DATABASE {self.options.DB_NAME} TEMPLATE {self.options.DB_TEMPLATE_NAME}" + try: + await conn.execute(query) + except DuplicateDatabaseError: + ... + + async def _drop_test_db(self, conn: Connection): + query = f"DROP DATABASE {self.options.DB_NAME}" + try: + await conn.execute(query) + except InvalidCatalogNameError: + ... + + def _migrate_template_database(self): + conf = DatabaseConfig( + HOST=self.options.DB_HOST, + PORT=self.options.DB_PORT, + NAME=self.options.DB_TEMPLATE_NAME, + USER=self.options.DB_USER, + PASSWORD=self.options.DB_PASSWORD, + ) + migrate(conf) + + async def init_db(self): + conn = await asyncpg.connect(self.connection_str, database="postgres") + + if self.options.DROP_DATABASE_BEFORE_TESTS: + await self._drop_template_db(conn) + + await self._create_template_db(conn) + await self._create_test_db(conn) + await conn.close() + + if self.options.DROP_DATABASE_BEFORE_TESTS: + self._migrate_template_database() + + async def tear_down_db(self): + conn = await asyncpg.connect(self.connection_str, database="postgres") + + await self._drop_test_db(conn) + + if self.options.DROP_DATABASE_AFTER_TESTS: + await self._drop_template_db(conn) + + await conn.close() diff --git a/migrate.py b/migrate.py index 03e6ecf..fc4e5ba 100644 --- a/migrate.py +++ b/migrate.py @@ -1,5 +1,6 @@ import subprocess from dataclasses import dataclass +from pathlib import Path from app.lib import settings @@ -14,13 +15,18 @@ class DatabaseConfig: def migrate(conf: DatabaseConfig) -> None: + script_path = Path(__file__).parent + migrations_path = script_path / "migrations" + conf_file = migrations_path / "flyway.conf" + args = [ settings.db.FLYWAY_PATH, "migrate", f"-url=jdbc:postgresql://{conf.HOST}:{conf.PORT}/{conf.NAME}", f"-user={conf.USER}", f"-password={conf.PASSWORD}", - "-configFiles=migrations/flyway.conf", + f"-configFiles={conf_file}", + f"-locations=filesystem:{migrations_path}", ] subprocess.run( diff --git a/poetry.lock b/poetry.lock index d58a12e..1649c7f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. [[package]] name = "annotated-types" @@ -171,6 +171,7 @@ files = [ {file = "greenlet-2.0.2-cp27-cp27m-win32.whl", hash = "sha256:6c3acb79b0bfd4fe733dff8bc62695283b57949ebcca05ae5c129eb606ff2d74"}, {file = "greenlet-2.0.2-cp27-cp27m-win_amd64.whl", hash = "sha256:283737e0da3f08bd637b5ad058507e578dd462db259f7f6e4c5c365ba4ee9343"}, {file = "greenlet-2.0.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:d27ec7509b9c18b6d73f2f5ede2622441de812e7b1a80bbd446cb0633bd3d5ae"}, + {file = "greenlet-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d967650d3f56af314b72df7089d96cda1083a7fc2da05b375d2bc48c82ab3f3c"}, {file = "greenlet-2.0.2-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:30bcf80dda7f15ac77ba5af2b961bdd9dbc77fd4ac6105cee85b0d0a5fcf74df"}, {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26fbfce90728d82bc9e6c38ea4d038cba20b7faf8a0ca53a9c07b67318d46088"}, {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9190f09060ea4debddd24665d6804b995a9c122ef5917ab26e1566dcc712ceeb"}, @@ -179,6 +180,7 @@ files = [ {file = "greenlet-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:76ae285c8104046b3a7f06b42f29c7b73f77683df18c49ab5af7983994c2dd91"}, {file = "greenlet-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:2d4686f195e32d36b4d7cf2d166857dbd0ee9f3d20ae349b6bf8afc8485b3645"}, {file = "greenlet-2.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c4302695ad8027363e96311df24ee28978162cdcdd2006476c43970b384a244c"}, + {file = "greenlet-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d4606a527e30548153be1a9f155f4e283d109ffba663a15856089fb55f933e47"}, {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c48f54ef8e05f04d6eff74b8233f6063cb1ed960243eacc474ee73a2ea8573ca"}, {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a1846f1b999e78e13837c93c778dcfc3365902cfb8d1bdb7dd73ead37059f0d0"}, {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a06ad5312349fec0ab944664b01d26f8d1f05009566339ac6f63f56589bc1a2"}, @@ -208,6 +210,7 @@ files = [ {file = "greenlet-2.0.2-cp37-cp37m-win32.whl", hash = "sha256:3f6ea9bd35eb450837a3d80e77b517ea5bc56b4647f5502cd28de13675ee12f7"}, {file = "greenlet-2.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:7492e2b7bd7c9b9916388d9df23fa49d9b88ac0640db0a5b4ecc2b653bf451e3"}, {file = "greenlet-2.0.2-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:b864ba53912b6c3ab6bcb2beb19f19edd01a6bfcbdfe1f37ddd1778abfe75a30"}, + {file = "greenlet-2.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1087300cf9700bbf455b1b97e24db18f2f77b55302a68272c56209d5587c12d1"}, {file = "greenlet-2.0.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:ba2956617f1c42598a308a84c6cf021a90ff3862eddafd20c3333d50f0edb45b"}, {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3a569657468b6f3fb60587e48356fe512c1754ca05a564f11366ac9e306526"}, {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8eab883b3b2a38cc1e050819ef06a7e6344d4a990d24d45bc6f2cf959045a45b"}, @@ -216,6 +219,7 @@ files = [ {file = "greenlet-2.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b0ef99cdbe2b682b9ccbb964743a6aca37905fda5e0452e5ee239b1654d37f2a"}, {file = "greenlet-2.0.2-cp38-cp38-win32.whl", hash = "sha256:b80f600eddddce72320dbbc8e3784d16bd3fb7b517e82476d8da921f27d4b249"}, {file = "greenlet-2.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:4d2e11331fc0c02b6e84b0d28ece3a36e0548ee1a1ce9ddde03752d9b79bba40"}, + {file = "greenlet-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8512a0c38cfd4e66a858ddd1b17705587900dd760c6003998e9472b77b56d417"}, {file = "greenlet-2.0.2-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:88d9ab96491d38a5ab7c56dd7a3cc37d83336ecc564e4e8816dbed12e5aaefc8"}, {file = "greenlet-2.0.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:561091a7be172ab497a3527602d467e2b3fbe75f9e783d8b8ce403fa414f71a6"}, {file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:971ce5e14dc5e73715755d0ca2975ac88cfdaefcaab078a284fea6cfabf866df"}, @@ -699,6 +703,24 @@ pluggy = ">=0.12,<2.0" [package.extras] testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-asyncio" +version = "0.21.1" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-asyncio-0.21.1.tar.gz", hash = "sha256:40a7eae6dded22c7b604986855ea48400ab15b069ae38116e8c01238e9eeb64d"}, + {file = "pytest_asyncio-0.21.1-py3-none-any.whl", hash = "sha256:8666c1c8ac02631d7c51ba282e0c69a8a452b211ffedf2599099845da5c5c37b"}, +] + +[package.dependencies] +pytest = ">=7.0.0" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] + [[package]] name = "python-dateutil" version = "2.8.2" @@ -739,6 +761,7 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -746,8 +769,15 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -764,6 +794,7 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -771,6 +802,7 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -849,7 +881,7 @@ files = [ ] [package.dependencies] -greenlet = {version = "!=0.4.17", markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""} +greenlet = {version = "!=0.4.17", markers = "platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\""} typing-extensions = ">=4.2.0" [package.extras] @@ -942,4 +974,4 @@ anyio = ">=3.0.0" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "28c7f9f4b78e7d602b076108d3138c854cd1a1bc22c447987a6aedbba6300dcd" +content-hash = "2f47c374edfb8ee537dd4eeb7406bd443bdf0ed3bdd760e764a363ffd1b4650f" diff --git a/pyproject.toml b/pyproject.toml index 46ce95a..4998ad0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ pydantic-settings = "^2.0.3" [tool.poetry.group.dev.dependencies] pytest = "^7.4.2" watchfiles = "^0.20.0" +pytest-asyncio = "^0.21.1" [build-system] requires = ["poetry-core"] diff --git a/settings_test.py b/settings_test.py new file mode 100644 index 0000000..d85518a --- /dev/null +++ b/settings_test.py @@ -0,0 +1,11 @@ +import logging + +POSTGRES_HOST: str = "localhost" +POSTGRES_PORT: int = 5432 +POSTGRES_TEST_USER = "addressbook" +POSTGRES_TEST_PASSWORD = "addressbook" + +LOG_PATH = "/tmp/addressbook-log" +MIN_LOG_LEVEL: int = logging.INFO +DROP_TEMPLATE_DATABASE_BEFORE_TESTS: bool = False +FLYWAY_BINARY_PATH: str = "/usr/bin/flyway" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..891ef51 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,109 @@ +import asyncio +import logging +from datetime import datetime +from typing import AsyncGenerator + +import msgspec +import pytest_asyncio +import sqlalchemy +from _pytest.config import Config +from sqlalchemy import NullPool, event +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.lib import settings +from app.lib.sqlalchemy_plugin import _default +from app.lib.test_extras.db_setup import TestingDatabaseSetup + +# A Guide To Database Unit Testing with Pytest and SQLAlchemy +# https://coderpad.io/blog/development/a-guide-to-database-unit-testing-with-pytest-and-sqlalchemy/ + + +db_connection_url = sqlalchemy.engine.URL.create( + drivername="postgresql+asyncpg", + username=settings.testing.DB_USER, + password=settings.testing.DB_PASSWORD, + host=settings.testing.DB_HOST, + port=settings.testing.DB_PORT, + database=settings.testing.DB_NAME, +) + +engine = create_async_engine( + db_connection_url, + echo=settings.db.ECHO, + echo_pool=settings.db.ECHO_POOL, + json_serializer=msgspec.json.Encoder(enc_hook=_default), + max_overflow=settings.db.POOL_MAX_OVERFLOW, + pool_size=settings.db.POOL_SIZE, + pool_timeout=settings.db.POOL_TIMEOUT, + poolclass=NullPool if settings.db.POOL_DISABLE else None, +) + +async_session_factory = async_sessionmaker( + engine, expire_on_commit=False, class_=AsyncSession +) + + +TestingAsyncSessionLocal = async_sessionmaker( + engine, + expire_on_commit=False, + autoflush=False, + autocommit=False, + class_=AsyncSession, +) + + +@pytest_asyncio.fixture(scope="function") +async def db_session() -> AsyncGenerator[AsyncSession, None]: + """The expectation with async_sessions is that the + transactions be called on the connection object instead of the + session object. + Detailed explanation of async transactional tests + + """ + + async with engine.connect() as connection: + trans = await connection.begin() + async with TestingAsyncSessionLocal(bind=connection) as async_session: + nested = await connection.begin_nested() + + @event.listens_for(async_session.sync_session, "after_transaction_end") + def end_savepoint(session, transaction): + nonlocal nested + + if not nested.is_active: + nested = connection.sync_connection.begin_nested() + + yield async_session + + await trans.rollback() + + await engine.dispose(close=True) + + +# @pytest.fixture(scope="session") +# def event_loop(): +# """ +# Creates an instance of the default event loop for the test session. +# """ +# policy = asyncio.get_event_loop_policy() +# loop = policy.new_event_loop() +# yield loop +# loop.close() + + +pytest_plugins = ( + # "app.lib.test_extras.db_plugins", +) + + +def pytest_configure(config: Config) -> None: + logging.info(f"Starting tests: {datetime.utcnow()}") + db_setup = TestingDatabaseSetup(options=settings.testing) + asyncio.run(db_setup.init_db()) + print() + + +def pytest_unconfigure(config: Config) -> None: + logging.info(f"Ending tests: {datetime.utcnow()}") + db_setup = TestingDatabaseSetup(options=settings.testing) + asyncio.run(db_setup.tear_down_db()) diff --git a/tests/test_general.py b/tests/test_general.py new file mode 100644 index 0000000..97c1b4c --- /dev/null +++ b/tests/test_general.py @@ -0,0 +1,41 @@ +# A Guide To Database Unit Testing with Pytest and SQLAlchemy +# https://coderpad.io/blog/development/a-guide-to-database-unit-testing-with-pytest-and-sqlalchemy/ + +import pytest +from sqlalchemy import text, event +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.domain.city import City +from app.lib import settings +from app.lib.sqlalchemy_plugin import engine + +import pytest +import pytest_asyncio +import sqlalchemy +from sqlalchemy.ext.asyncio import ( + AsyncSession, + create_async_engine, + async_scoped_session, + AsyncConnection, +) + + +class TestGeneral: + @pytest.fixture(scope="function", autouse=True) + def setup_class(self, db_session): + self.db_session = db_session + + # async def teardown_class(self): + # await self.session.rollback() + # await self.session.close() + + @pytest.mark.asyncio + async def test_bla(self): + stmt = text("select * from cities") + result = await self.db_session.execute(stmt) + print("#"*100) + for c in result: + print(c) + print("#"*100) + + assert True