From bbbd124d781afe64d26b74c72fe4b8c68eb737e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Doma=C5=84ski?= Date: Thu, 2 Apr 2026 18:22:05 +0200 Subject: [PATCH] begin tedious work --- Dockerfile | 2 +- Makefile | 2 +- fooder/auth.py | 4 +- fooder/context.py | 30 ++++ fooder/controller/base.py | 5 +- fooder/controller/diary.py | 25 ++- fooder/db.py | 99 ++++++++---- fooder/repository/__init__.py | 0 fooder/repository/base.py | 6 + fooder/repository/user.py | 2 + fooder/settings.py | 5 +- fooder/test/conftest.py | 35 ++++- fooder/test/fixtures/__init__.py | 6 +- fooder/test/fixtures/client.py | 110 ------------- fooder/test/fixtures/dbssn.py | 24 +++ fooder/test/fixtures/entry.py | 14 -- fooder/test/fixtures/meal.py | 37 ----- fooder/test/fixtures/product.py | 22 --- fooder/test/fixtures/user.py | 22 --- fooder/test/test_db.py | 148 ++++++++++++++++++ fooder/test/test_diary.py | 95 ----------- fooder/test/test_preset.py | 107 ------------- fooder/test/test_product.py | 35 ----- fooder/test/test_tasks.py | 15 -- fooder/test/test_user.py | 29 ---- pytest.ini | 2 + requirements.txt => requirements/docker.txt | 2 +- .../local.txt | 3 +- test.sh | 32 +--- 29 files changed, 340 insertions(+), 578 deletions(-) create mode 100644 fooder/context.py create mode 100644 fooder/repository/__init__.py create mode 100644 fooder/repository/base.py create mode 100644 fooder/repository/user.py delete mode 100644 fooder/test/fixtures/client.py create mode 100644 fooder/test/fixtures/dbssn.py delete mode 100644 fooder/test/fixtures/entry.py delete mode 100644 fooder/test/fixtures/meal.py delete mode 100644 fooder/test/fixtures/product.py delete mode 100644 fooder/test/fixtures/user.py create mode 100644 fooder/test/test_db.py delete mode 100644 fooder/test/test_diary.py delete mode 100644 fooder/test/test_preset.py delete mode 100644 fooder/test/test_product.py delete mode 100644 fooder/test/test_tasks.py delete mode 100644 fooder/test/test_user.py create mode 100644 pytest.ini rename requirements.txt => requirements/docker.txt (90%) rename requirements_local.txt => requirements/local.txt (88%) diff --git a/Dockerfile b/Dockerfile index b75adf3..199dc54 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,7 @@ FROM python:3.11.5-bullseye RUN apt-get -y install libpq-dev -COPY requirements.txt requirements.txt +COPY requirements/docker.txt requirements.txt RUN pip install -r requirements.txt RUN useradd fooder diff --git a/Makefile b/Makefile index 69c618d..e350134 100644 --- a/Makefile +++ b/Makefile @@ -40,7 +40,7 @@ version: .PHONY: create-venv create-venv: python3 -m venv .venv --prompt="fooderapi-venv" --system-site-packages - bash -c "source .venv/bin/activate && pip install -r requirements_local.txt" + bash -c "source .venv/bin/activate && pip install -r requirements/local.txt" .PHONY: test test: diff --git a/fooder/auth.py b/fooder/auth.py index 463ccce..6dc7c20 100644 --- a/fooder/auth.py +++ b/fooder/auth.py @@ -8,7 +8,7 @@ from jose import JWTError, jwt from passlib.context import CryptContext from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker -from .db import get_session +from .db import get_db_session from .domain.token import RefreshToken from .domain.user import User from .settings import Settings @@ -18,7 +18,7 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/token") settings = Settings() password_helper = PasswordHelper(pwd_context) # type: ignore -AsyncSessionDependency = Annotated[async_sessionmaker, Depends(get_session)] +AsyncSessionDependency = Annotated[async_sessionmaker, Depends(get_db_session)] TokenDependency = Annotated[str, Depends(oauth2_scheme)] diff --git a/fooder/context.py b/fooder/context.py new file mode 100644 index 0000000..5bbf09f --- /dev/null +++ b/fooder/context.py @@ -0,0 +1,30 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from fastapi import Depends +from fooder.db import get_db_session + + +class Context: + """ + Main API context, aggregating dependencies + """ + + def __init__(self, dbssn: AsyncSession) -> None: + self.dbssn = dbssn + + +class ContextDependency: + """ + Configurable context dependecy. Allows for shared interface configuring + method required dependencies + """ + + def __init__( + self, + ) -> None: + pass + + def __call__( + self, + dbssn: AsyncSession = Depends(get_db_session), + ): + return Context(dbssn=dbssn) diff --git a/fooder/controller/base.py b/fooder/controller/base.py index 429f35d..a9948b8 100644 --- a/fooder/controller/base.py +++ b/fooder/controller/base.py @@ -4,17 +4,16 @@ from fastapi import Depends from sqlalchemy.ext.asyncio import async_sessionmaker from ..auth import authorize_api_key, get_current_user -from ..db import get_session +from ..db import get_db_session, AsyncSession from ..domain.user import User -AsyncSession = Annotated[async_sessionmaker, Depends(get_session)] UserDependency = Annotated[User, Depends(get_current_user)] ApiKeyDependency = Annotated[None, Depends(authorize_api_key)] class BaseController: def __init__(self, session: AsyncSession) -> None: - self.async_session = session + self.session = session async def call(self, *args, **kwargs) -> Any: raise NotImplementedError diff --git a/fooder/controller/diary.py b/fooder/controller/diary.py index 91f6396..42dda75 100644 --- a/fooder/controller/diary.py +++ b/fooder/controller/diary.py @@ -9,17 +9,16 @@ from .base import AuthorizedController class GetDiary(AuthorizedController): async def call(self, date: date) -> Diary: - async with self.async_session() as session: - diary = await DBDiary.get_diary(session, self.user.id, date) + diary = await DBDiary.get_diary(self.session, self.user.id, date) - if diary is not None: - return Diary.from_orm(diary) - else: - try: - await DBDiary.create(session, self.user.id, date) - await session.commit() - return Diary.from_orm( - await DBDiary.get_diary(session, self.user.id, date) - ) - except AssertionError as e: - raise HTTPException(status_code=400, detail=e.args[0]) + if diary is not None: + return Diary.from_orm(diary) + else: + try: + await DBDiary.create(session, self.user.id, date) + await session.commit() + return Diary.from_orm( + await DBDiary.get_diary(session, self.user.id, date) + ) + except AssertionError as e: + raise HTTPException(status_code=400, detail=e.args[0]) diff --git a/fooder/db.py b/fooder/db.py index 02ec728..ab53951 100644 --- a/fooder/db.py +++ b/fooder/db.py @@ -1,38 +1,69 @@ -import logging -from typing import AsyncIterator +import contextlib +from typing import AsyncIterator, AsyncGenerator -from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine - -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, - autocommit=False, - autoflush=False, - future=True, +from fooder.settings import Settings, settings +from sqlalchemy.ext.asyncio import ( + AsyncConnection, + AsyncSession, + async_sessionmaker, + create_async_engine, ) -async def get_session() -> AsyncIterator[async_sessionmaker]: - try: - yield AsyncSessionLocal - except SQLAlchemyError as e: - log.exception(e) +class DatabaseSessionManager: + def __init__(self, settings: Settings) -> None: + self._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 {} + ), + ) + self._sessionmaker = async_sessionmaker( + autocommit=False, autoflush=False, future=True, bind=self._engine + ) + + async def close(self) -> None: + if self._engine is None: + raise Exception("DatabaseSessionManager is not initialized") + await self._engine.dispose() + + self._engine = None + self._sessionmaker = None + + @contextlib.asynccontextmanager + async def connect(self) -> AsyncIterator[AsyncConnection]: + if self._engine is None: + raise Exception("DatabaseSessionManager is not initialized") + + async with self._engine.begin() as connection: + try: + yield connection + except Exception: + await connection.rollback() + raise + + @contextlib.asynccontextmanager + async def session(self) -> AsyncIterator[AsyncSession]: + if self._sessionmaker is None: + raise Exception("DatabaseSessionManager is not initialized") + + session = self._sessionmaker() + try: + yield session + except Exception: + await session.rollback() + raise + finally: + await session.close() + + +session_manager = DatabaseSessionManager(settings) + + +async def get_db_session() -> AsyncGenerator[AsyncSession, None]: + async with session_manager.session() as session: + yield session diff --git a/fooder/repository/__init__.py b/fooder/repository/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fooder/repository/base.py b/fooder/repository/base.py new file mode 100644 index 0000000..a89349f --- /dev/null +++ b/fooder/repository/base.py @@ -0,0 +1,6 @@ +from sqlalchemy.ext.asyncio import AsyncSession + + +class RepositoryBase: + def __init__(self, dbssn: AsyncSession): + self.dbssn = dbssn diff --git a/fooder/repository/user.py b/fooder/repository/user.py new file mode 100644 index 0000000..fe63377 --- /dev/null +++ b/fooder/repository/user.py @@ -0,0 +1,2 @@ +class UserRepository: + pass diff --git a/fooder/settings.py b/fooder/settings.py index c9690ed..261f038 100644 --- a/fooder/settings.py +++ b/fooder/settings.py @@ -13,8 +13,11 @@ class Settings(BaseSettings): REFRESH_SECRET_KEY: str ALGORITHM: str = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 - REFRESH_TOKEN_EXPIRE_DAYS: int = 30 + REFRESH_TOKEN_EXPIRE_DAYS: int = 120 ALLOWED_ORIGINS: List[str] = ["*"] API_KEY: str + + +settings = Settings() diff --git a/fooder/test/conftest.py b/fooder/test/conftest.py index ebe4cbe..0c9b106 100644 --- a/fooder/test/conftest.py +++ b/fooder/test/conftest.py @@ -1 +1,34 @@ -from .fixtures import * # noqa +import os +import pytest +import pytest_asyncio + +# --------------------------------------------------------------------------- # +# Supply minimal dummy env-vars *before* any of our modules are imported. # +# This lets the global `settings = Settings()` call succeed. # +# --------------------------------------------------------------------------- # +os.environ.update( + { + "DB_URI": "sqlite+aiosqlite:///:memory:", + "ECHO_SQL": "false", + "SECRET_KEY": "test-secret", + "REFRESH_SECRET_KEY": "test-refresh", + "API_KEY": "test-key", + } +) + +from fooder.db import DatabaseSessionManager +from fooder.domain import Base +from fooder.settings import settings + + +@pytest.fixture(scope="session") +def db_manager() -> DatabaseSessionManager: + return DatabaseSessionManager(settings) + + +@pytest_asyncio.fixture(scope="session", autouse=True) +async def setup_database(db_manager: DatabaseSessionManager): + async with db_manager.connect() as conn: + await conn.run_sync(Base.metadata.create_all) + + yield diff --git a/fooder/test/fixtures/__init__.py b/fooder/test/fixtures/__init__.py index e2ed9fe..3a25d46 100644 --- a/fooder/test/fixtures/__init__.py +++ b/fooder/test/fixtures/__init__.py @@ -1,9 +1,5 @@ -from .client import * # noqa -from .user import * # noqa -from .product import * # noqa -from .meal import * # noqa -from .entry import * # noqa import pytest +from .dbssn import * @pytest.fixture diff --git a/fooder/test/fixtures/client.py b/fooder/test/fixtures/client.py deleted file mode 100644 index 246a9b3..0000000 --- a/fooder/test/fixtures/client.py +++ /dev/null @@ -1,110 +0,0 @@ -from fooder.app import app -from fooder.tasks_app import app as tasks_app -from httpx import ASGITransport, AsyncClient -import pytest -import httpx -import os - - -class Client: - def __init__( - self, - username: str | None = None, - password: str | None = None, - ): - self.client = lambda: AsyncClient( - transport=ASGITransport(app=app), - base_url="http://testserver/api", - headers=self.headers, - ) - self.headers = {"Accept": "application/json"} - - def set_token(self, token: str) -> None: - """set_token. - - :param token: - :type token: str - :rtype: None - """ - self.headers["Authorization"] = "Bearer " + token - - async def create_user(self, username: str, password: str) -> None: - data = {"username": username, "password": password} - response = await self.post("user", json=data) - response.raise_for_status() - - async def login(self, username: str, password: str, force_login: bool) -> None: - """login. - - :param username: - :type username: str - :param password: - :type password: str - :param force_login: - :type password: bool - :rtype: None - """ - data = {"username": username, "password": password} - - response = await self.post("token", data=data) - - if response.status_code != 200: - if force_login: - await self.create_user(username, password) - return await self.login(username, password, False) - else: - raise Exception( - f"Could not login as {username}! Detail: {response.text}" - ) - - result = response.json() - self.set_token(result["access_token"]) - - async def get(self, path: str, **kwargs) -> httpx.Response: - async with self.client() as client: - return await client.get(path, **kwargs) - - async def delete(self, path: str, **kwargs) -> httpx.Response: - async with self.client() as client: - return await client.delete(path, **kwargs) - - async def post(self, path: str, **kwargs) -> httpx.Response: - async with self.client() as client: - return await client.post(path, **kwargs) - - async def patch(self, path: str, **kwargs) -> httpx.Response: - async with self.client() as client: - return await client.patch(path, **kwargs) - - -class TasksClient(Client): - def __init__(self, authorized: bool = True): - super().__init__() - self.client = lambda: AsyncClient( - transport=ASGITransport(app=tasks_app), - base_url="http://testserver/api", - headers=self.headers, - ) - - if authorized: - self.headers["Authorization"] = "Bearer " + self.get_token() - - def get_token(self) -> str: - return os.getenv("API_KEY") - - -@pytest.fixture -def unauthorized_client() -> Client: - return Client() - - -@pytest.fixture -def tasks_client() -> Client: - return TasksClient() - - -@pytest.fixture -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/fixtures/dbssn.py b/fooder/test/fixtures/dbssn.py new file mode 100644 index 0000000..63284d9 --- /dev/null +++ b/fooder/test/fixtures/dbssn.py @@ -0,0 +1,24 @@ +import pytest_asyncio +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import event + + +@pytest_asyncio.fixture +async def db_session(db_manager): + async with db_manager._engine.connect() as conn: + trans = await conn.begin() + session = AsyncSession(bind=conn) + + nested = await conn.begin_nested() + + @event.listens_for(session.sync_session, "after_transaction_end") + def restart_savepoint(sess, transaction): + nonlocal nested + if not nested.is_active: + nested = conn.sync_connection.begin_nested() + + try: + yield session + finally: + await session.close() + await trans.rollback() diff --git a/fooder/test/fixtures/entry.py b/fooder/test/fixtures/entry.py deleted file mode 100644 index ba945f4..0000000 --- a/fooder/test/fixtures/entry.py +++ /dev/null @@ -1,14 +0,0 @@ -import pytest -from typing import Callable - - -@pytest.fixture -def entry_payload_factory() -> Callable[[int, int, float], dict[str, int | float]]: - def factory(meal_id: int, product_id: int, grams: float) -> dict[str, int | float]: - return { - "meal_id": meal_id, - "product_id": product_id, - "grams": grams, - } - - return factory diff --git a/fooder/test/fixtures/meal.py b/fooder/test/fixtures/meal.py deleted file mode 100644 index 98d98be..0000000 --- a/fooder/test/fixtures/meal.py +++ /dev/null @@ -1,37 +0,0 @@ -import pytest -from typing import Callable - - -@pytest.fixture -def meal_payload_factory() -> Callable[[int, int], dict[str, int | str]]: - def factory(diary_id: int, order: int) -> dict[str, int | str]: - return { - "order": order, - "diary_id": diary_id, - "name": f"meal {order}", - } - - return factory - - -@pytest.fixture -def meal_save_payload() -> Callable[[int], dict[str, str]]: - def factory(meal_id: int) -> dict[str, str]: - return { - "name": "new name", - } - - return factory - - -@pytest.fixture -def meal_from_preset() -> Callable[[int, int, int], dict[str, str | int]]: - def factory(order: int, diary_id: int, preset_id: int) -> dict[str, str | int]: - return { - "name": "new name", - "order": order, - "diary_id": diary_id, - "preset_id": preset_id, - } - - return factory diff --git a/fooder/test/fixtures/product.py b/fooder/test/fixtures/product.py deleted file mode 100644 index eb15bab..0000000 --- a/fooder/test/fixtures/product.py +++ /dev/null @@ -1,22 +0,0 @@ -import pytest -import uuid -from typing import Callable - - -@pytest.fixture -def product_payload_factory() -> Callable[[], dict[str, str | float]]: - def factory() -> dict[str, str | float]: - return { - "name": "test" + str(uuid.uuid4().hex), - "protein": 1.0, - "carb": 1.0, - "fat": 1.0, - "fiber": 1.0, - } - - return factory - - -@pytest.fixture -def product_payload(product_payload_factory) -> dict[str, str | float]: - return product_payload_factory() diff --git a/fooder/test/fixtures/user.py b/fooder/test/fixtures/user.py deleted file mode 100644 index 8190a38..0000000 --- a/fooder/test/fixtures/user.py +++ /dev/null @@ -1,22 +0,0 @@ -import pytest -from typing import Callable -import uuid - - -@pytest.fixture -def user_payload() -> dict[str, str]: - return { - "username": "test", - "password": "test", - } - - -@pytest.fixture -def user_payload_factory(user_payload) -> Callable[[], dict[str, str]]: - def factory() -> dict[str, str]: - return { - "username": "test" + str(uuid.uuid4().hex), - "password": "test", - } - - return factory diff --git a/fooder/test/test_db.py b/fooder/test/test_db.py new file mode 100644 index 0000000..fa759e6 --- /dev/null +++ b/fooder/test/test_db.py @@ -0,0 +1,148 @@ +# tests/test_db.py +import pytest +import asyncio +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession + +from fooder.db import DatabaseSessionManager, get_db_session +from fooder.settings import settings + + +@pytest.fixture +def fresh_manager(): + return DatabaseSessionManager(settings) + + +async def test_init_creates_engine_and_sessionmaker(db_manager: DatabaseSessionManager): + assert db_manager._engine is not None + assert db_manager._sessionmaker is not None + + +async def test_close_disposes_engine_and_nullifies_attrs( + fresh_manager: DatabaseSessionManager, +): + await fresh_manager.close() + assert fresh_manager._engine is None + assert fresh_manager._sessionmaker is None + + +async def test_connect_after_close_raises(fresh_manager: DatabaseSessionManager): + await fresh_manager.close() + + with pytest.raises(Exception, match="not initialized"): + async with fresh_manager.connect(): + pass + + +async def test_session_after_close_raises(fresh_manager: DatabaseSessionManager): + await fresh_manager.close() + + with pytest.raises(Exception, match="not initialized"): + async with fresh_manager.session(): + pass + + +async def test_close_when_already_closed_raises(fresh_manager: DatabaseSessionManager): + await fresh_manager.close() + + with pytest.raises(Exception, match="not initialized"): + await fresh_manager.close() + + +async def test_session_commit_persists_data(db_manager: DatabaseSessionManager): + async with db_manager.connect() as conn: + await conn.execute(text("CREATE TABLE test_commit(x int)")) + + async with db_manager.session() as session: + await session.execute(text("INSERT INTO test_commit VALUES (42)")) + await session.commit() + + async with db_manager.session() as session: + res = await session.execute(text("SELECT x FROM test_commit")) + assert res.scalar() == 42 + + +async def test_session_does_not_autocommit(db_manager: DatabaseSessionManager): + async with db_manager.connect() as conn: + await conn.execute(text("CREATE TABLE test_no_commit(x int)")) + + async with db_manager.session() as session: + await session.execute(text("INSERT INTO test_no_commit VALUES (1)")) + # no commit + + async with db_manager.session() as session: + res = await session.execute(text("SELECT * FROM test_no_commit")) + assert res.first() is None + + +async def test_connect_context_yields_working_connection( + db_manager: DatabaseSessionManager, +): + async with db_manager.connect() as conn: + assert isinstance(conn, AsyncConnection) + # prove the connection is real + res = await conn.execute(text("SELECT 1")) + assert res.scalar() == 1 + + +async def test_connect_rolls_back_on_exception(db_manager: DatabaseSessionManager): + """Raising inside connect() must roll back the txn.""" + + class BoomError(Exception): + pass + + with pytest.raises(BoomError): + async with db_manager.connect() as conn: + await conn.execute(text("CREATE TABLE t(x int)")) + await conn.execute(text("INSERT INTO t VALUES (1)")) + raise BoomError("deliberate") + + # Use a *fresh* connection so the failed one is really gone + async with db_manager.connect() as conn: + res = await conn.execute(text("SELECT * FROM t")) + assert res.first() is None + + +async def test_session_rolls_back_on_exception(db_manager: DatabaseSessionManager): + """Raising inside session() must roll back the txn.""" + + class BoomError(Exception): + pass + + with pytest.raises(BoomError): + async with db_manager.session() as session: + await session.execute(text("CREATE TABLE s(a int)")) + await session.execute(text("INSERT INTO s VALUES (1)")) + raise BoomError("deliberate") + + # Fresh session / connection + async with db_manager.session() as session: + res = await session.execute(text("SELECT * FROM s")) + assert res.first() is None + + +async def test_get_db_session_yields_active_session(): + async for session in get_db_session(): + assert isinstance(session, AsyncSession) + res = await session.execute(text("SELECT 1337")) + assert res.scalar() == 1337 + break # single yield is enough + + +async def test_concurrent_sessions(db_manager: DatabaseSessionManager): + async with db_manager.connect() as conn: + await conn.execute(text("CREATE TABLE test_concurrent(x int)")) + + async def worker(val): + async with db_manager.session() as session: + await session.execute( + text("INSERT INTO test_concurrent VALUES (:v)"), + {"v": val}, + ) + await session.commit() + + await asyncio.gather(*(worker(i) for i in range(5))) + + async with db_manager.session() as session: + res = await session.execute(text("SELECT COUNT(*) FROM test_concurrent")) + assert res.scalar() == 5 diff --git a/fooder/test/test_diary.py b/fooder/test/test_diary.py deleted file mode 100644 index 1e92d32..0000000 --- a/fooder/test/test_diary.py +++ /dev/null @@ -1,95 +0,0 @@ -import datetime -import pytest - - -@pytest.mark.anyio -async def test_get_diary(client): - today = datetime.date.today().isoformat() - response = await client.get("diary", params={"date": today}) - assert response.status_code == 200, response.json() - - assert response.json()["date"] == today - # new diary should contain exactly one meal - assert len(response.json()["meals"]) == 1 - - -@pytest.mark.anyio -async def test_diary_add_meal(client, meal_payload_factory): - today = datetime.date.today().isoformat() - response = await client.get("diary", params={"date": today}) - - diary_id = response.json()["id"] - meal_order = len(response.json()["meals"]) + 1 - - response = await client.post( - "meal", json=meal_payload_factory(diary_id, meal_order) - ) - assert response.status_code == 200, response.json() - - -@pytest.mark.anyio -async def test_diary_delete_meal(client): - today = datetime.date.today().isoformat() - response = await client.get("diary", params={"date": today}) - - meals_amount = len(response.json()["meals"]) - meal_id = response.json()["meals"][0]["id"] - - response = await client.delete(f"meal/{meal_id}") - assert response.status_code == 200, response.json() - - 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.anyio -async def test_diary_add_entry(client, product_payload_factory, entry_payload_factory): - today = datetime.date.today().isoformat() - response = await client.get("diary", params={"date": today}) - - meal_id = response.json()["meals"][0]["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 = await client.post("entry", json=entry_payload) - assert response.status_code == 200, response.json() - - -@pytest.mark.anyio -async def test_diary_edit_entry(client, entry_payload_factory): - today = datetime.date.today().isoformat() - response = await client.get("diary", params={"date": today}) - - entry = response.json()["meals"][0]["entries"][0] - id_ = entry["id"] - entry_payload = entry_payload_factory( - entry["meal_id"], entry["product"]["id"], entry["grams"] + 100.0 - ) - - 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.anyio -async def test_diary_delete_entry(client): - today = datetime.date.today().isoformat() - response = await client.get("diary", params={"date": today}) - - entry_id = response.json()["meals"][0]["entries"][0]["id"] - response = await client.delete(f"entry/{entry_id}") - assert response.status_code == 200, response.json() - - response = await client.get("diary", params={"date": today}) - assert response.status_code == 200, response.json() - deleted_entries = [ - entry - for meal in response.json()["meals"] - for entry in meal["entries"] - if entry["id"] == entry_id - ] - assert len(deleted_entries) == 0 diff --git a/fooder/test/test_preset.py b/fooder/test/test_preset.py deleted file mode 100644 index 2c0c626..0000000 --- a/fooder/test/test_preset.py +++ /dev/null @@ -1,107 +0,0 @@ -import datetime -import pytest - - -@pytest.mark.anyio -async def test_create_meal( - client, meal_payload_factory, product_payload_factory, entry_payload_factory -): - today = datetime.date.today().isoformat() - response = await client.get("diary", params={"date": today}) - - diary_id = response.json()["id"] - meal_order = len(response.json()["meals"]) + 1 - - 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 = (await client.post("product", json=product_payload_factory())).json()[ - "id" - ] - - entry_payload = entry_payload_factory(meal_id, product_id, 100.0) - response = await client.post("entry", json=entry_payload) - assert response.status_code == 200, response.json() - - -@pytest.mark.anyio -async def test_save_meal(client, meal_save_payload): - today = datetime.date.today().isoformat() - 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 = await client.post(f"meal/{meal_id}/save", json=save_payload) - assert response.status_code == 200, response.json() - - preset = response.json() - - for k, v in preset.items(): - if k in ("id", "name", "entries"): - continue - - assert meal[k] == v, f"{k} != {v}" - - -@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 = await client.get(f"preset?q={name}") - assert response.status_code == 200, response.json() - assert len(response.json()["presets"]) > 0, response.json() - - -@pytest.mark.anyio -async def test_create_meal_from_preset(client, meal_from_preset): - today = datetime.date.today().isoformat() - response = await client.get("diary", params={"date": today}) - - diary_id = response.json()["id"] - meal_order = len(response.json()["meals"]) + 1 - - response = await client.get("preset") - assert response.status_code == 200, response.json() - assert len(response.json()["presets"]) > 0, response.json() - - preset = response.json()["presets"][0] - - payload = meal_from_preset( - meal_order, - diary_id, - preset["id"], - ) - - response = await client.post("meal/from_preset", json=payload) - assert response.status_code == 200, response.json() - meal = response.json() - - for k, v in preset.items(): - if k in ("id", "name", "entries"): - continue - - assert meal[k] == v, f"{k} != {v}" - - -@pytest.mark.anyio -async def test_delete_preset(client): - presets = (await client.get("preset")).json()["presets"] - preset_id = presets[0]["id"] - - response = await client.get(f"preset/{preset_id}") - assert response.status_code == 200, response.json() - - response = await client.delete(f"preset/{preset_id}") - assert response.status_code == 200, response.json() - - 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 deleted file mode 100644 index 6fe2b20..0000000 --- a/fooder/test/test_product.py +++ /dev/null @@ -1,35 +0,0 @@ -import pytest - - -@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.anyio -async def test_list_product(client): - response = await client.get("product") - assert response.status_code == 200, response.json() - - data = response.json()["products"] - assert len(data) != 0 - - product_ids = set() - for product in data: - assert product["id"] not in product_ids - product_ids.add(product["id"]) - - -@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 = 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_tasks.py b/fooder/test/test_tasks.py deleted file mode 100644 index 03bf7e9..0000000 --- a/fooder/test/test_tasks.py +++ /dev/null @@ -1,15 +0,0 @@ -import pytest - - -@pytest.mark.anyio -async def test_cache_product_usage(client, tasks_client): - response = await client.get("product") - assert response.status_code == 200, response.json() - old_data = response.json() - - response = await tasks_client.post("/cache_product_usage_data") - assert response.status_code == 200, response.json() - - response = await client.get("product") - assert response.status_code == 200, response.json() - assert response.json() != old_data diff --git a/fooder/test/test_user.py b/fooder/test/test_user.py deleted file mode 100644 index e6a5fe4..0000000 --- a/fooder/test/test_user.py +++ /dev/null @@ -1,29 +0,0 @@ -import pytest - - -@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.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() - assert data["access_token"] is not None - assert data["refresh_token"] is not None - - -@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 = await client.post("token/refresh", json=payload) - assert response.status_code == 200, response.json() diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..2f4c80e --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +asyncio_mode = auto diff --git a/requirements.txt b/requirements/docker.txt similarity index 90% rename from requirements.txt rename to requirements/docker.txt index d08cf83..2ea3556 100644 --- a/requirements.txt +++ b/requirements/docker.txt @@ -1,6 +1,6 @@ fastapi pydantic -pydantic_settings +pydantic-settings sqlalchemy[postgresql_asyncpg] uvicorn[standard] asyncpg diff --git a/requirements_local.txt b/requirements/local.txt similarity index 88% rename from requirements_local.txt rename to requirements/local.txt index 00b80c1..88e1f55 100644 --- a/requirements_local.txt +++ b/requirements/local.txt @@ -1,6 +1,6 @@ fastapi pydantic -pydantic_settings +pydantic-settings sqlalchemy[postgresql_asyncpg] uvicorn[standard] python-jose[cryptography] @@ -8,6 +8,7 @@ bcrypt<5.0.0 passlib[bcrypt] fastapi-users pytest +pytest-asyncio requests black flake8 diff --git a/test.sh b/test.sh index abf655c..548b10d 100755 --- a/test.sh +++ b/test.sh @@ -5,36 +5,10 @@ echo "Running fooder api tests" -# if exists, remove test.db -[ -f test.db ] && rm test.db - -# create test env values -export DB_URI="sqlite+aiosqlite:///test.db" -export ECHO_SQL=0 -export SECRET_KEY=$(openssl rand -hex 32) -export REFRESH_SECRET_KEY=$(openssl rand -hex 32) -export API_KEY=$(openssl rand -hex 32) - -python3 -m fooder --create-tables - -# finally run tests if [[ $# -eq 1 ]]; then - python3 -m pytest fooder --disable-warnings -sv -k "${1}" + python -m pytest fooder --disable-warnings -sv -k "${1}" else - python3 -m pytest fooder --disable-warnings -sv + python -m pytest fooder --disable-warnings -sv fi -status=$? - -# unset test env values -unset POSTGRES_USER -unset POSTGRES_DATABASE -unset POSTGRES_PASSWORD -unset SECRET_KEY -unset REFRESH_SECRET -unset API_KEY - -# if exists, remove test.db -[ -f test.db ] && rm test.db - -exit $status +exit $?