diff --git a/backend/Pipfile b/backend/Pipfile index 89d2deba..450fb9b8 100644 --- a/backend/Pipfile +++ b/backend/Pipfile @@ -23,6 +23,7 @@ types-passlib = "*" pyjwt = "2.6.0" click = "8.1.3" python-multipart = "*" +parameterized = "*" [dev-packages] mypy = "0.991" diff --git a/backend/bracket/models/auth.py b/backend/bracket/models/auth.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/bracket/routes/auth.py b/backend/bracket/routes/auth.py index 19567009..41373c6d 100644 --- a/backend/bracket/routes/auth.py +++ b/backend/bracket/routes/auth.py @@ -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) diff --git a/backend/bracket/routes/tournaments.py b/backend/bracket/routes/tournaments.py index 53da6924..59a5e7cf 100644 --- a/backend/bracket/routes/tournaments.py +++ b/backend/bracket/routes/tournaments.py @@ -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( diff --git a/backend/bracket/utils/sql.py b/backend/bracket/utils/sql.py index 3cfc13c1..ccdcb2b2 100644 --- a/backend/bracket/utils/sql.py +++ b/backend/bracket/utils/sql.py @@ -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] diff --git a/backend/tests/integration_tests/api/auth_test.py b/backend/tests/integration_tests/api/auth_test.py index e81f31e5..562e2876 100644 --- a/backend/tests/integration_tests/api/auth_test.py +++ b/backend/tests/integration_tests/api/auth_test.py @@ -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'} diff --git a/backend/tests/integration_tests/api/matches_test.py b/backend/tests/integration_tests/api/matches_test.py index e9463f73..867a63bb 100644 --- a/backend/tests/integration_tests/api/matches_test.py +++ b/backend/tests/integration_tests/api/matches_test.py @@ -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( diff --git a/backend/tests/integration_tests/api/rounds_test.py b/backend/tests/integration_tests/api/rounds_test.py index b0bdb7d3..46248a0b 100644 --- a/backend/tests/integration_tests/api/rounds_test.py +++ b/backend/tests/integration_tests/api/rounds_test.py @@ -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(), diff --git a/backend/tests/integration_tests/api/tournaments_test.py b/backend/tests/integration_tests/api/tournaments_test.py index 5535596a..3ee0f969 100644 --- a/backend/tests/integration_tests/api/tournaments_test.py +++ b/backend/tests/integration_tests/api/tournaments_test.py @@ -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) diff --git a/backend/tests/integration_tests/models.py b/backend/tests/integration_tests/models.py index f2e38837..b74caa63 100644 --- a/backend/tests/integration_tests/models.py +++ b/backend/tests/integration_tests/models.py @@ -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] diff --git a/backend/tests/integration_tests/sql.py b/backend/tests/integration_tests/sql.py index df5231fb..37259b0e 100644 --- a/backend/tests/integration_tests/sql.py +++ b/backend/tests/integration_tests/sql.py @@ -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, ) diff --git a/backend/tests/unit_tests/elo_test.py b/backend/tests/unit_tests/elo_test.py new file mode 100644 index 00000000..609b9ccb --- /dev/null +++ b/backend/tests/unit_tests/elo_test.py @@ -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')), + }