Merge branch 'development' into main

This commit is contained in:
lvrossem
2023-04-12 15:46:09 -06:00
22 changed files with 1459 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
venv/*
venv
*__pycache__*

7
format.sh Normal file
View File

@@ -0,0 +1,7 @@
flake8 src/*
black src/*
isort src/*
flake8 tests/*
black tests/*
isort tests/*

15
requirements.txt Normal file
View File

@@ -0,0 +1,15 @@
fastapi
pydantic
sqlalchemy
uvicorn[standard]
psycopg2-binary
fastapi_utils
flake8
black
isort
interrogate
python-jose[cryptography]
passlib
jwt
PyJWT
pytest-asyncio

0
src/__init__.py Normal file
View File

View File

@@ -0,0 +1,77 @@
from datetime import datetime, timedelta
import jwt
from fastapi import Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from src.crud.users import (check_empty_fields, get_user_by_username,
pwd_context)
from src.models import User
DEFAULT_NR_HIGH_SCORES = 10
# JWT authentication setup
jwt_secret = "secret_key"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 44640 # One month
bearer_scheme = HTTPBearer()
def get_current_user_name(
token: HTTPAuthorizationCredentials = Depends(bearer_scheme),
):
try:
payload = jwt.decode(
token.credentials,
jwt_secret,
algorithms=[ALGORITHM],
)
username = payload.get("sub")
if username is None:
raise HTTPException(status_code=401, detail="Invalid JWT token")
return username
except jwt.exceptions.DecodeError:
raise HTTPException(status_code=401, detail="Invalid JWT token")
def authenticate_user(db: Session, username: str, password: str):
"""Checks whether the provided credentials match with an existing User"""
db_user = get_user_by_username(db, username)
if not db_user:
return False
hashed_password = db_user.hashed_password
if not hashed_password or not pwd_context.verify(password, hashed_password):
return False
return db_user
def register(db: Session, username: str, password: str, avatar: str):
"""Register a new user"""
check_empty_fields(username, password, avatar)
db_user = get_user_by_username(db, username)
if db_user:
raise HTTPException(status_code=400, detail="Username already registered")
db_user = User(
username=username, hashed_password=pwd_context.hash(password), avatar=avatar
)
db.add(db_user)
db.commit()
db.refresh(db_user)
return login(db, username, password)
def login(db: Session, username: str, password: str):
"""Log in based on username and password; supply access token if succeeded"""
user = authenticate_user(db, username, password)
if not user:
raise HTTPException(status_code=401, detail="Invalid username or password")
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token_payload = {
"sub": user.username,
"exp": datetime.utcnow() + access_token_expires,
}
access_token = jwt.encode(access_token_payload, jwt_secret, algorithm=ALGORITHM)
return {"access_token": access_token}

View File

@@ -0,0 +1,84 @@
from fastapi import HTTPException
from sqlalchemy.orm import Session
from src.enums import CourseEnum
from src.models import CourseProgress, User
from src.schemas.courseprogress import CourseProgressBase, CourseProgressParent
def get_course_progress(db: Session, user: User, course: CourseEnum):
"""Get the progress a user has for a certain course"""
result = []
courses_to_fetch = [course]
if course == CourseEnum.All:
all_courses_list = [course for course in CourseEnum]
courses_to_fetch = filter(
lambda course: course != CourseEnum.All, all_courses_list
)
for course in courses_to_fetch:
course_progress = (
db.query(CourseProgress)
.filter(
CourseProgress.owner_id == user.user_id, CourseProgress.course == course
)
.first()
)
if course_progress:
result.append(
CourseProgressParent(
progress_value=course_progress.progress_value, course=course
)
)
else:
db.add(
CourseProgress(progress_value=0.0, course=course, owner_id=user.user_id)
)
db.commit()
result.append(CourseProgressParent(progress_value=0.0, course=course))
return result
def initialize_user(db: Session, user: User):
"""Create CourseProgress records with a value of 0 for a new user"""
for course in CourseEnum:
if course != CourseEnum.All:
db.add(
CourseProgress(progress_value=0.0, course=course, owner_id=user.user_id)
)
db.commit()
def patch_course_progress(
db: Session, user: User, course: CourseEnum, course_progress: CourseProgressBase
):
"""Change the progress value for a given course"""
if course_progress.progress_value > 1 or course_progress.progress_value < 0:
raise HTTPException(status_code=400, detail="Invalid progress value")
db_course_progress_list = []
if course != CourseEnum.All:
db_course_progress_list = (
db.query(CourseProgress)
.filter(
CourseProgress.owner_id == user.user_id,
CourseProgress.course == course,
)
.all()
)
else:
db_course_progress_list = (
db.query(CourseProgress)
.filter(CourseProgress.owner_id == user.user_id)
.all()
)
for db_course_progress in db_course_progress_list:
db_course_progress.progress_value = course_progress.progress_value
db.commit()
return [
CourseProgressParent(course=db_cp.course, progress_value=db_cp.progress_value)
for db_cp in db_course_progress_list
]

99
src/crud/highscores.py Normal file
View File

@@ -0,0 +1,99 @@
from fastapi import HTTPException
from sqlalchemy import desc
from sqlalchemy.orm import Session
from src.enums import MinigameEnum
from src.models import HighScore, User
from src.schemas.highscores import HighScoreBase
from src.schemas.users import UserHighScore
def get_high_scores(
db: Session, minigame: MinigameEnum, user: User, nr_highest: int, mine_only: bool
):
"""Get the n highest scores of a given minigame"""
if nr_highest < 1:
raise HTTPException(status_code=400, detail="Invalid number of high scores")
if mine_only:
if nr_highest > 1:
raise HTTPException(
status_code=400,
detail="nr_highest should be 1 when requesting high score of current user only",
)
else:
high_score = (
db.query(HighScore)
.filter(
HighScore.minigame == minigame, HighScore.owner_id == user.user_id
)
.first()
)
if high_score:
return [
UserHighScore(
username=user.username,
score_value=high_score.score_value,
avatar=user.avatar,
)
]
else:
return []
user_high_scores = []
if not minigame:
minigame = MinigameEnum.SpellingBee
high_scores = (
db.query(HighScore)
.filter(HighScore.minigame == minigame)
.order_by(desc(HighScore.score_value))
.limit(nr_highest)
.all()
)
for high_score in high_scores:
owner = db.query(User).filter(User.user_id == high_score.owner_id).first()
user_high_scores.append(
UserHighScore(
username=owner.username,
score_value=high_score.score_value,
avatar=owner.avatar,
)
)
return user_high_scores
def create_high_score(
db: Session, user: User, minigame: MinigameEnum, high_score: HighScoreBase
):
"""Create a new high score for a given minigame"""
def add_to_db():
"""Helper function that adds new score to database; prevents code duplication"""
db_high_score = HighScore(
score_value=high_score.score_value,
minigame=minigame,
owner_id=user.user_id,
)
db.add(db_high_score)
db.commit()
db.refresh(db_high_score)
return db_high_score
old_high_score = (
db.query(HighScore)
.filter(
HighScore.owner_id == user.user_id,
HighScore.minigame == minigame,
)
.first()
)
if old_high_score:
if old_high_score.score_value < high_score.score_value:
db.delete(old_high_score)
return add_to_db()
else:
return old_high_score
else:
return add_to_db()

42
src/crud/users.py Normal file
View File

@@ -0,0 +1,42 @@
from fastapi import HTTPException
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from src.models import User
from src.schemas.users import UserCreate
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def check_empty_fields(username: str, password: str, avatar: str):
"Checks if any user fields are empty"
if len(avatar) == 0:
raise HTTPException(status_code=400, detail="No avatar was provided")
if len(username) == 0:
raise HTTPException(status_code=400, detail="No username was provided")
if len(password) == 0:
raise HTTPException(status_code=400, detail="No password was provided")
def patch_user(db: Session, username: str, user: UserCreate):
"""Changes the username and/or the password of a User"""
check_empty_fields(user.username, user.password, user.avatar)
db_user = get_user_by_username(db, username)
potential_duplicate = get_user_by_username(db, user.username)
if potential_duplicate:
if potential_duplicate.user_id != db_user.user_id:
raise HTTPException(status_code=400, detail="Username already registered")
db_user.username = user.username
db_user.hashed_password = pwd_context.hash(user.password)
db_user.avatar = user.avatar
db.commit()
def get_user_by_username(db: Session, username: str):
"""Fetches a User from the database by their username"""
return db.query(User).filter(User.username == username).first()
def get_users(db: Session):
"""Fetch a list of all users"""
return db.query(User).all()

19
src/database.py Normal file
View File

@@ -0,0 +1,19 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
SQLALCHEMY_DATABASE_URL = "postgresql://admin:WeSign123!@localhost/wesigndev"
engine = create_engine(SQLALCHEMY_DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

36
src/enums.py Normal file
View File

@@ -0,0 +1,36 @@
from fastapi_utils.enums import StrEnum
from sqlalchemy.types import Enum, TypeDecorator
class StrEnumType(TypeDecorator):
impl = Enum
def __init__(self, enum_class, **kw):
self.enum_class = enum_class
super().__init__(enum_class, **kw)
def process_bind_param(self, value, dialect):
if value is None:
return None
return value.value
def process_result_value(self, value, dialect):
if value is None:
return None
return self.enum_class(value)
class MinigameEnum(StrEnum):
SpellingBee = "SpellingBee"
Hangman = "Hangman"
JustSign = "JustSign"
class CourseEnum(StrEnum):
Fingerspelling = "Fingerspelling"
Basics = "Basics"
Hobbies = "Hobbies"
Animals = "Animals"
Colors = "Colors"
FruitsVegetables = "FruitsVegetables"
All = "All"

113
src/main.py Normal file
View File

@@ -0,0 +1,113 @@
import sys
from typing import List, Optional
from fastapi import Depends, FastAPI
from sqlalchemy.orm import Session
sys.path.append("..")
from src.crud import authentication as crud_authentication
from src.crud import courseprogress as crud_courseprogress
from src.crud import highscores as crud_highscores
from src.crud import users as crud_users
from src.database import Base, engine, get_db
from src.enums import CourseEnum, MinigameEnum
from src.schemas import courseprogress, highscores, users
app = FastAPI()
Base.metadata.create_all(bind=engine)
@app.get("/")
async def root():
return {"message": "Hello world!"}
@app.get("/allusers", response_model=List[users.User])
async def read_users(db: Session = Depends(get_db)):
return crud_users.get_users(db)
@app.get("/users", response_model=users.User)
async def read_user(
current_user_name: str = Depends(crud_authentication.get_current_user_name),
db: Session = Depends(get_db),
):
return crud_users.get_user_by_username(db, current_user_name)
@app.patch("/users")
async def patch_current_user(
user: users.UserCreate,
current_user_name=Depends(crud_authentication.get_current_user_name),
db: Session = Depends(get_db),
):
crud_users.patch_user(db, current_user_name, user)
@app.post("/register")
async def register(user: users.UserCreate, db: Session = Depends(get_db)):
access_token = crud_authentication.register(
db, user.username, user.password, user.avatar
)
user = crud_users.get_user_by_username(db, user.username)
crud_courseprogress.initialize_user(db, user)
return access_token
@app.post("/login")
async def login(user: users.UserCreate, db: Session = Depends(get_db)):
return crud_authentication.login(db, user.username, user.password)
@app.get("/highscores/{minigame}", response_model=List[users.UserHighScore])
async def get_high_scores(
minigame: MinigameEnum,
nr_highest: Optional[int] = 1,
mine_only: Optional[bool] = True,
current_user_name: str = Depends(crud_authentication.get_current_user_name),
db: Session = Depends(get_db),
):
print(str(nr_highest))
print(str(mine_only))
user = crud_users.get_user_by_username(db, current_user_name)
return crud_highscores.get_high_scores(db, minigame, user, nr_highest, mine_only)
@app.put("/highscores/{minigame}", response_model=highscores.HighScore)
async def create_high_score(
minigame: MinigameEnum,
high_score: highscores.HighScoreBase,
current_user_name: str = Depends(crud_authentication.get_current_user_name),
db: Session = Depends(get_db),
):
current_user = crud_users.get_user_by_username(db, current_user_name)
return crud_highscores.create_high_score(db, current_user, minigame, high_score)
@app.get(
"/courseprogress/{course}", response_model=List[courseprogress.CourseProgressParent]
)
async def get_course_progress(
course: Optional[CourseEnum] = CourseEnum.All,
current_user_name: str = Depends(crud_authentication.get_current_user_name),
db: Session = Depends(get_db),
):
current_user = crud_users.get_user_by_username(db, current_user_name)
return crud_courseprogress.get_course_progress(db, current_user, course)
@app.patch(
"/courseprogress/{course}", response_model=List[courseprogress.CourseProgressParent]
)
async def patch_course_progress(
course: CourseEnum,
course_progress: courseprogress.CourseProgressBase,
current_user_name: str = Depends(crud_authentication.get_current_user_name),
db: Session = Depends(get_db),
):
current_user = crud_users.get_user_by_username(db, current_user_name)
return crud_courseprogress.patch_course_progress(
db, current_user, course, course_progress
)

46
src/models.py Normal file
View File

@@ -0,0 +1,46 @@
from sqlalchemy import Column, Float, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from src.database import Base
class User(Base):
"""The database model for users"""
__tablename__ = "users"
user_id = Column(Integer, primary_key=True, index=True)
username = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
avatar = Column(String, nullable=False)
high_scores = relationship(
"HighScore", back_populates="owner", cascade="all, delete", lazy="dynamic"
)
course_progress = relationship(
"CourseProgress", back_populates="owner", cascade="all, delete", lazy="dynamic"
)
class HighScore(Base):
"""The database model for high scores"""
__tablename__ = "high_scores"
high_score_id = Column(Integer, primary_key=True, index=True)
score_value = Column(Float, nullable=False)
minigame = Column(String, nullable=False)
owner_id = Column(Integer, ForeignKey("users.user_id"))
owner = relationship("User", back_populates="high_scores")
class CourseProgress(Base):
"""The database model for course progress"""
__tablename__ = "course_progress"
course_progress_id = Column(Integer, primary_key=True, index=True)
progress_value = Column(Float, nullable=False)
course = Column(String, nullable=False)
owner_id = Column(Integer, ForeignKey("users.user_id"))
owner = relationship("User", back_populates="course_progress")

View File

@@ -0,0 +1,19 @@
from pydantic import BaseModel
from src.enums import CourseEnum
class CourseProgressBase(BaseModel):
progress_value: float
class CourseProgressParent(CourseProgressBase):
course: CourseEnum
class CourseProgress(CourseProgressParent):
course_progress_id: int
owner_id: int
class Config:
orm_mode = True

16
src/schemas/highscores.py Normal file
View File

@@ -0,0 +1,16 @@
from pydantic import BaseModel
from src.enums import MinigameEnum
class HighScoreBase(BaseModel):
score_value: float
class HighScore(HighScoreBase):
high_score_id: int
owner_id: int
minigame: MinigameEnum
class Config:
orm_mode = True

22
src/schemas/users.py Normal file
View File

@@ -0,0 +1,22 @@
from pydantic import BaseModel
class UserBase(BaseModel):
username: str
avatar: str = ""
class User(UserBase):
user_id: int
hashed_password: str
class Config:
orm_mode = True
class UserCreate(UserBase):
password: str
class UserHighScore(UserBase):
score_value: float

0
tests/__init__.py Normal file
View File

16
tests/base.py Normal file
View File

@@ -0,0 +1,16 @@
import sys
from fastapi.testclient import TestClient
sys.path.append("..")
from src.main import app, get_db
from tests.config.database import override_get_db
app.dependency_overrides[get_db] = override_get_db
client = TestClient(app)
username = "user1"
password = "password"
avatar = "lion"

30
tests/config/database.py Normal file
View File

@@ -0,0 +1,30 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from src.database import Base
from src.models import CourseProgress, HighScore, User
SQLALCHEMY_DATABASE_URL = "postgresql://admin:WeSign123!@localhost/wesigntest"
engine = create_engine(SQLALCHEMY_DATABASE_URL)
TestSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base.metadata.create_all(bind=engine)
def clear_db():
db = TestSessionLocal()
db.query(HighScore).delete()
db.query(CourseProgress).delete()
db.query(User).delete()
db.commit()
def override_get_db():
try:
db = TestSessionLocal()
yield db
finally:
db.close()

View File

@@ -0,0 +1,159 @@
import pytest
from fastapi.testclient import TestClient
from src.main import app, get_db
from tests.base import avatar, client, password, username
from tests.config.database import clear_db, override_get_db
async def register_user():
response = client.post(
"/register",
headers={"Content-Type": "application/json"},
json={"username": username, "password": password, "avatar": avatar},
)
assert response.status_code == 200
return response.json()["access_token"]
@pytest.mark.asyncio
async def test_register():
"""Test the register endpoint"""
clear_db()
response = client.post(
"/register",
headers={"Content-Type": "application/json"},
json={"username": username, "password": password, "avatar": avatar},
)
assert response.status_code == 200
assert len(response.json()["access_token"]) > 0
@pytest.mark.asyncio
async def test_register_duplicate_name_should_fail():
"""Test whether registering a user with an existing username fails"""
clear_db()
await register_user()
response = client.post(
"/register",
headers={"Content-Type": "application/json"},
json={"username": username, "password": password, "avatar": avatar},
)
assert response.status_code == 400
assert "access_token" not in response.json()
@pytest.mark.asyncio
async def test_register_without_username_should_fail():
"""Test whether registering a user without passing a username fails"""
clear_db()
response = client.post(
"/register",
headers={"Content-Type": "application/json"},
json={"password": password, "avatar": avatar},
)
assert response.status_code == 422
assert "access_token" not in response.json()
@pytest.mark.asyncio
async def test_register_without_password_should_fail():
"""Test whether registering a user without passing a password fails"""
clear_db()
response = client.post(
"/register",
headers={"Content-Type": "application/json"},
json={"username": username, "avatar": avatar},
)
assert response.status_code == 422
assert "access_token" not in response.json()
@pytest.mark.asyncio
async def test_register_without_avatar_should_fail():
"""Test whether registering a user without passing an avatar fails"""
clear_db()
response = client.post(
"/register",
headers={"Content-Type": "application/json"},
json={"username": username, "password": password},
)
# Not ideal that this is 400 instead of 422, but had no other choice than to give this field a default value
assert response.status_code == 400
assert "access_token" not in response.json()
@pytest.mark.asyncio
async def test_login():
"""Test the login endpoint"""
clear_db()
await register_user()
response = client.post(
"/login",
headers={"Content-Type": "application/json"},
json={"username": username, "password": password},
)
assert response.status_code == 200
assert len(response.json()["access_token"]) > 0
@pytest.mark.asyncio
async def test_login_wrong_password_should_fail():
clear_db()
await register_user()
wrong_password = password + "extra characters"
response = client.post(
"/login",
headers={"Content-Type": "application/json"},
json={"username": username, "password": wrong_password},
)
assert response.status_code == 401
assert "access_token" not in response.json()
@pytest.mark.asyncio
async def test_login_without_username_should_fail():
"""Test whether logging in without passing a username fails"""
clear_db()
await register_user()
response = client.post(
"/login",
headers={"Content-Type": "application/json"},
json={"password": password},
)
assert response.status_code == 422
assert "access_token" not in response.json()
@pytest.mark.asyncio
async def test_login_without_password_should_fail():
"""Test whether logging in without passing a password fails"""
clear_db()
await register_user()
response = client.post(
"/login",
headers={"Content-Type": "application/json"},
json={"username": username},
)
assert response.status_code == 422
assert "access_token" not in response.json()

View File

@@ -0,0 +1,200 @@
import random
import pytest
from fastapi.testclient import TestClient
from src.enums import CourseEnum
from src.main import app, get_db
from tests.base import avatar, client, password, username
from tests.config.database import clear_db, override_get_db
async def register_user():
response = client.post(
"/register",
headers={"Content-Type": "application/json"},
json={"username": username, "password": password, "avatar": avatar},
)
assert response.status_code == 200
return response.json()["access_token"]
@pytest.mark.asyncio
async def test_register_creates_progress_of_zero():
"""Test whether registering a new user initializes all progress values to 0.0"""
clear_db()
token = await register_user()
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
for course in CourseEnum:
if course != CourseEnum.All:
response = client.get(f"/courseprogress/{course}", headers=headers)
assert response.status_code == 200
response = response.json()[0]
assert response["progress_value"] == 0.0
assert response["course"] == course
@pytest.mark.asyncio
async def test_get_all_returns_all():
"""Test whether the 'All'-course fetches all course progress values"""
clear_db()
token = await register_user()
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
response = client.get("/courseprogress/All", headers=headers)
assert response.status_code == 200
response = response.json()
for course in CourseEnum:
if course != CourseEnum.All:
assert {"progress_value": 0.0, "course": course} in response
@pytest.mark.asyncio
async def test_get_course_progress_value_without_auth_should_fail():
"""Test whether fetching a course progress value without authentication fails"""
clear_db()
headers = {"Content-Type": "application/json"}
for course in CourseEnum:
response = client.get(f"/courseprogress/{course}", headers=headers)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_get_nonexisting_course_should_fail():
"""Test whether fetching the progress of a nonexisting course fails"""
clear_db()
token = await register_user()
fake_course = "FakeCourse"
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
response = client.get(f"/courseprogress/{fake_course}", headers=headers)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_patch_course_progress():
"""Test whether patching the progress value of a course works properly"""
clear_db()
token = await register_user()
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
for course in CourseEnum:
if course != CourseEnum.All:
progress_value = random.uniform(0, 1)
response = client.patch(
f"/courseprogress/{course}",
headers=headers,
json={"progress_value": progress_value},
)
assert response.status_code == 200
assert response.json()[0]["progress_value"] == progress_value
@pytest.mark.asyncio
async def test_patch_all_should_patch_all_courses():
"""Test whether patching the 'All'-course updates all progress values"""
clear_db()
token = await register_user()
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
progress_value = random.uniform(0, 1)
response = client.patch(
"/courseprogress/All",
headers=headers,
json={"progress_value": progress_value},
)
assert response.status_code == 200
response = client.get("/courseprogress/All", headers=headers)
assert response.status_code == 200
response = response.json()
for course in CourseEnum:
if course != CourseEnum.All:
assert {"progress_value": progress_value, "course": course} in response
@pytest.mark.asyncio
async def test_patch_nonexisting_course_should_fail():
"""Test whether patching a nonexisting course fails"""
clear_db()
token = await register_user()
fake_course = "FakeCourse"
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
progress_value = random.uniform(0, 1)
response = client.patch(
f"/courseprogress/{fake_course}",
headers=headers,
json={"progress_value": progress_value},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_patch_course_with_invalid_value_should_fail():
"""Test whether patching a course progress value with an invalid value fails"""
clear_db()
token = await register_user()
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
too_high_progress_value = random.uniform(0, 1) + 2
too_low_progress_value = random.uniform(0, 1) - 2
response = client.patch(
"/courseprogress/All",
headers=headers,
json={"progress_value": too_high_progress_value},
)
assert response.status_code == 400
response = client.patch(
"/courseprogress/All",
headers=headers,
json={"progress_value": too_low_progress_value},
)
assert response.status_code == 400
@pytest.mark.asyncio
async def test_patch_course_progress_value_without_auth_should_fail():
"""Test whether updating a course progress value without authentication fails"""
clear_db()
headers = {"Content-Type": "application/json"}
for course in CourseEnum:
response = client.patch(
f"/courseprogress/{course}",
headers=headers,
json={"progress_value": random.uniform(0, 1)},
)
assert response.status_code == 403

318
tests/test_highscores.py Normal file
View File

@@ -0,0 +1,318 @@
import random
import pytest
from fastapi.testclient import TestClient
from src.enums import MinigameEnum
from src.main import app, get_db
from tests.base import avatar, client, password, username
from tests.config.database import clear_db, override_get_db
async def register_user():
response = client.post(
"/register",
headers={"Content-Type": "application/json"},
json={"username": username, "password": password, "avatar": avatar},
)
assert response.status_code == 200
return response.json()["access_token"]
@pytest.mark.asyncio
async def test_put_highscore():
"""Test whether putting a new high score succeeds"""
clear_db()
token = await register_user()
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
for minigame in MinigameEnum:
score_value = random.random()
response = client.put(
f"/highscores/{minigame}",
headers=headers,
json={"score_value": score_value},
)
assert response.status_code == 200
response = response.json()
assert response["minigame"] == minigame
assert response["score_value"] == score_value
@pytest.mark.asyncio
async def test_put_lower_highscore_does_not_change_old_value():
"""Test whether putting a new high score lower than the current one doesn't change the old one"""
clear_db()
token = await register_user()
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
for minigame in MinigameEnum:
score_value = random.random()
response = client.put(
f"/highscores/{minigame}",
headers=headers,
json={"score_value": score_value},
)
assert response.status_code == 200
response = response.json()
assert response["minigame"] == minigame
assert response["score_value"] == score_value
lower_score_value = score_value - 100
response = client.put(
f"/highscores/{minigame}",
headers=headers,
json={"score_value": lower_score_value},
)
assert response.status_code == 200
response = response.json()
assert response["minigame"] == minigame
assert response["score_value"] == score_value
@pytest.mark.asyncio
async def test_put_highscore_for_nonexisting_minigame_should_fail():
"""Test whether putting a new high score for a nonexisting minigame fails"""
clear_db()
token = await register_user()
fake_minigame = "FakeGame"
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
response = client.put(
f"/highscores/{fake_minigame}",
headers=headers,
json={"score_value": random.random()},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_put_highscores_without_auth_should_fail():
"""Test whether putting high scores without authentication fails"""
clear_db()
headers = {"Content-Type": "application/json"}
for minigame in MinigameEnum:
response = client.put(
f"/highscores/{minigame}",
headers=headers,
json={"score_value": random.random()},
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_get_highscores_without_auth_should_fail():
"""Test whether fetching high scores without authentication fails"""
clear_db()
headers = {"Content-Type": "application/json"}
for minigame in MinigameEnum:
response = client.get(
f"/highscores/{minigame}",
headers=headers,
)
assert response.status_code == 403
response = client.get(
f"/highscores/{minigame}?mine_only=false&nr_highest={random.randint(1, 50)}",
headers=headers,
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_get_highscore_for_nonexisting_minigame_should_fail():
"""Test whether fetching a new high score for a nonexisting minigame fails"""
clear_db()
token = await register_user()
fake_minigame = "FakeGame"
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
response = client.get(
f"/highscores/{fake_minigame}",
headers=headers,
)
assert response.status_code == 422
response = client.get(
f"/highscores/{fake_minigame}?mine_only=false&nr_highest={random.randint(1, 50)}",
headers=headers,
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_get_invalid_number_of_highscores_should_fail():
"""Test whether getting a numbe rof high scores lower than 1 fails"""
clear_db()
token = await register_user()
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
for minigame in MinigameEnum:
response = client.get(
f"/highscores/{minigame}?nr_highest={random.randint(-100, 0)}",
headers=headers,
)
assert response.status_code == 400
@pytest.mark.asyncio
async def test_get_highscores_should_work_with_default_value():
"""Test whether fetching high scores without passing an explicit amount still succeeds"""
clear_db()
token = await register_user()
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
for minigame in MinigameEnum:
response = client.get(
f"/highscores/{minigame}",
headers=headers,
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_get_highscores_returns_sorted_list_with_correct_length():
"""Test whether getting a list of high scores gets a list in descending order and of the correct length"""
clear_db()
token = await register_user()
headers = {"Content-Type": "application/json"}
for minigame in MinigameEnum:
clear_db()
nr_entries = random.randint(5, 50)
token = ""
users_score_tuples = [
(f"user{i + 1}", random.random()) for i in range(nr_entries)
]
for user, score in users_score_tuples:
response = client.post(
"/register",
headers=headers,
json={"username": user, "password": password, "avatar": avatar},
)
assert response.status_code == 200
token = response.json()["access_token"]
response = client.put(
f"/highscores/{minigame}",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
json={"score_value": score},
)
assert response.status_code == 200
response = client.get(
f"/highscores/{minigame}?mine_only=false&nr_highest={int(nr_entries)}",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
)
assert response.status_code == 200
response = response.json()
assert len(response) == nr_entries
for i in range(1, len(response)):
assert response[i]["score_value"] <= response[i - 1]["score_value"]
@pytest.mark.asyncio
async def test_get_own_existing_high_score_should_return_high_score():
"""Test whether fetching your own high score of a game succeeds"""
clear_db()
token = await register_user()
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
for minigame in MinigameEnum:
response = client.put(
f"/highscores/{minigame}",
headers=headers,
json={"score_value": random.random()},
)
assert response.status_code == 200
response = client.get(
f"/highscores/{minigame}",
headers=headers,
)
assert response.status_code == 200
assert len(response.json()) == 1
@pytest.mark.asyncio
async def test_get_own_nonexisting_high_score_should_return_empty_list():
"""Test whether fetching the high score of a game you haven't played returns an empty list"""
clear_db()
token = await register_user()
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
for minigame in MinigameEnum:
response = client.get(
f"/highscores/{minigame}",
headers=headers,
)
assert response.status_code == 200
assert len(response.json()) == 0
@pytest.mark.asyncio
async def test_get_multiple_own_high_scores_of_same_game_should_fail():
"""Test whether asking more than one of your high scores on a single game fails"""
clear_db()
token = await register_user()
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
for minigame in MinigameEnum:
response = client.get(
f"/highscores/{minigame}?nr_highest={random.randint(2, 20)}",
headers=headers,
)
assert response.status_code == 400

138
tests/test_users.py Normal file
View File

@@ -0,0 +1,138 @@
import pytest
from fastapi.testclient import TestClient
from src.main import app, get_db
from tests.base import avatar, client, password, username
from tests.config.database import clear_db, override_get_db
patched_username = "New name"
patched_password = "New password"
patched_avatar = "New avatar"
@pytest.mark.asyncio
async def test_get_current_user():
"""Test the GET /users endpoint to get info about the current user"""
clear_db()
response = client.post(
"/register",
headers={"Content-Type": "application/json"},
json={"username": username, "password": password, "avatar": avatar},
)
assert response.status_code == 200
token = response.json()["access_token"]
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
response = client.get("/users", headers=headers)
assert response.status_code == 200
response = response.json()
assert response["username"] == username
assert response["avatar"] == avatar
@pytest.mark.asyncio
async def test_get_current_user_without_auth():
"""Getting the current user without a token should fail"""
clear_db()
response = client.get("/users", headers={"Content-Type": "application/json"})
assert response.status_code == 403
@pytest.mark.asyncio
async def test_patch_user():
"""Test the patching of a user's username, password and avatar"""
clear_db()
response = client.post(
"/register",
headers={"Content-Type": "application/json"},
json={"username": username, "password": password, "avatar": avatar},
)
assert response.status_code == 200
token = response.json()["access_token"]
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
response = client.patch(
"/users",
json={
"username": patched_username,
"password": patched_password,
"avatar": patched_avatar,
},
headers=headers,
)
assert response.status_code == 200
response = client.post(
"/login",
headers={"Content-Type": "application/json"},
json={"username": patched_username, "password": patched_password},
)
assert response.status_code == 200
token = response.json()["access_token"]
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
response = client.get("/users", headers=headers)
assert response.status_code == 200
# Correctness of password and username is already asserted by the login
assert response.json()["avatar"] == patched_avatar
@pytest.mark.asyncio
async def test_patch_user_with_empty_fields():
"""Patching a user with empty fields should fail"""
clear_db()
response = client.post(
"/register",
headers={"Content-Type": "application/json"},
json={"username": username, "password": password, "avatar": avatar},
)
assert response.status_code == 200
token = response.json()["access_token"]
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
response = client.patch(
"/users",
json={
"username": patched_username,
"password": patched_password,
"avatar": "",
},
headers=headers,
)
assert response.status_code == 400
response = client.patch(
"/users",
json={
"username": patched_username,
"password": "",
"avatar": patched_avatar,
},
headers=headers,
)
assert response.status_code == 400
response = client.patch(
"/users",
json={
"username": "",
"password": patched_password,
"avatar": patched_avatar,
},
headers=headers,
)
assert response.status_code == 400