[tests] added tests [product] order by latest usage
This commit is contained in:
parent
611f271f1d
commit
24a7d5fa5a
15 changed files with 447 additions and 5 deletions
30
docker-compose.test.yml
Normal file
30
docker-compose.test.yml
Normal 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"
|
10
env.template
10
env.template
|
@ -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
|
||||||
|
|
|
@ -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
0
fooder/test/__init__.py
Normal file
1
fooder/test/conftest.py
Normal file
1
fooder/test/conftest.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
from .fixtures import *
|
5
fooder/test/fixtures/__init__.py
vendored
Normal file
5
fooder/test/fixtures/__init__.py
vendored
Normal 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
93
fooder/test/fixtures/client.py
vendored
Normal 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
14
fooder/test/fixtures/entry.py
vendored
Normal 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
14
fooder/test/fixtures/meal.py
vendored
Normal 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
22
fooder/test/fixtures/product.py
vendored
Normal 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
22
fooder/test/fixtures/user.py
vendored
Normal 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
75
fooder/test/test_diary.py
Normal 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
|
45
fooder/test/test_product.py
Normal file
45
fooder/test/test_product.py
Normal 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
29
fooder/test/test_user.py
Normal 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
62
test.sh
Executable 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
|
Loading…
Reference in a new issue