From 98fa2187d449b4deed7fc442b2667a628db052b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Doma=C5=84ski?= Date: Mon, 20 May 2024 12:10:17 +0200 Subject: [PATCH] [tests] migrate to tests without docker containers --- docker-compose.test.yml | 30 -------------- env.template | 2 +- fooder/__main__.py | 4 +- fooder/db.py | 9 ++++ fooder/test/fixtures/__init__.py | 6 +++ fooder/test/fixtures/client.py | 70 +++++++++++++------------------- fooder/test/test_diary.py | 56 +++++++++++++------------ fooder/test/test_preset.py | 54 ++++++++++++------------ fooder/test/test_product.py | 22 +++++----- fooder/test/test_user.py | 20 ++++----- fooder/utils/product_finder.py | 1 + requirements_local.txt | 1 + test.sh | 60 ++++++++------------------- 13 files changed, 146 insertions(+), 189 deletions(-) delete mode 100644 docker-compose.test.yml diff --git a/docker-compose.test.yml b/docker-compose.test.yml deleted file mode 100644 index ed156c3..0000000 --- a/docker-compose.test.yml +++ /dev/null @@ -1,30 +0,0 @@ -version: '3' - -networks: - fooder_test: - driver: bridge - -services: - database: - restart: unless-stopped - image: postgres - networks: - - fooder_test - env_file: - - .env.test - - api: - restart: unless-stopped - image: registry.domandoman.xyz/fooder/api - build: - dockerfile: Dockerfile - context: . - networks: - - fooder_test - env_file: - - .env.test - volumes: - - ./fooder:/opt/fooder/fooder - ports: - - "8000:8000" - command: "uvicorn fooder.app:app --host 0.0.0.0 --port 8000 --reload" diff --git a/env.template b/env.template index 982989c..015a6ea 100644 --- a/env.template +++ b/env.template @@ -7,7 +7,7 @@ DB_URI="postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database/${PO ECHO_SQL=0 SECRET_KEY="${SECRET_KEY}" # generate with $ openssl rand -hex 32 -REFRESH_SECRET_KEY="${REFRESH_SECRET}" # generate with $ openssl rand -hex 32 +REFRESH_SECRET_KEY="${REFRESH_SECRET_KEY}" # generate with $ openssl rand -hex 32 ALGORITHM="HS256" ACCESS_TOKEN_EXPIRE_MINUTES=30 REFRESH_TOKEN_EXPIRE_DAYS=30 diff --git a/fooder/__main__.py b/fooder/__main__.py index aad905c..d2adece 100644 --- a/fooder/__main__.py +++ b/fooder/__main__.py @@ -16,7 +16,9 @@ if __name__ == "__main__": from .settings import Settings settings = Settings() - engine = sqlalchemy.create_engine(settings.DB_URI.replace("+asyncpg", "")) + engine = sqlalchemy.create_engine( + settings.DB_URI.replace("+asyncpg", "").replace("+aiosqlite", "") + ) if args.create_tables: Base.metadata.create_all(engine) diff --git a/fooder/db.py b/fooder/db.py index 5e05ef5..02ec728 100644 --- a/fooder/db.py +++ b/fooder/db.py @@ -9,10 +9,19 @@ from .settings import Settings log = logging.getLogger(__name__) settings = Settings.parse_obj({}) +if settings.DB_URI.startswith("sqlite"): + settings.DB_URI = settings.DB_URI + "?check_same_thread=False" + +""" +Asynchronous PostgreSQL database engine. +""" async_engine = create_async_engine( settings.DB_URI, pool_pre_ping=True, echo=settings.ECHO_SQL, + connect_args=( + {"check_same_thread": False} if settings.DB_URI.startswith("sqlite") else {} + ), ) AsyncSessionLocal = async_sessionmaker( bind=async_engine, diff --git a/fooder/test/fixtures/__init__.py b/fooder/test/fixtures/__init__.py index 4b2a210..e2ed9fe 100644 --- a/fooder/test/fixtures/__init__.py +++ b/fooder/test/fixtures/__init__.py @@ -3,3 +3,9 @@ from .user import * # noqa from .product import * # noqa from .meal import * # noqa from .entry import * # noqa +import pytest + + +@pytest.fixture +def anyio_backend(): + return "asyncio" diff --git a/fooder/test/fixtures/client.py b/fooder/test/fixtures/client.py index 98dbc00..6a9bd15 100644 --- a/fooder/test/fixtures/client.py +++ b/fooder/test/fixtures/client.py @@ -1,31 +1,17 @@ -import requests +from fooder.app import app +from httpx import AsyncClient import pytest -import os -import yaml - - -def get_api_url(service_name) -> str: - with open("docker-compose.test.yml") as f: - config = yaml.safe_load(f) - - port = config["services"][service_name]["ports"][0].split(":")[0] - - return f"http://localhost:{port}/" +import httpx class Client: def __init__( self, - base_url: str, username: str | None = None, password: str | None = None, ): - self.base_url = os.path.join(base_url, "api") - self.session = requests.Session() - self.session.headers["Accept"] = "application/json" - - if username and password: - self.login(username, password, True) + self.client = AsyncClient(app=app, base_url="http://testserver/api") + self.client.headers["Accept"] = "application/json" def set_token(self, token: str) -> None: """set_token. @@ -34,14 +20,14 @@ class Client: :type token: str :rtype: None """ - self.session.headers["Authorization"] = "Bearer " + token + self.client.headers["Authorization"] = "Bearer " + token - def create_user(self, username: str, password: str) -> None: + async def create_user(self, username: str, password: str) -> None: data = {"username": username, "password": password} - response = self.post("user", json=data) + response = await self.post("user", json=data) response.raise_for_status() - def login(self, username: str, password: str, force_login: bool) -> None: + async def login(self, username: str, password: str, force_login: bool) -> None: """login. :param username: @@ -54,40 +40,40 @@ class Client: """ data = {"username": username, "password": password} - response = self.post("token", data=data) + response = await self.post("token", data=data) if response.status_code != 200: if force_login: - self.create_user(username, password) - return self.login(username, password, False) + await self.create_user(username, password) + return await self.login(username, password, False) else: - raise Exception(f"Could not login as {username}") + raise Exception( + f"Could not login as {username}! Detail: {response.text}" + ) result = response.json() self.set_token(result["access_token"]) - def get(self, path: str, **kwargs) -> requests.Response: - return self.session.get(os.path.join(self.base_url, path), **kwargs) + async def get(self, path: str, **kwargs) -> httpx.Response: + return await self.client.get(path, **kwargs) - def delete(self, path: str, **kwargs) -> requests.Response: - return self.session.delete(os.path.join(self.base_url, path), **kwargs) + async def delete(self, path: str, **kwargs) -> httpx.Response: + return await self.client.delete(path, **kwargs) - def post(self, path: str, **kwargs) -> requests.Response: - return self.session.post(os.path.join(self.base_url, path), **kwargs) + async def post(self, path: str, **kwargs) -> httpx.Response: + return await self.client.post(path, **kwargs) - def patch(self, path: str, **kwargs) -> requests.Response: - return self.session.patch(os.path.join(self.base_url, path), **kwargs) + async def patch(self, path: str, **kwargs) -> httpx.Response: + return await self.client.patch(path, **kwargs) @pytest.fixture def unauthorized_client() -> Client: - return Client(get_api_url("api")) + return Client() @pytest.fixture -def client(user_payload) -> Client: - return Client( - get_api_url("api"), - username=user_payload["username"], - password=user_payload["password"], - ) +async def client(user_payload) -> Client: + client = Client() + await client.login(user_payload["username"], user_payload["password"], True) + return client diff --git a/fooder/test/test_diary.py b/fooder/test/test_diary.py index 5833468..1e92d32 100644 --- a/fooder/test/test_diary.py +++ b/fooder/test/test_diary.py @@ -2,10 +2,10 @@ import datetime import pytest -@pytest.mark.dependency() -def test_get_diary(client): +@pytest.mark.anyio +async def test_get_diary(client): today = datetime.date.today().isoformat() - response = client.get("diary", params={"date": today}) + response = await client.get("diary", params={"date": today}) assert response.status_code == 200, response.json() assert response.json()["date"] == today @@ -13,52 +13,56 @@ def test_get_diary(client): assert len(response.json()["meals"]) == 1 -@pytest.mark.dependency(depends=["test_get_diary"]) -def test_diary_add_meal(client, meal_payload_factory): +@pytest.mark.anyio +async def test_diary_add_meal(client, meal_payload_factory): today = datetime.date.today().isoformat() - response = client.get("diary", params={"date": today}) + response = await client.get("diary", params={"date": today}) diary_id = response.json()["id"] meal_order = len(response.json()["meals"]) + 1 - response = client.post("meal", json=meal_payload_factory(diary_id, meal_order)) + response = await client.post( + "meal", json=meal_payload_factory(diary_id, meal_order) + ) assert response.status_code == 200, response.json() -@pytest.mark.dependency(depends=["test_diary_add_meal"]) -def test_diary_delete_meal(client): +@pytest.mark.anyio +async def test_diary_delete_meal(client): today = datetime.date.today().isoformat() - response = client.get("diary", params={"date": today}) + response = await client.get("diary", params={"date": today}) meals_amount = len(response.json()["meals"]) meal_id = response.json()["meals"][0]["id"] - response = client.delete(f"meal/{meal_id}") + response = await client.delete(f"meal/{meal_id}") assert response.status_code == 200, response.json() - response = client.get("diary", params={"date": today}) + response = await client.get("diary", params={"date": today}) assert response.status_code == 200, response.json() assert len(response.json()["meals"]) == meals_amount - 1 -@pytest.mark.dependency(depends=["test_get_diary"]) -def test_diary_add_entry(client, product_payload_factory, entry_payload_factory): +@pytest.mark.anyio +async def test_diary_add_entry(client, product_payload_factory, entry_payload_factory): today = datetime.date.today().isoformat() - response = client.get("diary", params={"date": today}) + response = await client.get("diary", params={"date": today}) meal_id = response.json()["meals"][0]["id"] - product_id = client.post("product", json=product_payload_factory()).json()["id"] + product_id = (await client.post("product", json=product_payload_factory())).json()[ + "id" + ] entry_payload = entry_payload_factory(meal_id, product_id, 100.0) - response = client.post("entry", json=entry_payload) + response = await client.post("entry", json=entry_payload) assert response.status_code == 200, response.json() -@pytest.mark.dependency(depends=["test_diary_add_entry"]) -def test_diary_edit_entry(client, entry_payload_factory): +@pytest.mark.anyio +async def test_diary_edit_entry(client, entry_payload_factory): today = datetime.date.today().isoformat() - response = client.get("diary", params={"date": today}) + response = await client.get("diary", params={"date": today}) entry = response.json()["meals"][0]["entries"][0] id_ = entry["id"] @@ -66,21 +70,21 @@ def test_diary_edit_entry(client, entry_payload_factory): entry["meal_id"], entry["product"]["id"], entry["grams"] + 100.0 ) - response = client.patch(f"entry/{id_}", json=entry_payload) + response = await client.patch(f"entry/{id_}", json=entry_payload) assert response.status_code == 200, response.json() assert response.json()["grams"] == entry_payload["grams"] -@pytest.mark.dependency(depends=["test_diary_add_entry"]) -def test_diary_delete_entry(client): +@pytest.mark.anyio +async def test_diary_delete_entry(client): today = datetime.date.today().isoformat() - response = client.get("diary", params={"date": today}) + response = await client.get("diary", params={"date": today}) entry_id = response.json()["meals"][0]["entries"][0]["id"] - response = client.delete(f"entry/{entry_id}") + response = await client.delete(f"entry/{entry_id}") assert response.status_code == 200, response.json() - response = client.get("diary", params={"date": today}) + response = await client.get("diary", params={"date": today}) assert response.status_code == 200, response.json() deleted_entries = [ entry diff --git a/fooder/test/test_preset.py b/fooder/test/test_preset.py index 2c492ea..2c0c626 100644 --- a/fooder/test/test_preset.py +++ b/fooder/test/test_preset.py @@ -2,38 +2,42 @@ import datetime import pytest -@pytest.mark.dependency() -def test_create_meal( +@pytest.mark.anyio +async def test_create_meal( client, meal_payload_factory, product_payload_factory, entry_payload_factory ): today = datetime.date.today().isoformat() - response = client.get("diary", params={"date": today}) + response = await client.get("diary", params={"date": today}) diary_id = response.json()["id"] meal_order = len(response.json()["meals"]) + 1 - response = client.post("meal", json=meal_payload_factory(diary_id, meal_order)) + response = await client.post( + "meal", json=meal_payload_factory(diary_id, meal_order) + ) assert response.status_code == 200, response.json() meal_id = response.json()["id"] - product_id = client.post("product", json=product_payload_factory()).json()["id"] + product_id = (await client.post("product", json=product_payload_factory())).json()[ + "id" + ] entry_payload = entry_payload_factory(meal_id, product_id, 100.0) - response = client.post("entry", json=entry_payload) + response = await client.post("entry", json=entry_payload) assert response.status_code == 200, response.json() -@pytest.mark.dependency(depends=["test_create_meal"]) -def test_save_meal(client, meal_save_payload): +@pytest.mark.anyio +async def test_save_meal(client, meal_save_payload): today = datetime.date.today().isoformat() - response = client.get("diary", params={"date": today}) + response = await client.get("diary", params={"date": today}) meal = response.json()["meals"][0] meal_id = meal["id"] save_payload = meal_save_payload(meal_id) - response = client.post(f"meal/{meal_id}/save", json=save_payload) + response = await client.post(f"meal/{meal_id}/save", json=save_payload) assert response.status_code == 200, response.json() preset = response.json() @@ -45,27 +49,27 @@ def test_save_meal(client, meal_save_payload): assert meal[k] == v, f"{k} != {v}" -@pytest.mark.dependency(depends=["test_create_meal"]) -def test_list_presets(client, meal_save_payload): - response = client.get("preset") +@pytest.mark.anyio +async def test_list_presets(client, meal_save_payload): + response = await client.get("preset") assert response.status_code == 200, response.json() assert len(response.json()["presets"]) > 0, response.json() name = meal_save_payload(0)["name"] - response = client.get(f"preset?q={name}") + response = await client.get(f"preset?q={name}") assert response.status_code == 200, response.json() assert len(response.json()["presets"]) > 0, response.json() -@pytest.mark.dependency(depends=["test_list_presets"]) -def test_create_meal_from_preset(client, meal_from_preset): +@pytest.mark.anyio +async def test_create_meal_from_preset(client, meal_from_preset): today = datetime.date.today().isoformat() - response = client.get("diary", params={"date": today}) + response = await client.get("diary", params={"date": today}) diary_id = response.json()["id"] meal_order = len(response.json()["meals"]) + 1 - response = client.get("preset") + response = await client.get("preset") assert response.status_code == 200, response.json() assert len(response.json()["presets"]) > 0, response.json() @@ -77,7 +81,7 @@ def test_create_meal_from_preset(client, meal_from_preset): preset["id"], ) - response = client.post("meal/from_preset", json=payload) + response = await client.post("meal/from_preset", json=payload) assert response.status_code == 200, response.json() meal = response.json() @@ -88,16 +92,16 @@ def test_create_meal_from_preset(client, meal_from_preset): assert meal[k] == v, f"{k} != {v}" -@pytest.mark.dependency(depends=["test_list_presets"]) -def test_delete_preset(client): - presets = client.get("preset").json()["presets"] +@pytest.mark.anyio +async def test_delete_preset(client): + presets = (await client.get("preset")).json()["presets"] preset_id = presets[0]["id"] - response = client.get(f"preset/{preset_id}") + response = await client.get(f"preset/{preset_id}") assert response.status_code == 200, response.json() - response = client.delete(f"preset/{preset_id}") + response = await client.delete(f"preset/{preset_id}") assert response.status_code == 200, response.json() - response = client.get(f"preset/{preset_id}") + response = await client.get(f"preset/{preset_id}") assert response.status_code == 404, response.json() diff --git a/fooder/test/test_product.py b/fooder/test/test_product.py index eccd266..6fe2b20 100644 --- a/fooder/test/test_product.py +++ b/fooder/test/test_product.py @@ -1,15 +1,15 @@ import pytest -@pytest.mark.dependency() -def test_create_product(client, product_payload): - response = client.post("product", json=product_payload) +@pytest.mark.anyio +async def test_create_product(client, product_payload): + response = await client.post("product", json=product_payload) assert response.status_code == 200, response.json() -@pytest.mark.dependency(depends=["test_create_product"]) -def test_list_product(client): - response = client.get("product") +@pytest.mark.anyio +async def test_list_product(client): + response = await client.get("product") assert response.status_code == 200, response.json() data = response.json()["products"] @@ -21,13 +21,15 @@ def test_list_product(client): product_ids.add(product["id"]) -@pytest.mark.dependency(depends=["test_create_product"]) -def test_get_product_by_barcode(client): - response = client.get("product/by_barcode", params={"barcode": "4056489666028"}) +@pytest.mark.anyio +async def test_get_product_by_barcode(client): + response = await client.get( + "product/by_barcode", params={"barcode": "4056489666028"} + ) assert response.status_code == 200, response.json() name = response.json()["name"] - response = client.get("product", params={"q": name}) + response = await client.get("product", params={"q": name}) assert response.status_code == 200, response.json() assert len(response.json()["products"]) == 1 diff --git a/fooder/test/test_user.py b/fooder/test/test_user.py index 072d4cc..e6a5fe4 100644 --- a/fooder/test/test_user.py +++ b/fooder/test/test_user.py @@ -1,15 +1,15 @@ import pytest -@pytest.mark.dependency() -def test_user_creation(unauthorized_client, user_payload_factory): - response = unauthorized_client.post("user", json=user_payload_factory()) +@pytest.mark.anyio +async def test_user_creation(unauthorized_client, user_payload_factory): + response = await unauthorized_client.post("user", json=user_payload_factory()) assert response.status_code == 200, response.json() -@pytest.mark.dependency(depends=["test_user_creation"]) -def test_user_login(client, user_payload): - response = client.post("token", data=user_payload) +@pytest.mark.anyio +async def test_user_login(client, user_payload): + response = await client.post("token", data=user_payload) assert response.status_code == 200, response.json() data = response.json() @@ -17,13 +17,13 @@ def test_user_login(client, user_payload): assert data["refresh_token"] is not None -@pytest.mark.dependency(depends=["test_user_login"]) -def test_user_refresh_token(client, user_payload): - response = client.post("token", data=user_payload) +@pytest.mark.anyio +async def test_user_refresh_token(client, user_payload): + response = await client.post("token", data=user_payload) assert response.status_code == 200, response.json() token = response.json()["refresh_token"] payload = {"refresh_token": token} - response = client.post("token/refresh", json=payload) + response = await client.post("token/refresh", json=payload) assert response.status_code == 200, response.json() diff --git a/fooder/utils/product_finder.py b/fooder/utils/product_finder.py index 64cffa3..ee8165a 100644 --- a/fooder/utils/product_finder.py +++ b/fooder/utils/product_finder.py @@ -48,5 +48,6 @@ def find(bar_code: str) -> Product: fiber=data["product"]["nutriments"].get("fiber_100g", 0.0), ) except Exception as e: + raise e logger.error(e) raise ParseError() diff --git a/requirements_local.txt b/requirements_local.txt index d05118b..da1b732 100644 --- a/requirements_local.txt +++ b/requirements_local.txt @@ -11,3 +11,4 @@ requests black flake8 flake8-bugbear +httpx diff --git a/test.sh b/test.sh index 65ed4d7..737cece 100755 --- a/test.sh +++ b/test.sh @@ -3,60 +3,32 @@ # Run fooder api tests # -set -e - -TESTS='test' -[[ $# -eq 1 ]] && TESTS=${1} - echo "Running fooder api tests" -# up containers -DC='docker-compose -f docker-compose.test.yml' - -if [ "$(${DC} ps | grep -E 'running|Up' | wc -l)" -gt 0 ]; then - # down containers to assure a clean database - echo "stopping containers..." - ${DC} down --remove-orphans -fi +# if exists, remove test.db +[ -f test.db ] && rm test.db # create test env values -export POSTGRES_USER=fooder_test -export POSTGRES_DATABASE=fooder_test -export POSTGRES_PASSWORD=$(pwgen 13 1) +export DB_URI="sqlite+aiosqlite:///test.db" +export ECHO_SQL=0 export SECRET_KEY=$(openssl rand -hex 32) -export REFRESH_SECRET=$(openssl rand -hex 32) +export REFRESH_SECRET_KEY=$(openssl rand -hex 32) -rm -f .env.test -envsubst < env.template > .env.test +python -m fooder --create-tables +# finally run tests +if [[ $# -eq 1 ]]; then + python -m pytest fooder --disable-warnings -sv -k "${1}" +else + python -m pytest fooder --disable-warnings -sv +fi + +# unset test env values unset POSTGRES_USER unset POSTGRES_DATABASE unset POSTGRES_PASSWORD unset SECRET_KEY unset REFRESH_SECRET -echo "starting containers..." -${DC} up -d - -# Wait for the containers to start -echo "waiting for containers to start..." -while [ "$(${DC} ps | grep -E 'running|Up' | wc -l)" -lt 2 ]; do - sleep 1 -done - -while [ "$(${DC} logs database | grep -E 'database system is ready to accept connections' | wc -l)" -lt 2 ]; do - sleep 1 -done - -# create tables -echo "creating tables..." -${DC} exec api bash -c "python -m fooder --create-tables" - -# finally run tests -set -xe -python -m pytest fooder -sv -k "${TESTS}" - -# clean up after tests -echo "cleaning up..." -${DC} down --remove-orphans -rm -f .env.test +# if exists, remove test.db +[ -f test.db ] && rm test.db