[tests] added tests [product] order by latest usage

This commit is contained in:
doman 2023-09-03 15:43:24 +02:00
parent 611f271f1d
commit 24a7d5fa5a
15 changed files with 447 additions and 5 deletions

30
docker-compose.test.yml Normal file
View file

@ -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"

View file

@ -1,13 +1,13 @@
POSTGRES_MAX_CONNECTIONS=200 POSTGRES_MAX_CONNECTIONS=200
POSTGRES_USER="fooder" POSTGRES_USER="${POSTGRES_USER}"
POSTGRES_DATABASE="fooder" POSTGRES_DATABASE="${POSTGRES_DATABASE}"
POSTGRES_PASSWORD=123 POSTGRES_PASSWORD="${POSTGRES_PASSWORD}"
DB_URI="postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database/${POSTGRES_DATABASE}" DB_URI="postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database/${POSTGRES_DATABASE}"
ECHO_SQL=0 ECHO_SQL=0
SECRET_KEY="" # generate with $ openssl rand -hex 32 SECRET_KEY="${SECRET_KEY}" # generate with $ openssl rand -hex 32
REFRESH_SECRET_KEY="" # generate with $ openssl rand -hex 32 REFRESH_SECRET_KEY="${REFRESH_SECRET}" # generate with $ openssl rand -hex 32
ALGORITHM="HS256" ALGORITHM="HS256"
ACCESS_TOKEN_EXPIRE_MINUTES=30 ACCESS_TOKEN_EXPIRE_MINUTES=30
REFRESH_TOKEN_EXPIRE_DAYS=30 REFRESH_TOKEN_EXPIRE_DAYS=30

View file

@ -4,6 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from typing import AsyncIterator, Optional from typing import AsyncIterator, Optional
from .base import Base, CommonMixin from .base import Base, CommonMixin
from .user import User
class Product(Base, CommonMixin): class Product(Base, CommonMixin):
@ -38,6 +39,35 @@ class Product(Base, CommonMixin):
async for row in stream: async for row in stream:
yield row 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 @classmethod
async def create( async def create(
cls, cls,

0
fooder/test/__init__.py Normal file
View file

1
fooder/test/conftest.py Normal file
View file

@ -0,0 +1 @@
from .fixtures import *

5
fooder/test/fixtures/__init__.py vendored Normal file
View file

@ -0,0 +1,5 @@
from .client import *
from .user import *
from .product import *
from .meal import *
from .entry import *

93
fooder/test/fixtures/client.py vendored Normal file
View file

@ -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"],
)

14
fooder/test/fixtures/entry.py vendored Normal file
View file

@ -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

14
fooder/test/fixtures/meal.py vendored Normal file
View file

@ -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

22
fooder/test/fixtures/product.py vendored Normal file
View file

@ -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()

22
fooder/test/fixtures/user.py vendored Normal file
View file

@ -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

75
fooder/test/test_diary.py Normal file
View file

@ -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

View file

@ -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

29
fooder/test/test_user.py Normal file
View file

@ -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()

62
test.sh Executable file
View file

@ -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