diff --git a/.github/workflows/check-pr.yaml b/.github/workflows/check-pr.yaml index 1eb1b49..cdb0be7 100644 --- a/.github/workflows/check-pr.yaml +++ b/.github/workflows/check-pr.yaml @@ -100,6 +100,7 @@ jobs: - name: Test Package env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }} POSTGRES_DRIVER: ${{ vars.POSTGRES_DRIVER }} diff --git a/src/assistant/.env.example b/src/assistant/.env.example index 9ddb24f..224cc85 100644 --- a/src/assistant/.env.example +++ b/src/assistant/.env.example @@ -15,6 +15,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/Makefile b/src/assistant/Makefile index b8d750c..91ad0d8 100644 --- a/src/assistant/Makefile +++ b/src/assistant/Makefile @@ -1,3 +1,3 @@ include ../../common_makefile.mk -PROJECT_FOLDERS = bin lib tests \ No newline at end of file +PROJECT_FOLDERS = bin lib tests 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/poetry.lock b/src/assistant/poetry.lock index 259f36a..92fdc49 100644 --- a/src/assistant/poetry.lock +++ b/src/assistant/poetry.lock @@ -1507,6 +1507,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-dotenv" version = "1.0.0" @@ -2051,4 +2069,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "64e70d8e6e21863567fed017ba25f061ab0af9b99b6281b50ff6eceb7bc4cb7b" +content-hash = "9d9f56a0892e3eb62cadf0061462fe1b8a9fd8aa404761e331d485a6a05a531c" diff --git a/src/assistant/pyproject.toml b/src/assistant/pyproject.toml index fe80947..d03010f 100644 --- a/src/assistant/pyproject.toml +++ b/src/assistant/pyproject.toml @@ -26,11 +26,14 @@ dill = "^0.3.7" fastapi = "0.103.1" greenlet = "^2.0.2" httpx = "^0.25.0" +multidict = "^6.0.4" openai = "^0.28.1" orjson = "3.9.7" psycopg2-binary = "^2.9.9" pydantic = {extras = ["email"], version = "^2.3.0"} pydantic-settings = "^2.0.3" +pytest = "^7.4.2" +pytest-asyncio = "^0.21.1" python = "^3.11" python-jose = "^3.3.0" python-magic = "^0.4.27" @@ -90,6 +93,7 @@ variable-rgx = "^_{0,2}[a-z][a-z0-9_]*$" [tool.pyright] exclude = [ + ".pytest_cache", ".venv" ] pythonPlatform = "All" 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..ae41a38 --- /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