diff --git a/backend/bracket/app.py b/backend/bracket/app.py index e0e25c52..7f35e24a 100644 --- a/backend/bracket/app.py +++ b/backend/bracket/app.py @@ -1,9 +1,9 @@ from fastapi import FastAPI from starlette.middleware.cors import CORSMiddleware -from bracket.config import config +from bracket.config import Environment, config, environment from bracket.database import database -from bracket.routes import auth, matches, players, rounds, teams, tournaments +from bracket.routes import auth, clubs, matches, players, rounds, teams, tournaments app = FastAPI( title="Bracket API", @@ -30,7 +30,10 @@ async def startup() -> None: @app.on_event("shutdown") async def shutdown() -> None: - await database.disconnect() + # On CI, we need first to clean up db data in conftest.py before we can disconnect the + # db connections. + if environment != Environment.CI: + await database.disconnect() @app.get('/ping', summary="Healthcheck ping") @@ -39,6 +42,7 @@ async def ping() -> str: app.include_router(auth.router, tags=['auth']) +app.include_router(clubs.router, tags=['clubs']) app.include_router(tournaments.router, tags=['tournaments']) app.include_router(players.router, tags=['players']) app.include_router(rounds.router, tags=['rounds']) diff --git a/backend/bracket/routes/teams.py b/backend/bracket/routes/teams.py index af77cff2..9bc434b6 100644 --- a/backend/bracket/routes/teams.py +++ b/backend/bracket/routes/teams.py @@ -2,18 +2,12 @@ from fastapi import APIRouter, Depends from heliclockter import datetime_utc from bracket.database import database -from bracket.models.db.player import Player from bracket.models.db.team import Team, TeamBody, TeamToInsert, TeamWithPlayers from bracket.models.db.user import UserPublic from bracket.routes.auth import get_current_user -from bracket.routes.models import ( - SingleTeamResponse, - SuccessResponse, - TeamsResponse, - TeamsWithPlayersResponse, -) +from bracket.routes.models import SingleTeamResponse, SuccessResponse, TeamsWithPlayersResponse from bracket.schema import players, teams -from bracket.utils.db import fetch_all_parsed, fetch_one_parsed +from bracket.utils.db import fetch_one_parsed router = APIRouter() diff --git a/backend/bracket/utils/dummy_records.py b/backend/bracket/utils/dummy_records.py index c1747bf7..5cec1b22 100644 --- a/backend/bracket/utils/dummy_records.py +++ b/backend/bracket/utils/dummy_records.py @@ -1,3 +1,5 @@ +from zoneinfo import ZoneInfo + from heliclockter import datetime_utc from passlib.context import CryptContext @@ -11,27 +13,29 @@ from bracket.models.db.user import User pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +DUMMY_MOCK_TIME = datetime_utc(2022, 1, 11, 4, 32, 11, tzinfo=ZoneInfo('UTC')) + DUMMY_CLUB = Club( name='Some Cool Club', - created=datetime_utc.now(), + created=DUMMY_MOCK_TIME, ) DUMMY_TOURNAMENT = Tournament( club_id=1, name='Some Cool Tournament', - created=datetime_utc.now(), + created=DUMMY_MOCK_TIME, ) DUMMY_ROUND1 = Round( tournament_id=1, - created=datetime_utc.now(), + created=DUMMY_MOCK_TIME, is_draft=False, name='Round 1', ) DUMMY_ROUND2 = Round( tournament_id=1, - created=datetime_utc.now(), + created=DUMMY_MOCK_TIME, is_active=True, is_draft=False, name='Round 2', @@ -39,13 +43,13 @@ DUMMY_ROUND2 = Round( DUMMY_ROUND3 = Round( tournament_id=1, - created=datetime_utc.now(), + created=DUMMY_MOCK_TIME, is_draft=True, name='Round 3', ) DUMMY_MATCH1 = Match( - created=datetime_utc.now(), + created=DUMMY_MOCK_TIME, round_id=1, team1_id=1, team2_id=2, @@ -54,7 +58,7 @@ DUMMY_MATCH1 = Match( ) DUMMY_MATCH2 = Match( - created=datetime_utc.now(), + created=DUMMY_MOCK_TIME, round_id=1, team1_id=3, team2_id=4, @@ -63,7 +67,7 @@ DUMMY_MATCH2 = Match( ) DUMMY_MATCH3 = Match( - created=datetime_utc.now(), + created=DUMMY_MOCK_TIME, round_id=2, team1_id=1, team2_id=4, @@ -72,7 +76,7 @@ DUMMY_MATCH3 = Match( ) DUMMY_MATCH4 = Match( - created=datetime_utc.now(), + created=DUMMY_MOCK_TIME, round_id=2, team1_id=2, team2_id=3, @@ -84,32 +88,32 @@ DUMMY_USER = User( email='admin@example.com', name='Admin', password_hash=pwd_context.hash('adminadmin'), - created=datetime_utc.now(), + created=DUMMY_MOCK_TIME, ) DUMMY_TEAM1 = Team( - created=datetime_utc.now(), + created=DUMMY_MOCK_TIME, name='Team 1', tournament_id=1, active=True, ) DUMMY_TEAM2 = Team( - created=datetime_utc.now(), + created=DUMMY_MOCK_TIME, name='Team 2', tournament_id=1, active=True, ) DUMMY_TEAM3 = Team( - created=datetime_utc.now(), + created=DUMMY_MOCK_TIME, name='Team 3', tournament_id=1, active=True, ) DUMMY_TEAM4 = Team( - created=datetime_utc.now(), + created=DUMMY_MOCK_TIME, name='Team 4', tournament_id=1, active=True, @@ -118,7 +122,7 @@ DUMMY_TEAM4 = Team( DUMMY_PLAYER1 = Player( name='Luke', - created=datetime_utc.now(), + created=DUMMY_MOCK_TIME, team_id=1, tournament_id=1, elo_score=0, @@ -126,7 +130,7 @@ DUMMY_PLAYER1 = Player( DUMMY_PLAYER2 = Player( name='Anakin', - created=datetime_utc.now(), + created=DUMMY_MOCK_TIME, team_id=1, tournament_id=1, elo_score=0, @@ -134,7 +138,7 @@ DUMMY_PLAYER2 = Player( DUMMY_PLAYER3 = Player( name='Leia', - created=datetime_utc.now(), + created=DUMMY_MOCK_TIME, team_id=2, tournament_id=1, elo_score=0, @@ -142,7 +146,7 @@ DUMMY_PLAYER3 = Player( DUMMY_PLAYER4 = Player( name='Yoda', - created=datetime_utc.now(), + created=DUMMY_MOCK_TIME, team_id=2, tournament_id=1, elo_score=0, @@ -150,7 +154,7 @@ DUMMY_PLAYER4 = Player( DUMMY_PLAYER5 = Player( name='Boba', - created=datetime_utc.now(), + created=DUMMY_MOCK_TIME, team_id=3, tournament_id=1, elo_score=0, @@ -158,7 +162,7 @@ DUMMY_PLAYER5 = Player( DUMMY_PLAYER6 = Player( name='General', - created=datetime_utc.now(), + created=DUMMY_MOCK_TIME, team_id=3, tournament_id=1, elo_score=0, @@ -166,7 +170,7 @@ DUMMY_PLAYER6 = Player( DUMMY_PLAYER7 = Player( name='Han', - created=datetime_utc.now(), + created=DUMMY_MOCK_TIME, team_id=4, tournament_id=1, elo_score=0, @@ -174,7 +178,7 @@ DUMMY_PLAYER7 = Player( DUMMY_PLAYER8 = Player( name='Emperor', - created=datetime_utc.now(), + created=DUMMY_MOCK_TIME, team_id=4, tournament_id=1, elo_score=0, @@ -182,7 +186,7 @@ DUMMY_PLAYER8 = Player( DUMMY_PLAYER9 = Player( name='R2D2', - created=datetime_utc.now(), + created=DUMMY_MOCK_TIME, team_id=None, tournament_id=1, elo_score=0, diff --git a/backend/precommit.sh b/backend/precommit.sh index 2ebb4137..a421d0cd 100755 --- a/backend/precommit.sh +++ b/backend/precommit.sh @@ -3,6 +3,6 @@ set -evo pipefail black . dmypy run -- --follow-imports=normal --junit-xml= . -ENVIRONMENT=CI pytest --cov . +ENVIRONMENT=CI pytest --cov --cov-report=xml . pylint alembic bracket tests isort . diff --git a/backend/pyproject.toml b/backend/pyproject.toml index e7895b6f..5c301e56 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -61,6 +61,7 @@ disable = [ 'unused-argument', # Gives false positives. 'invalid-name', 'dangerous-default-value', + 'duplicate-code', ] [tool.bandit] diff --git a/backend/tests/integration_tests/api/auth_test.py b/backend/tests/integration_tests/api/auth_test.py index 6a304709..a5a9a681 100644 --- a/backend/tests/integration_tests/api/auth_test.py +++ b/backend/tests/integration_tests/api/auth_test.py @@ -8,7 +8,7 @@ from bracket.config import config 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.mocks import MOCK_NOW, MOCK_USER +from tests.integration_tests.mocks import MOCK_NOW, MOCK_USER, get_mock_token from tests.integration_tests.sql import inserted_user @@ -47,12 +47,7 @@ async def test_get_token_invalid_credentials(startup_and_shutdown_uvicorn_server async def test_auth_on_protected_endpoint(startup_and_shutdown_uvicorn_server: None) -> None: - token = ( - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.' - + 'eyJ1c2VyIjoiZG9uYWxkX2R1Y2siLCJleHAiOjcyNTgxMjAyMDB9.' - + 'CRk4n5gmgto5K-qWtI4hbcqo92BxLkggwwK1yTgWGLM' - ) - headers = {'Authorization': f'Bearer {token}'} + 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)) diff --git a/backend/tests/integration_tests/api/clubs_test.py b/backend/tests/integration_tests/api/clubs_test.py new file mode 100644 index 00000000..286f744c --- /dev/null +++ b/backend/tests/integration_tests/api/clubs_test.py @@ -0,0 +1,18 @@ +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_clubs_endpoint( + startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext +) -> None: + assert await send_auth_request(HTTPMethod.GET, 'clubs', auth_context, {}) == { + 'data': [ + { + 'created': DUMMY_MOCK_TIME.isoformat(), + 'id': 1, + 'name': 'Some Cool Club', + } + ], + } diff --git a/backend/tests/integration_tests/api/conftest.py b/backend/tests/integration_tests/api/conftest.py index 8dd3a119..8f21c042 100644 --- a/backend/tests/integration_tests/api/conftest.py +++ b/backend/tests/integration_tests/api/conftest.py @@ -1,17 +1,17 @@ +# pylint: disable=redefined-outer-name import asyncio import os from asyncio import AbstractEventLoop -from functools import partial -from typing import AsyncIterator, Iterator +from typing import AsyncIterator -import aioresponses import pytest -from aiohttp import ClientResponse from databases import Database from bracket.database import database, engine from bracket.schema import metadata from tests.integration_tests.api.shared import UvicornTestServer +from tests.integration_tests.models import AuthContext +from tests.integration_tests.sql import inserted_auth_context os.environ['ENVIRONMENT'] = 'CI' @@ -29,13 +29,6 @@ async def startup_and_shutdown_uvicorn_server() -> AsyncIterator[None]: await server.down() -@pytest.fixture -def mock_http() -> Iterator[aioresponses.aioresponses]: - with aioresponses.aioresponses() as m: - m.add = partial(m.add, response_class=ClientResponse) # type: ignore[assignment] - yield m - - @pytest.fixture(scope="session") def event_loop() -> AsyncIterator[AbstractEventLoop]: # type: ignore[misc] try: @@ -48,10 +41,18 @@ def event_loop() -> AsyncIterator[AbstractEventLoop]: # type: ignore[misc] @pytest.fixture(scope="session", autouse=True) -async def reinit_database( - event_loop: AbstractEventLoop, # pylint: disable=redefined-outer-name -) -> AsyncIterator[Database]: +async def reinit_database(event_loop: AbstractEventLoop) -> AsyncIterator[Database]: await database.connect() metadata.drop_all(engine) metadata.create_all(engine) - yield database + try: + yield database + finally: + await database.disconnect() + + +@pytest.fixture(scope="session") +async def auth_context(reinit_database: Database) -> AsyncIterator[AuthContext]: + async with reinit_database: + async with inserted_auth_context() as auth_context: + yield auth_context diff --git a/backend/tests/integration_tests/api/players_test.py b/backend/tests/integration_tests/api/players_test.py new file mode 100644 index 00000000..0651a4a2 --- /dev/null +++ b/backend/tests/integration_tests/api/players_test.py @@ -0,0 +1,24 @@ +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.models import AuthContext +from tests.integration_tests.sql import inserted_player, inserted_team + + +async def test_players_endpoint( + startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext +) -> None: + async with inserted_team(DUMMY_TEAM1): + async with inserted_player(DUMMY_PLAYER1) as player_inserted: + assert await send_tournament_request(HTTPMethod.GET, 'players', auth_context, {}) == { + 'data': [ + { + 'created': DUMMY_MOCK_TIME.isoformat(), + 'id': player_inserted.id, + 'elo_score': 0.0, + 'name': 'Luke', + 'team_id': 1, + 'tournament_id': 1, + } + ], + } diff --git a/backend/tests/integration_tests/api/rounds_test.py b/backend/tests/integration_tests/api/rounds_test.py new file mode 100644 index 00000000..3917e5aa --- /dev/null +++ b/backend/tests/integration_tests/api/rounds_test.py @@ -0,0 +1,25 @@ +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.models import AuthContext +from tests.integration_tests.sql import inserted_round, inserted_team + + +async def test_rounds_endpoint( + 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.GET, 'rounds', auth_context, {}) == { + 'data': [ + { + 'created': DUMMY_MOCK_TIME.isoformat(), + 'id': round_inserted.id, + 'is_active': False, + 'is_draft': False, + 'matches': [], + 'name': 'Round 1', + 'tournament_id': 1, + } + ], + } diff --git a/backend/tests/integration_tests/api/shared.py b/backend/tests/integration_tests/api/shared.py index cf9e5103..f114c5a7 100644 --- a/backend/tests/integration_tests/api/shared.py +++ b/backend/tests/integration_tests/api/shared.py @@ -10,6 +10,7 @@ from fastapi import FastAPI from bracket.app import app from bracket.utils.http import HTTPMethod from bracket.utils.types import JsonDict, JsonObject +from tests.integration_tests.models import AuthContext def find_free_port() -> int: @@ -74,3 +75,17 @@ async def send_request( ) as resp: response: JsonObject = await resp.json() return response + + +async def send_auth_request( + method: HTTPMethod, endpoint: str, auth_context: AuthContext, body: JsonDict = {} +) -> JsonObject: + return await send_request(method, endpoint, body, auth_context.headers) + + +async def send_tournament_request( + method: HTTPMethod, endpoint: str, auth_context: AuthContext, body: JsonDict = {} +) -> JsonObject: + return await send_request( + method, f'tournaments/{auth_context.tournament.id}/{endpoint}', body, auth_context.headers + ) diff --git a/backend/tests/integration_tests/api/teams_test.py b/backend/tests/integration_tests/api/teams_test.py new file mode 100644 index 00000000..53f0bf2b --- /dev/null +++ b/backend/tests/integration_tests/api/teams_test.py @@ -0,0 +1,23 @@ +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.models import AuthContext +from tests.integration_tests.sql import inserted_team + + +async def test_teams_endpoint( + 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.GET, 'teams', auth_context, {}) == { + 'data': [ + { + 'active': True, + 'created': DUMMY_MOCK_TIME.isoformat(), + 'id': team_inserted.id, + 'name': 'Team 1', + 'players': [], + 'tournament_id': 1, + } + ], + } diff --git a/backend/tests/integration_tests/mocks.py b/backend/tests/integration_tests/mocks.py index f476ed40..2d89d932 100644 --- a/backend/tests/integration_tests/mocks.py +++ b/backend/tests/integration_tests/mocks.py @@ -9,6 +9,14 @@ MOCK_NOW = datetime_utc( ) +def get_mock_token() -> str: + return ( + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.' + + 'eyJ1c2VyIjoiZG9uYWxkX2R1Y2siLCJleHAiOjcyNTgxMjAyMDB9.' + + 'CRk4n5gmgto5K-qWtI4hbcqo92BxLkggwwK1yTgWGLM' + ) + + MOCK_USER = User( email='donald_duck', name='Donald Duck', diff --git a/backend/tests/integration_tests/models.py b/backend/tests/integration_tests/models.py new file mode 100644 index 00000000..f2e38837 --- /dev/null +++ b/backend/tests/integration_tests/models.py @@ -0,0 +1,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 + + +class AuthContext(BaseModel): + club: Club + tournament: Tournament + user: User + headers: dict[str, str] diff --git a/backend/tests/integration_tests/sql.py b/backend/tests/integration_tests/sql.py index 062fb061..60507b53 100644 --- a/backend/tests/integration_tests/sql.py +++ b/backend/tests/integration_tests/sql.py @@ -1,20 +1,90 @@ from contextlib import asynccontextmanager -from typing import AsyncIterator +from typing import AsyncIterator, Type, cast + +from sqlalchemy import Table from bracket.database import database +from bracket.models.db.club import Club +from bracket.models.db.match import Match +from bracket.models.db.player import Player +from bracket.models.db.round import Round +from bracket.models.db.team import Team +from bracket.models.db.tournament import Tournament from bracket.models.db.user import User, UserInDB -from bracket.schema import users +from bracket.schema import clubs, matches, players, rounds, teams, tournaments, users from bracket.utils.db import fetch_one_parsed +from bracket.utils.dummy_records import DUMMY_CLUB, DUMMY_TOURNAMENT +from bracket.utils.types import BaseModelT +from tests.integration_tests.mocks import MOCK_USER, get_mock_token +from tests.integration_tests.models import AuthContext + + +@asynccontextmanager +async def inserted_generic( + data_model: BaseModelT, table: Table, return_type: Type[BaseModelT] +) -> AsyncIterator[BaseModelT]: + last_record_id = await database.execute(query=table.insert(), values=data_model.dict()) + row_inserted = await fetch_one_parsed( + database, return_type, table.select().where(table.c.id == last_record_id) + ) + assert isinstance(row_inserted, return_type) + try: + yield row_inserted + finally: + await database.execute(query=table.delete().where(table.c.id == last_record_id)) @asynccontextmanager async def inserted_user(user: User) -> AsyncIterator[UserInDB]: - last_record_id = await database.execute(query=users.insert(), values=user.dict()) - user_inserted = await fetch_one_parsed( - database, UserInDB, users.select().where(users.c.id == last_record_id) - ) - assert user_inserted is not None - try: - yield user_inserted - finally: - await database.execute(query=users.delete().where(users.c.id == last_record_id)) + async with inserted_generic(user, users, UserInDB) as row_inserted: + yield cast(UserInDB, row_inserted) + + +@asynccontextmanager +async def inserted_club(club: Club) -> AsyncIterator[Club]: + async with inserted_generic(club, clubs, Club) as row_inserted: + yield row_inserted + + +@asynccontextmanager +async def inserted_tournament(tournament: Tournament) -> AsyncIterator[Tournament]: + async with inserted_generic(tournament, tournaments, Tournament) as row_inserted: + yield row_inserted + + +@asynccontextmanager +async def inserted_team(team: Team) -> AsyncIterator[Team]: + async with inserted_generic(team, teams, Team) as row_inserted: + yield row_inserted + + +@asynccontextmanager +async def inserted_player(player: Player) -> AsyncIterator[Player]: + async with inserted_generic(player, players, Player) as row_inserted: + yield row_inserted + + +@asynccontextmanager +async def inserted_round(round_: Round) -> AsyncIterator[Round]: + async with inserted_generic(round_, rounds, Round) as row_inserted: + yield row_inserted + + +@asynccontextmanager +async def inserted_match(match: Match) -> AsyncIterator[Match]: + async with inserted_generic(match, matches, Match) as row_inserted: + yield row_inserted + + +@asynccontextmanager +async def inserted_auth_context() -> AsyncIterator[AuthContext]: + headers = {'Authorization': f'Bearer {get_mock_token()}'} + async with inserted_user(MOCK_USER) as user_inserted: + async with inserted_club(DUMMY_CLUB) as club_inserted: + async with inserted_tournament(DUMMY_TOURNAMENT) as tournament_inserted: + yield AuthContext( + headers=headers, + user=user_inserted, + club=club_inserted, + tournament=tournament_inserted, + )