Add test coverage (#61)

This commit is contained in:
Erik Vroon
2023-01-07 08:27:21 -08:00
committed by GitHub
parent 1bfdf804fb
commit 56df31c343
12 changed files with 250 additions and 31 deletions

View File

@@ -23,6 +23,7 @@ types-passlib = "*"
pyjwt = "2.6.0"
click = "8.1.3"
python-multipart = "*"
parameterized = "*"
[dev-packages]
mypy = "0.991"

View File

View File

@@ -6,6 +6,7 @@ from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from heliclockter import datetime_utc, timedelta
from jwt import DecodeError, ExpiredSignatureError
from pydantic import BaseModel
from starlette.requests import Request
from bracket.config import config
from bracket.database import database
@@ -38,10 +39,6 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
async def get_user(email: str) -> UserInDB | None:
return await fetch_one_parsed(database, UserInDB, users.select().where(users.c.email == email))
@@ -55,13 +52,9 @@ async def authenticate_user(email: str, password: str) -> UserInDB | None:
return user
def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str:
def create_access_token(data: dict[str, Any], expires_delta: timedelta) -> str:
to_encode = data.copy()
if expires_delta:
expire = datetime_utc.now() + expires_delta
else:
expire = datetime_utc.now() + timedelta(minutes=15)
expire = datetime_utc.now() + expires_delta
to_encode.update({"exp": expire})
return jwt.encode(to_encode, config.jwt_secret, algorithm=ALGORITHM)
@@ -111,13 +104,17 @@ async def user_authenticated_for_tournament(
async def user_authenticated_or_public_dashboard(
tournament_id: int, token: str = Depends(oauth2_scheme)
tournament_id: int, request: Request
) -> UserPublic | None:
user = await check_jwt_and_get_user(token)
if user is not None and await get_user_access_to_tournament(
tournament_id, assert_some(user.id)
):
return user
try:
token: str = assert_some(await oauth2_scheme(request))
user = await check_jwt_and_get_user(token)
if user is not None and await get_user_access_to_tournament(
tournament_id, assert_some(user.id)
):
return user
except HTTPException:
pass
tournaments_fetched = await fetch_all_parsed(
database, Tournament, tournaments.select().where(tournaments.c.id == tournament_id)

View File

@@ -1,5 +1,6 @@
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from heliclockter import datetime_utc
from starlette import status
from bracket.database import database
from bracket.models.db.tournament import (
@@ -13,6 +14,8 @@ from bracket.routes.auth import user_authenticated, user_authenticated_for_tourn
from bracket.routes.models import SuccessResponse, TournamentsResponse
from bracket.schema import tournaments
from bracket.utils.db import fetch_all_parsed
from bracket.utils.sql import get_user_access_to_club
from bracket.utils.types import assert_some
router = APIRouter()
@@ -51,8 +54,18 @@ async def delete_tournament(
@router.post("/tournaments", response_model=SuccessResponse)
async def create_tournament(
tournament_to_insert: TournamentBody, _: UserPublic = Depends(user_authenticated)
tournament_to_insert: TournamentBody, user: UserPublic = Depends(user_authenticated)
) -> SuccessResponse:
has_access_to_club = await get_user_access_to_club(
tournament_to_insert.club_id, assert_some(user.id)
)
if not has_access_to_club:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Club ID is invalid",
headers={"WWW-Authenticate": "Bearer"},
)
await database.execute(
query=tournaments.insert(),
values=TournamentToInsert(

View File

@@ -11,7 +11,7 @@ async def get_rounds_with_matches(tournament_id: int) -> RoundsWithMatchesRespon
WITH teams_with_players AS (
SELECT DISTINCT ON (teams.id)
teams.*,
to_json(array_agg(p)) as players
to_json(array_remove(array_agg(p), NULL)) as players
FROM teams
LEFT JOIN players p on p.team_id = teams.id
WHERE teams.tournament_id = :tournament_id
@@ -33,6 +33,7 @@ async def get_rounds_with_matches(tournament_id: int) -> RoundsWithMatchesRespon
GROUP BY rounds.id
'''
result = await database.fetch_all(query=query, values={'tournament_id': tournament_id})
print([x._mapping for x in result])
return RoundsWithMatchesResponse.parse_obj(
{'data': [RoundWithMatches.parse_obj(x._mapping) for x in result]}
)
@@ -75,4 +76,14 @@ async def get_user_access_to_tournament(tournament_id: int, user_id: int) -> boo
WHERE user_id = :user_id
'''
result = await database.fetch_all(query=query, values={'user_id': user_id})
return tournament_id in [tournament.id for tournament in result] # type: ignore[attr-defined]
return tournament_id in {tournament.id for tournament in result} # type: ignore[attr-defined]
async def get_user_access_to_club(club_id: int, user_id: int) -> bool:
query = f'''
SELECT club_id
FROM users_x_clubs
WHERE user_id = :user_id
'''
result = await database.fetch_all(query=query, values={'user_id': user_id})
return club_id in {club.club_id for club in result} # type: ignore[attr-defined]

View File

@@ -5,11 +5,13 @@ from unittest.mock import Mock, patch
import jwt
from bracket.config import config
from bracket.utils.dummy_records import DUMMY_CLUB, DUMMY_TOURNAMENT
from bracket.utils.http import HTTPMethod
from bracket.utils.types import JsonDict
from tests.integration_tests.api.shared import send_request
from tests.integration_tests.api.shared import send_auth_request, send_request
from tests.integration_tests.mocks import MOCK_NOW, MOCK_USER, get_mock_token
from tests.integration_tests.sql import inserted_user
from tests.integration_tests.models import AuthContext
from tests.integration_tests.sql import inserted_club, inserted_tournament, inserted_user
@contextmanager
@@ -65,3 +67,18 @@ async def test_invalid_token(startup_and_shutdown_uvicorn_server: None) -> None:
response = JsonDict(await send_request(HTTPMethod.GET, 'users/me', {}, None, headers))
assert response == {'detail': 'Could not validate credentials'}
async def test_not_authenticated_for_tournament(
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
) -> None:
async with inserted_club(DUMMY_CLUB) as club_inserted:
async with inserted_tournament(
DUMMY_TOURNAMENT.copy(update={'club_id': club_inserted.id})
) as tournament_inserted:
response = JsonDict(
await send_auth_request(
HTTPMethod.GET, f'tournaments/{tournament_inserted.id}/teams', auth_context
)
)
assert response == {'detail': 'Could not validate credentials'}

View File

@@ -1,4 +1,9 @@
from bracket.database import database
from bracket.models.db.match import Match
from bracket.schema import matches
from bracket.utils.db import fetch_one_parsed_certain
from bracket.utils.dummy_records import (
DUMMY_MATCH1,
DUMMY_PLAYER1,
DUMMY_PLAYER2,
DUMMY_ROUND1,
@@ -6,9 +11,96 @@ from bracket.utils.dummy_records import (
DUMMY_TEAM2,
)
from bracket.utils.http import HTTPMethod
from tests.integration_tests.api.shared import send_tournament_request
from tests.integration_tests.api.shared import SUCCESS_RESPONSE, send_tournament_request
from tests.integration_tests.models import AuthContext
from tests.integration_tests.sql import inserted_player, inserted_round, inserted_team
from tests.integration_tests.sql import (
assert_row_count_and_clear,
inserted_match,
inserted_player,
inserted_round,
inserted_team,
)
async def test_create_match(
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
) -> None:
async with inserted_round(DUMMY_ROUND1) as round_inserted:
async with inserted_team(DUMMY_TEAM1) as team1_inserted:
async with inserted_team(DUMMY_TEAM2) as team2_inserted:
body = {
'team1_id': team1_inserted.id,
'team2_id': team2_inserted.id,
'round_id': round_inserted.id,
}
assert (
await send_tournament_request(
HTTPMethod.POST, 'matches', auth_context, json=body
)
== SUCCESS_RESPONSE
)
await assert_row_count_and_clear(matches, 1)
async def test_delete_match(
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
) -> None:
async with inserted_round(DUMMY_ROUND1) as round_inserted:
async with inserted_team(DUMMY_TEAM1) as team1_inserted:
async with inserted_team(DUMMY_TEAM2) as team2_inserted:
async with inserted_match(
DUMMY_MATCH1.copy(
update={
'round_id': round_inserted.id,
'team1_id': team1_inserted.id,
'team2_id': team2_inserted.id,
}
)
) as match_inserted:
assert (
await send_tournament_request(
HTTPMethod.DELETE, f'matches/{match_inserted.id}', auth_context, {}
)
== SUCCESS_RESPONSE
)
await assert_row_count_and_clear(matches, 0)
async def test_update_match(
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
) -> None:
async with inserted_team(DUMMY_TEAM1) as team1_inserted:
async with inserted_team(DUMMY_TEAM2) as team2_inserted:
async with inserted_round(DUMMY_ROUND1) as round_inserted:
async with inserted_match(
DUMMY_MATCH1.copy(
update={
'round_id': round_inserted.id,
'team1_id': team1_inserted.id,
'team2_id': team2_inserted.id,
}
)
) as match_inserted:
body = {'team1_score': 42, 'team2_score': 24, 'round_id': round_inserted.id}
assert (
await send_tournament_request(
HTTPMethod.PATCH,
f'matches/{match_inserted.id}',
auth_context,
None,
body,
)
== SUCCESS_RESPONSE
)
patched_match = await fetch_one_parsed_certain(
database,
Match,
query=matches.select().where(matches.c.id == round_inserted.id),
)
assert patched_match.team1_score == body['team1_score']
assert patched_match.team2_score == body['team2_score']
await assert_row_count_and_clear(matches, 1)
async def test_upcoming_matches_endpoint(

View File

@@ -1,20 +1,34 @@
import pytest
from bracket.database import database
from bracket.models.db.round import Round
from bracket.schema import rounds
from bracket.utils.db import fetch_one_parsed_certain
from bracket.utils.dummy_records import DUMMY_MOCK_TIME, DUMMY_ROUND1, DUMMY_TEAM1
from bracket.utils.http import HTTPMethod
from tests.integration_tests.api.shared import SUCCESS_RESPONSE, send_tournament_request
from tests.integration_tests.api.shared import (
SUCCESS_RESPONSE,
send_request,
send_tournament_request,
)
from tests.integration_tests.models import AuthContext
from tests.integration_tests.sql import assert_row_count_and_clear, inserted_round, inserted_team
@pytest.mark.parametrize(("with_auth",), [(True,), (False,)])
async def test_rounds_endpoint(
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext, with_auth: bool
) -> None:
async with inserted_team(DUMMY_TEAM1):
async with inserted_round(DUMMY_ROUND1) as round_inserted:
assert await send_tournament_request(HTTPMethod.GET, 'rounds', auth_context, {}) == {
if with_auth:
response = await send_tournament_request(HTTPMethod.GET, 'rounds', auth_context, {})
else:
response = await send_request(
HTTPMethod.GET, f'tournaments/{auth_context.tournament.id}/rounds'
)
assert response == {
'data': [
{
'created': DUMMY_MOCK_TIME.isoformat(),

View File

@@ -2,7 +2,7 @@ from bracket.database import database
from bracket.models.db.tournament import Tournament
from bracket.schema import tournaments
from bracket.utils.db import fetch_one_parsed_certain
from bracket.utils.dummy_records import DUMMY_MOCK_TIME
from bracket.utils.dummy_records import DUMMY_MOCK_TIME, DUMMY_TOURNAMENT
from bracket.utils.http import HTTPMethod
from tests.integration_tests.api.shared import (
SUCCESS_RESPONSE,
@@ -10,7 +10,7 @@ from tests.integration_tests.api.shared import (
send_tournament_request,
)
from tests.integration_tests.models import AuthContext
from tests.integration_tests.sql import assert_row_count_and_clear
from tests.integration_tests.sql import assert_row_count_and_clear, inserted_tournament
async def test_tournaments_endpoint(
@@ -29,12 +29,23 @@ async def test_tournaments_endpoint(
}
async def test_create_tournament(
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
) -> None:
body = {'name': 'Some new name', 'club_id': auth_context.club.id, 'dashboard_public': False}
assert (
await send_auth_request(HTTPMethod.POST, 'tournaments', auth_context, json=body)
== SUCCESS_RESPONSE
)
await database.execute(tournaments.delete().where(tournaments.c.name == body['name']))
async def test_update_tournament(
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
) -> None:
body = {'name': 'Some new name', 'dashboard_public': False}
assert (
await send_tournament_request(HTTPMethod.PATCH, '', auth_context, None, body)
await send_tournament_request(HTTPMethod.PATCH, '', auth_context, json=body)
== SUCCESS_RESPONSE
)
patched_tournament = await fetch_one_parsed_certain(
@@ -46,3 +57,17 @@ async def test_update_tournament(
assert patched_tournament.dashboard_public == body['dashboard_public']
await assert_row_count_and_clear(tournaments, 1)
async def test_delete_tournament(
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
) -> None:
async with inserted_tournament(DUMMY_TOURNAMENT) as tournament_inserted:
assert (
await send_tournament_request(
HTTPMethod.DELETE, '', auth_context.copy(update={'tournament': tournament_inserted})
)
== SUCCESS_RESPONSE
)
await assert_row_count_and_clear(tournaments, 0)

View File

@@ -3,10 +3,12 @@ from pydantic import BaseModel
from bracket.models.db.club import Club
from bracket.models.db.tournament import Tournament
from bracket.models.db.user import User
from bracket.models.db.user_x_club import UserXClub
class AuthContext(BaseModel):
club: Club
tournament: Tournament
user: User
user_x_club: UserXClub
headers: dict[str, str]

View File

@@ -96,10 +96,11 @@ async def inserted_auth_context() -> AsyncIterator[AuthContext]:
async with inserted_tournament(DUMMY_TOURNAMENT) as tournament_inserted:
async with inserted_user_x_club(
UserXClub(user_id=user_inserted.id, club_id=club_inserted.id)
):
) as user_x_club_inserted:
yield AuthContext(
headers=headers,
user=user_inserted,
club=club_inserted,
tournament=tournament_inserted,
user_x_club=user_x_club_inserted,
)

View File

@@ -0,0 +1,46 @@
from decimal import Decimal
from bracket.logic.elo import PlayerStatistics, calculate_elo_per_player
from bracket.models.db.match import MatchWithTeamDetails
from bracket.models.db.round import RoundWithMatches
from bracket.models.db.team import TeamWithPlayers
from bracket.utils.dummy_records import DUMMY_MOCK_TIME, DUMMY_PLAYER1, DUMMY_PLAYER2
def test_elo_calculation() -> None:
round_ = RoundWithMatches(
tournament_id=1,
created=DUMMY_MOCK_TIME,
is_draft=True,
is_active=False,
name='Some round',
matches=[
MatchWithTeamDetails(
created=DUMMY_MOCK_TIME,
team1_id=1,
team2_id=1,
team1_score=3,
team2_score=4,
round_id=1,
team1=TeamWithPlayers(
name='Dummy team 1',
tournament_id=1,
active=True,
created=DUMMY_MOCK_TIME,
players=[DUMMY_PLAYER1.copy(update={'id': 1})],
),
team2=TeamWithPlayers(
name='Dummy team 2',
tournament_id=1,
active=True,
created=DUMMY_MOCK_TIME,
players=[DUMMY_PLAYER2.copy(update={'id': 2})],
),
)
],
)
calculation = calculate_elo_per_player([round_])
assert calculation == {
1: PlayerStatistics(losses=1, elo_score=3, swiss_score=Decimal('0.00')),
2: PlayerStatistics(wins=1, elo_score=4, swiss_score=Decimal('1.00')),
}