diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 3de50076..cdf340c7 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -39,7 +39,7 @@ jobs: env: ENVIRONMENT: CI - - name: Upload coverage reports to Codecov with GitHub Action + - name: Upload coverage report to Codecov uses: codecov/codecov-action@v3 - name: Run mypy diff --git a/backend/bracket/models/db/player.py b/backend/bracket/models/db/player.py index bc231f53..4c2e2d04 100644 --- a/backend/bracket/models/db/player.py +++ b/backend/bracket/models/db/player.py @@ -1,3 +1,5 @@ +from decimal import Decimal + from heliclockter import datetime_utc from bracket.models.db.shared import BaseModelORM @@ -9,14 +11,14 @@ class Player(BaseModelORM): created: datetime_utc team_id: int | None = None tournament_id: int - elo_score: float + elo_score: Decimal class PlayerBody(BaseModelORM): name: str - team_id: int | None class PlayerToInsert(PlayerBody): created: datetime_utc tournament_id: int + elo_score: Decimal diff --git a/backend/bracket/routes/players.py b/backend/bracket/routes/players.py index 85c25cbb..0b0c3a4f 100644 --- a/backend/bracket/routes/players.py +++ b/backend/bracket/routes/players.py @@ -70,7 +70,10 @@ async def create_player( last_record_id = await database.execute( query=players.insert(), values=PlayerToInsert( - **player_body.dict(), created=datetime_utc.now(), tournament_id=tournament_id + **player_body.dict(), + created=datetime_utc.now(), + tournament_id=tournament_id, + elo_score=0, ).dict(), ) return SinglePlayerResponse( diff --git a/backend/bracket/utils/db.py b/backend/bracket/utils/db.py index 2370dfaa..178b65a6 100644 --- a/backend/bracket/utils/db.py +++ b/backend/bracket/utils/db.py @@ -3,7 +3,7 @@ from typing import Type from databases import Database from sqlalchemy.sql import Select -from bracket.utils.types import BaseModelT +from bracket.utils.types import BaseModelT, assert_some async def fetch_one_parsed( @@ -13,6 +13,12 @@ async def fetch_one_parsed( return model.parse_obj(record._mapping) if record is not None else None +async def fetch_one_parsed_certain( + database: Database, model: Type[BaseModelT], query: Select +) -> BaseModelT: + return assert_some(await fetch_one_parsed(database, model, query)) + + async def fetch_all_parsed( database: Database, model: Type[BaseModelT], query: Select ) -> list[BaseModelT]: diff --git a/backend/tests/integration_tests/api/auth_test.py b/backend/tests/integration_tests/api/auth_test.py index a5a9a681..e81f31e5 100644 --- a/backend/tests/integration_tests/api/auth_test.py +++ b/backend/tests/integration_tests/api/auth_test.py @@ -50,7 +50,7 @@ async def test_auth_on_protected_endpoint(startup_and_shutdown_uvicorn_server: N headers = {'Authorization': f'Bearer {get_mock_token()}'} async with inserted_user(MOCK_USER) as user_inserted: - response = JsonDict(await send_request(HTTPMethod.GET, 'users/me', {}, headers)) + response = JsonDict(await send_request(HTTPMethod.GET, 'users/me', {}, None, headers)) assert response == { 'id': user_inserted.id, @@ -63,5 +63,5 @@ async def test_auth_on_protected_endpoint(startup_and_shutdown_uvicorn_server: N async def test_invalid_token(startup_and_shutdown_uvicorn_server: None) -> None: headers = {'Authorization': 'Bearer some.invalid.token'} - response = JsonDict(await send_request(HTTPMethod.GET, 'users/me', {}, headers)) + response = JsonDict(await send_request(HTTPMethod.GET, 'users/me', {}, None, headers)) assert response == {'detail': 'Could not validate credentials'} diff --git a/backend/tests/integration_tests/api/players_test.py b/backend/tests/integration_tests/api/players_test.py index 0651a4a2..127c80fc 100644 --- a/backend/tests/integration_tests/api/players_test.py +++ b/backend/tests/integration_tests/api/players_test.py @@ -1,8 +1,12 @@ +from bracket.database import database +from bracket.models.db.player import Player +from bracket.schema import players +from bracket.utils.db import fetch_one_parsed_certain from bracket.utils.dummy_records import DUMMY_MOCK_TIME, DUMMY_PLAYER1, DUMMY_TEAM1 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_team +from tests.integration_tests.sql import assert_row_count_and_clear, inserted_player, inserted_team async def test_players_endpoint( @@ -22,3 +26,48 @@ async def test_players_endpoint( } ], } + + +async def test_create_player( + startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext +) -> None: + body = {'name': 'Some new name'} + response = await send_tournament_request(HTTPMethod.POST, 'players', auth_context, json=body) + assert response['data']['name'] == body['name'] # type: ignore[call-overload] + await assert_row_count_and_clear(players, 1) + + +async def test_delete_player( + startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext +) -> None: + async with inserted_team(DUMMY_TEAM1) as team_inserted: + async with inserted_player( + DUMMY_PLAYER1.copy(update={'team_id': team_inserted.id}) + ) as player_inserted: + assert ( + await send_tournament_request( + HTTPMethod.DELETE, f'players/{player_inserted.id}', auth_context + ) + == SUCCESS_RESPONSE + ) + await assert_row_count_and_clear(players, 0) + + +async def test_update_player( + startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext +) -> None: + body = {'name': 'Some new name'} + async with inserted_team(DUMMY_TEAM1) as team_inserted: + async with inserted_player( + DUMMY_PLAYER1.copy(update={'team_id': team_inserted.id}) + ) as player_inserted: + response = await send_tournament_request( + HTTPMethod.PATCH, f'players/{player_inserted.id}', auth_context, json=body + ) + patched_player = await fetch_one_parsed_certain( + database, Player, query=players.select().where(players.c.id == player_inserted.id) + ) + assert patched_player.name == body['name'] + assert response['data']['name'] == body['name'] # type: ignore[call-overload] + + await assert_row_count_and_clear(players, 1) diff --git a/backend/tests/integration_tests/api/rounds_test.py b/backend/tests/integration_tests/api/rounds_test.py index 3917e5aa..b0bdb7d3 100644 --- a/backend/tests/integration_tests/api/rounds_test.py +++ b/backend/tests/integration_tests/api/rounds_test.py @@ -1,8 +1,12 @@ +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 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_round, inserted_team +from tests.integration_tests.sql import assert_row_count_and_clear, inserted_round, inserted_team async def test_rounds_endpoint( @@ -23,3 +27,50 @@ async def test_rounds_endpoint( } ], } + + +async def test_create_round( + startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext +) -> None: + async with inserted_team(DUMMY_TEAM1): + assert ( + await send_tournament_request(HTTPMethod.POST, 'rounds', auth_context, {}) + == SUCCESS_RESPONSE + ) + await assert_row_count_and_clear(rounds, 1) + + +async def test_delete_round( + startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext +) -> None: + async with inserted_team(DUMMY_TEAM1): + async with inserted_round(DUMMY_ROUND1) as round_inserted: + assert ( + await send_tournament_request( + HTTPMethod.DELETE, f'rounds/{round_inserted.id}', auth_context, {} + ) + == SUCCESS_RESPONSE + ) + await assert_row_count_and_clear(rounds, 0) + + +async def test_update_round( + startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext +) -> None: + body = {'name': 'Some new name', 'is_draft': True, 'is_active': False} + async with inserted_team(DUMMY_TEAM1): + async with inserted_round(DUMMY_ROUND1) as round_inserted: + assert ( + await send_tournament_request( + HTTPMethod.PATCH, f'rounds/{round_inserted.id}', auth_context, None, body + ) + == SUCCESS_RESPONSE + ) + patched_round = await fetch_one_parsed_certain( + database, Round, query=rounds.select().where(rounds.c.id == round_inserted.id) + ) + assert patched_round.name == body['name'] + assert patched_round.is_draft == body['is_draft'] + assert patched_round.is_active == body['is_active'] + + await assert_row_count_and_clear(rounds, 1) diff --git a/backend/tests/integration_tests/api/shared.py b/backend/tests/integration_tests/api/shared.py index f114c5a7..f9b92fe7 100644 --- a/backend/tests/integration_tests/api/shared.py +++ b/backend/tests/integration_tests/api/shared.py @@ -8,10 +8,13 @@ import uvicorn from fastapi import FastAPI from bracket.app import app +from bracket.routes.models import SuccessResponse from bracket.utils.http import HTTPMethod from bracket.utils.types import JsonDict, JsonObject from tests.integration_tests.models import AuthContext +SUCCESS_RESPONSE = SuccessResponse().dict() + def find_free_port() -> int: """ @@ -64,13 +67,18 @@ class UvicornTestServer(uvicorn.Server): async def send_request( - method: HTTPMethod, endpoint: str, body: JsonDict = {}, headers: JsonDict = {} + method: HTTPMethod, + endpoint: str, + body: JsonDict | None = None, + json: JsonDict | None = None, + headers: JsonDict = {}, ) -> JsonObject: async with aiohttp.ClientSession() as session: async with session.request( method=method.value, url=get_root_uvicorn_url() + endpoint, data=body, + json=json, headers=headers, ) as resp: response: JsonObject = await resp.json() @@ -78,14 +86,28 @@ async def send_request( async def send_auth_request( - method: HTTPMethod, endpoint: str, auth_context: AuthContext, body: JsonDict = {} + method: HTTPMethod, + endpoint: str, + auth_context: AuthContext, + body: JsonDict | None = None, + json: JsonDict | None = None, ) -> JsonObject: - return await send_request(method, endpoint, body, auth_context.headers) + return await send_request( + method=method, endpoint=endpoint, body=body, json=json, headers=auth_context.headers + ) async def send_tournament_request( - method: HTTPMethod, endpoint: str, auth_context: AuthContext, body: JsonDict = {} + method: HTTPMethod, + endpoint: str, + auth_context: AuthContext, + body: JsonDict | None = None, + json: JsonDict | None = None, ) -> JsonObject: return await send_request( - method, f'tournaments/{auth_context.tournament.id}/{endpoint}', body, auth_context.headers + method=method, + endpoint=f'tournaments/{auth_context.tournament.id}/{endpoint}', + body=body, + json=json, + headers=auth_context.headers, ) diff --git a/backend/tests/integration_tests/api/teams_test.py b/backend/tests/integration_tests/api/teams_test.py index 53f0bf2b..a40bfb50 100644 --- a/backend/tests/integration_tests/api/teams_test.py +++ b/backend/tests/integration_tests/api/teams_test.py @@ -1,8 +1,12 @@ +from bracket.database import database +from bracket.models.db.team import Team +from bracket.schema import teams +from bracket.utils.db import fetch_one_parsed_certain from bracket.utils.dummy_records import DUMMY_MOCK_TIME, DUMMY_TEAM1 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_team +from tests.integration_tests.sql import assert_row_count_and_clear, inserted_team async def test_teams_endpoint( @@ -21,3 +25,42 @@ async def test_teams_endpoint( } ], } + + +async def test_create_team( + startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext +) -> None: + body = {'name': 'Some new name', 'active': True, 'player_ids': []} + response = await send_tournament_request(HTTPMethod.POST, 'teams', auth_context, None, body) + assert response['data']['name'] == body['name'] # type: ignore[call-overload] + await assert_row_count_and_clear(teams, 1) + + +async def test_delete_team( + startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext +) -> None: + async with inserted_team(DUMMY_TEAM1) as team_inserted: + assert ( + await send_tournament_request( + HTTPMethod.DELETE, f'teams/{team_inserted.id}', auth_context, {} + ) + == SUCCESS_RESPONSE + ) + await assert_row_count_and_clear(teams, 0) + + +async def test_update_team( + startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext +) -> None: + body = {'name': 'Some new name', 'active': True, 'player_ids': []} + async with inserted_team(DUMMY_TEAM1) as team_inserted: + response = await send_tournament_request( + HTTPMethod.PATCH, f'teams/{team_inserted.id}', auth_context, None, body + ) + patched_round = await fetch_one_parsed_certain( + database, Team, query=teams.select().where(teams.c.id == team_inserted.id) + ) + assert patched_round.name == body['name'] + assert response['data']['name'] == body['name'] # type: ignore[call-overload] + + await assert_row_count_and_clear(teams, 1) diff --git a/backend/tests/integration_tests/api/tournaments_test.py b/backend/tests/integration_tests/api/tournaments_test.py new file mode 100644 index 00000000..58e3f376 --- /dev/null +++ b/backend/tests/integration_tests/api/tournaments_test.py @@ -0,0 +1,19 @@ +from bracket.utils.dummy_records import DUMMY_MOCK_TIME +from bracket.utils.http import HTTPMethod +from tests.integration_tests.api.shared import send_auth_request +from tests.integration_tests.models import AuthContext + + +async def test_tournaments_endpoint( + startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext +) -> None: + assert await send_auth_request(HTTPMethod.GET, 'tournaments', auth_context, {}) == { + 'data': [ + { + 'id': auth_context.tournament.id, + 'club_id': auth_context.club.id, + 'created': DUMMY_MOCK_TIME.isoformat(), + 'name': 'Some Cool Tournament', + } + ], + } diff --git a/backend/tests/integration_tests/sql.py b/backend/tests/integration_tests/sql.py index 60507b53..ff18d31b 100644 --- a/backend/tests/integration_tests/sql.py +++ b/backend/tests/integration_tests/sql.py @@ -19,6 +19,11 @@ from tests.integration_tests.mocks import MOCK_USER, get_mock_token from tests.integration_tests.models import AuthContext +async def assert_row_count_and_clear(table: Table, expected_rows: int) -> None: + assert len(await database.fetch_all(query=table.select())) == expected_rows + await database.execute(query=table.delete()) + + @asynccontextmanager async def inserted_generic( data_model: BaseModelT, table: Table, return_type: Type[BaseModelT]