diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..95b7416 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,30 @@ +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: 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 749f54f..982989c 100644 --- a/env.template +++ b/env.template @@ -1,13 +1,13 @@ POSTGRES_MAX_CONNECTIONS=200 -POSTGRES_USER="fooder" -POSTGRES_DATABASE="fooder" -POSTGRES_PASSWORD=123 +POSTGRES_USER="${POSTGRES_USER}" +POSTGRES_DATABASE="${POSTGRES_DATABASE}" +POSTGRES_PASSWORD="${POSTGRES_PASSWORD}" DB_URI="postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database/${POSTGRES_DATABASE}" ECHO_SQL=0 -SECRET_KEY="" # generate with $ openssl rand -hex 32 -REFRESH_SECRET_KEY="" # generate with $ openssl rand -hex 32 +SECRET_KEY="${SECRET_KEY}" # generate with $ openssl rand -hex 32 +REFRESH_SECRET_KEY="${REFRESH_SECRET}" # generate with $ openssl rand -hex 32 ALGORITHM="HS256" ACCESS_TOKEN_EXPIRE_MINUTES=30 REFRESH_TOKEN_EXPIRE_DAYS=30 diff --git a/fooder/domain/product.py b/fooder/domain/product.py index 15351fe..bee3d31 100644 --- a/fooder/domain/product.py +++ b/fooder/domain/product.py @@ -4,6 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from typing import AsyncIterator, Optional from .base import Base, CommonMixin +from .user import User class Product(Base, CommonMixin): @@ -38,6 +39,35 @@ class Product(Base, CommonMixin): async for row in stream: yield row + @classmethod + async def list_all_for_user( + cls, + session: AsyncSession, + offset: int, + limit: int, + user: User, + q: Optional[str] = None, + ) -> AsyncIterator["Product"]: + from .meal import Meal + from .diary import Diary + from .entry import Entry + + query = ( + select(cls) + .join(Entry, isouter=True) + .join(Meal, isouter=True) + .join(Diary, isouter=True) + .where(Diary.user_id == user.id) + ) + + if q: + query = query.filter(cls.name.ilike(f"%{q.lower()}%")) + + query = query.order_by(Entry.last_changed.desc()).offset(offset).limit(limit) + stream = await session.stream_scalars(query.order_by(cls.id)) + async for row in stream: + yield row + @classmethod async def create( cls, diff --git a/fooder/test/__init__.py b/fooder/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fooder/test/conftest.py b/fooder/test/conftest.py new file mode 100644 index 0000000..d4bb209 --- /dev/null +++ b/fooder/test/conftest.py @@ -0,0 +1 @@ +from .fixtures import * diff --git a/fooder/test/fixtures/__init__.py b/fooder/test/fixtures/__init__.py new file mode 100644 index 0000000..64c0ec0 --- /dev/null +++ b/fooder/test/fixtures/__init__.py @@ -0,0 +1,5 @@ +from .client import * +from .user import * +from .product import * +from .meal import * +from .entry import * diff --git a/fooder/test/fixtures/client.py b/fooder/test/fixtures/client.py new file mode 100644 index 0000000..98dbc00 --- /dev/null +++ b/fooder/test/fixtures/client.py @@ -0,0 +1,93 @@ +import requests +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}/" + + +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) + + def set_token(self, token: str) -> None: + """set_token. + + :param token: + :type token: str + :rtype: None + """ + self.session.headers["Authorization"] = "Bearer " + token + + def create_user(self, username: str, password: str) -> None: + data = {"username": username, "password": password} + response = self.post("user", json=data) + response.raise_for_status() + + 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 = self.post("token", data=data) + + if response.status_code != 200: + if force_login: + self.create_user(username, password) + return self.login(username, password, False) + else: + raise Exception(f"Could not login as {username}") + + 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) + + def delete(self, path: str, **kwargs) -> requests.Response: + return self.session.delete(os.path.join(self.base_url, path), **kwargs) + + def post(self, path: str, **kwargs) -> requests.Response: + return self.session.post(os.path.join(self.base_url, path), **kwargs) + + def patch(self, path: str, **kwargs) -> requests.Response: + return self.session.patch(os.path.join(self.base_url, path), **kwargs) + + +@pytest.fixture +def unauthorized_client() -> Client: + return Client(get_api_url("api")) + + +@pytest.fixture +def client(user_payload) -> Client: + return Client( + get_api_url("api"), + username=user_payload["username"], + password=user_payload["password"], + ) diff --git a/fooder/test/fixtures/entry.py b/fooder/test/fixtures/entry.py new file mode 100644 index 0000000..ba945f4 --- /dev/null +++ b/fooder/test/fixtures/entry.py @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000..44fe769 --- /dev/null +++ b/fooder/test/fixtures/meal.py @@ -0,0 +1,14 @@ +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 diff --git a/fooder/test/fixtures/product.py b/fooder/test/fixtures/product.py new file mode 100644 index 0000000..eb15bab --- /dev/null +++ b/fooder/test/fixtures/product.py @@ -0,0 +1,22 @@ +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 new file mode 100644 index 0000000..8190a38 --- /dev/null +++ b/fooder/test/fixtures/user.py @@ -0,0 +1,22 @@ +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_diary.py b/fooder/test/test_diary.py new file mode 100644 index 0000000..b00e405 --- /dev/null +++ b/fooder/test/test_diary.py @@ -0,0 +1,75 @@ +import datetime +import pytest + + +@pytest.mark.dependency() +def test_get_diary(client): + today = datetime.date.today().isoformat() + response = 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.dependency(depends=["test_get_diary"]) +def test_diary_add_meal(client, meal_payload_factory): + today = datetime.date.today().isoformat() + response = 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)) + assert response.status_code == 200, response.json() + + +@pytest.mark.dependency(depends=["test_get_diary"]) +def test_diary_add_entry(client, product_payload_factory, entry_payload_factory): + today = datetime.date.today().isoformat() + response = client.get("diary", params={"date": today}) + + meal_id = response.json()["meals"][0]["id"] + + product_id = 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) + assert response.status_code == 200, response.json() + + +@pytest.mark.dependency(depends=["test_diary_add_entry"]) +def test_diary_edit_entry(client, entry_payload_factory): + today = datetime.date.today().isoformat() + response = 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 = 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): + today = datetime.date.today().isoformat() + response = client.get("diary", params={"date": today}) + + entry_id = response.json()["meals"][0]["entries"][0]["id"] + response = client.delete(f"entry/{entry_id}") + assert response.status_code == 200, response.json() + + response = 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_product.py b/fooder/test/test_product.py new file mode 100644 index 0000000..2bd36f3 --- /dev/null +++ b/fooder/test/test_product.py @@ -0,0 +1,45 @@ +import pytest +import datetime + + +@pytest.mark.dependency() +def test_create_product(client, product_payload): + response = 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") + 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.dependency(depends=["test_list_product"]) +def test_products_list_by_latest_usage( + client, product_payload_factory, entry_payload_factory +): + today = datetime.date.today().isoformat() + response = client.get("diary", params={"date": today}) + assert response.status_code == 200, response.json() + + meal_id = response.json()["meals"][0]["id"] + + response = client.get("product") + product_id = response.json()["products"][0]["id"] + + entry_payload = entry_payload_factory(meal_id, product_id, 100.0) + response = client.post("entry", json=entry_payload) + + for _ in range(5): + client.post("product", json=product_payload_factory()).json()["id"] + + response = client.get("product") + assert response.json()["products"][0]["id"] == product_id diff --git a/fooder/test/test_user.py b/fooder/test/test_user.py new file mode 100644 index 0000000..072d4cc --- /dev/null +++ b/fooder/test/test_user.py @@ -0,0 +1,29 @@ +import pytest + + +@pytest.mark.dependency() +def test_user_creation(unauthorized_client, user_payload_factory): + response = 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) + 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.dependency(depends=["test_user_login"]) +def test_user_refresh_token(client, user_payload): + response = 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) + assert response.status_code == 200, response.json() diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..2e09a40 --- /dev/null +++ b/test.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# +# 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 + +# create test env values +export POSTGRES_USER=fooder_test +export POSTGRES_DATABASE=fooder_test +export POSTGRES_PASSWORD=$(pwgen 13 1) +export SECRET_KEY=$(openssl rand -hex 32) +export REFRESH_SECRET=$(openssl rand -hex 32) + +rm -f .env.test +envsubst < env.template > .env.test + +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 +pytest fooder -sv -k "${TESTS}" + +# clean up after tests +echo "cleaning up..." +${DC} down --remove-orphans +rm -f .env.test