From 24992e5464f7f365645f878423eee5a9683f3646 Mon Sep 17 00:00:00 2001 From: ksieuk Date: Sun, 8 Oct 2023 22:15:52 +0300 Subject: [PATCH] feat: [#41] add tests from template --- src/assistant/.env.example | 4 ++ src/assistant/Dockerfile.tests | 18 +++++ src/assistant/docker-compose.tests.yml | 56 +++++++++++++++ src/assistant/tests/conftest.py | 70 +++++++++++++++++++ src/assistant/tests/core/__init__.py | 5 ++ src/assistant/tests/core/settings.py | 17 +++++ .../tests/core/split_settings/__init__.py | 9 +++ .../tests/core/split_settings/api.py | 23 ++++++ .../tests/core/split_settings/postgres.py | 42 +++++++++++ .../tests/core/split_settings/project.py | 15 ++++ .../tests/core/split_settings/utils.py | 4 ++ src/assistant/tests/functional/__init__.py | 0 .../tests/functional/models/__init__.py | 7 ++ src/assistant/tests/functional/models/http.py | 35 ++++++++++ .../tests/functional/src/__init__.py | 0 .../tests/functional/src/test_health.py | 17 +++++ .../tests/functional/testdata/__init__.py | 0 src/assistant/tests/pytest.ini | 3 + src/assistant/tests/unit/__init__.py | 0 src/assistant/tests/unit/src/__init__.py | 0 src/assistant/tests/unit/src/test_health.py | 11 +++ 21 files changed, 336 insertions(+) create mode 100644 src/assistant/Dockerfile.tests create mode 100644 src/assistant/docker-compose.tests.yml create mode 100644 src/assistant/tests/conftest.py create mode 100644 src/assistant/tests/core/__init__.py create mode 100644 src/assistant/tests/core/settings.py create mode 100644 src/assistant/tests/core/split_settings/__init__.py create mode 100644 src/assistant/tests/core/split_settings/api.py create mode 100644 src/assistant/tests/core/split_settings/postgres.py create mode 100644 src/assistant/tests/core/split_settings/project.py create mode 100644 src/assistant/tests/core/split_settings/utils.py create mode 100644 src/assistant/tests/functional/__init__.py create mode 100644 src/assistant/tests/functional/models/__init__.py create mode 100644 src/assistant/tests/functional/models/http.py create mode 100644 src/assistant/tests/functional/src/__init__.py create mode 100644 src/assistant/tests/functional/src/test_health.py create mode 100644 src/assistant/tests/functional/testdata/__init__.py create mode 100644 src/assistant/tests/pytest.ini create mode 100644 src/assistant/tests/unit/__init__.py create mode 100644 src/assistant/tests/unit/src/__init__.py create mode 100644 src/assistant/tests/unit/src/test_health.py diff --git a/src/assistant/.env.example b/src/assistant/.env.example index 23e920f..3a3defc 100644 --- a/src/assistant/.env.example +++ b/src/assistant/.env.example @@ -9,6 +9,10 @@ NGINX_PORT=80 API_HOST=0.0.0.0 API_PORT=8000 +TEST_API_PROTOCOL=http +TEST_API_HOST=api +TEST_API_PORT=8000 + JWT_SECRET_KEY=v9LctjUWwol4XbvczPiLFMDtZ8aal7mm JWT_ALGORITHM=HS256 diff --git a/src/assistant/Dockerfile.tests b/src/assistant/Dockerfile.tests new file mode 100644 index 0000000..7362665 --- /dev/null +++ b/src/assistant/Dockerfile.tests @@ -0,0 +1,18 @@ +FROM python:3.11 + +RUN apt-get update + +WORKDIR /opt/app + +COPY pyproject.toml ./ +COPY poetry.lock ./ + +RUN apt-get update \ + && pip install poetry \ + && poetry config virtualenvs.create false \ + && poetry install --no-dev + +COPY tests tests +COPY lib lib + +CMD ["pytest"] diff --git a/src/assistant/docker-compose.tests.yml b/src/assistant/docker-compose.tests.yml new file mode 100644 index 0000000..0309e55 --- /dev/null +++ b/src/assistant/docker-compose.tests.yml @@ -0,0 +1,56 @@ +version: "3" + +services: + postgres: + image: postgres:15.2 + restart: always + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_NAME} + env_file: + - .env + expose: + - "${POSTGRES_PORT}" + volumes: + - postgres_data:/var/lib/postgresql/data/ + networks: + - backend_network + + api: + build: + context: . + container_name: api + image: fastapi_app + restart: always + entrypoint: ["/opt/app/entrypoint.sh"] + env_file: + - .env + expose: + - "${API_PORT}" + depends_on: + - postgres + networks: + - backend_network + - api_network + + tests: + build: + context: . + dockerfile: "Dockerfile.tests" + env_file: + - .env + depends_on: + - postgres + - api + networks: + - api_network + +volumes: + postgres_data: + +networks: + api_network: + driver: bridge + backend_network: + driver: bridge diff --git a/src/assistant/tests/conftest.py b/src/assistant/tests/conftest.py new file mode 100644 index 0000000..26ce055 --- /dev/null +++ b/src/assistant/tests/conftest.py @@ -0,0 +1,70 @@ +import asyncio +import typing + +import fastapi +import httpx +import pytest_asyncio + +import lib.app as lib_app +import tests.core.settings as tests_core_settings +import tests.functional.models as functional_models + + +@pytest_asyncio.fixture # type: ignore[reportUntypedFunctionDecorator] +async def http_client( + base_url: str = tests_core_settings.tests_settings.api.get_api_url, +) -> typing.AsyncGenerator[httpx.AsyncClient, typing.Any]: + session = httpx.AsyncClient(base_url=base_url) + yield session + await session.aclose() + + +@pytest_asyncio.fixture # type: ignore[reportUntypedFunctionDecorator] +async def make_request(http_client: httpx.AsyncClient): + async def inner( + api_method: str = "", + method: functional_models.MethodsEnum = functional_models.MethodsEnum.GET, + headers: dict[str, str] = tests_core_settings.tests_settings.api.headers, + body: dict[str, typing.Any] | None = None, + jwt_token: str | None = None, + ) -> functional_models.HTTPResponse: + if jwt_token is not None: + headers["Authorization"] = f"Bearer {jwt_token}" + + client_params = {"json": body, "headers": headers} + if method == functional_models.MethodsEnum.GET: + del client_params["json"] + + response = await getattr(http_client, method.value)(api_method, **client_params) + return functional_models.HTTPResponse( + body=response.json(), + headers=response.headers, + status_code=response.status_code, + ) + + return inner + + +@pytest_asyncio.fixture(scope="session") # type: ignore[reportUntypedFunctionDecorator] +def app() -> fastapi.FastAPI: + settings = lib_app.Settings() + application = lib_app.Application.from_settings(settings) + fastapi_app = application._fastapi_app # type: ignore[reportPrivateUsage] + return fastapi_app + + +@pytest_asyncio.fixture # type: ignore[reportUntypedFunctionDecorator] +async def app_http_client( + app: fastapi.FastAPI, + base_url: str = tests_core_settings.tests_settings.api.get_api_url, +) -> typing.AsyncGenerator[httpx.AsyncClient, typing.Any]: + session = httpx.AsyncClient(app=app, base_url=base_url) + yield session + await session.aclose() + + +@pytest_asyncio.fixture(scope="session") # type: ignore[reportUntypedFunctionDecorator] +def event_loop(): + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() diff --git a/src/assistant/tests/core/__init__.py b/src/assistant/tests/core/__init__.py new file mode 100644 index 0000000..215e663 --- /dev/null +++ b/src/assistant/tests/core/__init__.py @@ -0,0 +1,5 @@ +from .settings import * + +__all__ = [ + "tests_settings", +] diff --git a/src/assistant/tests/core/settings.py b/src/assistant/tests/core/settings.py new file mode 100644 index 0000000..6a44620 --- /dev/null +++ b/src/assistant/tests/core/settings.py @@ -0,0 +1,17 @@ +import pydantic +import pydantic_settings + +import tests.core.split_settings as app_split_settings + + +class TestsSettings(pydantic_settings.BaseSettings): + api: app_split_settings.ApiSettings = pydantic.Field(default_factory=lambda: app_split_settings.ApiSettings()) + postgres: app_split_settings.PostgresSettings = pydantic.Field( + default_factory=lambda: app_split_settings.PostgresSettings() + ) + project: app_split_settings.ProjectSettings = pydantic.Field( + default_factory=lambda: app_split_settings.ProjectSettings() + ) + + +tests_settings = TestsSettings() diff --git a/src/assistant/tests/core/split_settings/__init__.py b/src/assistant/tests/core/split_settings/__init__.py new file mode 100644 index 0000000..b2e230c --- /dev/null +++ b/src/assistant/tests/core/split_settings/__init__.py @@ -0,0 +1,9 @@ +from .api import * +from .postgres import * +from .project import * + +__all__ = [ + "ApiSettings", + "PostgresSettings", + "ProjectSettings", +] diff --git a/src/assistant/tests/core/split_settings/api.py b/src/assistant/tests/core/split_settings/api.py new file mode 100644 index 0000000..eeb1776 --- /dev/null +++ b/src/assistant/tests/core/split_settings/api.py @@ -0,0 +1,23 @@ +import pydantic +import pydantic_settings + +import lib.app.split_settings.utils as app_split_settings_utils + + +class ApiSettings(pydantic_settings.BaseSettings): + model_config = pydantic_settings.SettingsConfigDict( + env_file=app_split_settings_utils.ENV_PATH, + env_prefix="TEST_API_", + env_file_encoding="utf-8", + extra="ignore", + ) + + protocol: str = "http" + host: str = "0.0.0.0" + port: int = 8000 + headers: dict[str, str] = {"Content-Type": "application/json"} + + @pydantic.computed_field + @property + def get_api_url(self) -> str: + return f"{self.protocol}://{self.host}:{self.port}/api/v1" diff --git a/src/assistant/tests/core/split_settings/postgres.py b/src/assistant/tests/core/split_settings/postgres.py new file mode 100644 index 0000000..f318fc3 --- /dev/null +++ b/src/assistant/tests/core/split_settings/postgres.py @@ -0,0 +1,42 @@ +import pydantic +import pydantic_settings + +import lib.app.split_settings.utils as app_split_settings_utils + + +class PostgresSettings(pydantic_settings.BaseSettings): + model_config = pydantic_settings.SettingsConfigDict( + env_file=app_split_settings_utils.ENV_PATH, + env_prefix="POSTGRES_", + env_file_encoding="utf-8", + extra="ignore", + ) + + name: str = "test_database_name" + host: str = "localhost" + port: int = 5432 + user: str = "app" + password: pydantic.SecretStr = pydantic.Field( + default=..., + validation_alias=pydantic.AliasChoices("password", "postgres_password"), + ) + + @property + def db_uri_async(self) -> str: + db_uri: str = "postgresql+asyncpg://{pg_user}:{pg_pass}@{pg_host}/{pg_dbname}".format( + pg_user=self.user, + pg_pass=self.password.get_secret_value(), + pg_host=self.host, + pg_dbname=self.name, + ) + return db_uri + + @property + def db_uri_sync(self) -> str: + db_uri: str = "postgresql://{pg_user}:{pg_pass}@{pg_host}/{pg_dbname}".format( + pg_user=self.user, + pg_pass=self.password.get_secret_value(), + pg_host=self.host, + pg_dbname=self.name, + ) + return db_uri diff --git a/src/assistant/tests/core/split_settings/project.py b/src/assistant/tests/core/split_settings/project.py new file mode 100644 index 0000000..23d9eb1 --- /dev/null +++ b/src/assistant/tests/core/split_settings/project.py @@ -0,0 +1,15 @@ +import pydantic +import pydantic_settings + +import lib.app.split_settings.utils as app_split_settings_utils + + +class ProjectSettings(pydantic_settings.BaseSettings): + model_config = pydantic_settings.SettingsConfigDict( + env_file=app_split_settings_utils.ENV_PATH, + env_file_encoding="utf-8", + extra="ignore", + ) + + debug: bool = False + jwt_secret_key: pydantic.SecretStr = pydantic.Field(default=..., validation_alias="jwt_secret_key") diff --git a/src/assistant/tests/core/split_settings/utils.py b/src/assistant/tests/core/split_settings/utils.py new file mode 100644 index 0000000..7339f5d --- /dev/null +++ b/src/assistant/tests/core/split_settings/utils.py @@ -0,0 +1,4 @@ +import pathlib + +BASE_PATH = pathlib.Path(__file__).parent.parent.parent.parent.parent.resolve() +ENV_PATH = BASE_PATH / ".env" diff --git a/src/assistant/tests/functional/__init__.py b/src/assistant/tests/functional/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/assistant/tests/functional/models/__init__.py b/src/assistant/tests/functional/models/__init__.py new file mode 100644 index 0000000..30c08b2 --- /dev/null +++ b/src/assistant/tests/functional/models/__init__.py @@ -0,0 +1,7 @@ +from .http import * + +__all__ = [ + "HTTPResponse", + "MakeResponseCallableType", + "MethodsEnum", +] diff --git a/src/assistant/tests/functional/models/http.py b/src/assistant/tests/functional/models/http.py new file mode 100644 index 0000000..9e87ba6 --- /dev/null +++ b/src/assistant/tests/functional/models/http.py @@ -0,0 +1,35 @@ +import dataclasses +import enum +import typing + +import multidict + +import tests.core.settings as functional_settings + + +class MethodsEnum(enum.Enum): + GET = "get" + POST = "post" + PUT = "put" + DELETE = "delete" + PATCH = "patch" + + +@dataclasses.dataclass +class HTTPResponse: + body: dict[str, typing.Any] | str + headers: multidict.CIMultiDictProxy[str] + status_code: int + + +class MakeResponseCallableType(typing.Protocol): + async def __call__( + self, + api_method: str = "", + url: str = functional_settings.tests_settings.api.get_api_url, + method: MethodsEnum = MethodsEnum.GET, + headers: dict[str, str] = functional_settings.tests_settings.api.headers, + body: dict[str, typing.Any] | None = None, + jwt_token: str | None = None, + ) -> HTTPResponse: + ... diff --git a/src/assistant/tests/functional/src/__init__.py b/src/assistant/tests/functional/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/assistant/tests/functional/src/test_health.py b/src/assistant/tests/functional/src/test_health.py new file mode 100644 index 0000000..1361391 --- /dev/null +++ b/src/assistant/tests/functional/src/test_health.py @@ -0,0 +1,17 @@ +import http + +import pytest + +import tests.functional.models as tests_functional_models + +pytestmark = [pytest.mark.asyncio] + + +async def test_health( + make_request: tests_functional_models.MakeResponseCallableType, +): + response = await make_request( + method=tests_functional_models.MethodsEnum.GET, + api_method=f"/health/", + ) + assert response.status_code == http.HTTPStatus.OK diff --git a/src/assistant/tests/functional/testdata/__init__.py b/src/assistant/tests/functional/testdata/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/assistant/tests/pytest.ini b/src/assistant/tests/pytest.ini new file mode 100644 index 0000000..96735eb --- /dev/null +++ b/src/assistant/tests/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +log_format = %(asctime)s %(levelname)s %(message)s +log_date_format = %Y-%m-%d %H:%M:%S diff --git a/src/assistant/tests/unit/__init__.py b/src/assistant/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/assistant/tests/unit/src/__init__.py b/src/assistant/tests/unit/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/assistant/tests/unit/src/test_health.py b/src/assistant/tests/unit/src/test_health.py new file mode 100644 index 0000000..15e2bb3 --- /dev/null +++ b/src/assistant/tests/unit/src/test_health.py @@ -0,0 +1,11 @@ +import http + +import httpx +import pytest + +pytestmark = [pytest.mark.asyncio] + + +async def test_health(app_http_client: httpx.AsyncClient) -> None: + response = await app_http_client.get("/health/") + assert response.status_code == http.HTTPStatus.OK