Skip to content

Commit

Permalink
docs: update badges and README..upgrade mypy to 1.1.1; test: add tag …
Browse files Browse the repository at this point in the history
…service tests (#25)
  • Loading branch information
nickatnight authored Mar 11, 2023
1 parent 2709744 commit b0539b9
Show file tree
Hide file tree
Showing 16 changed files with 159 additions and 44 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: build, test and deploy
name: ci

on:
push:
Expand Down
17 changes: 5 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
<img alt="Coverage" src="https://codecov.io/gh/nickatnight/tag-youre-it-backend/branch/main/graph/badge.svg?token=E03I4QK6D9"/>
</a>
<a href="https://github.com/nickatnight/tag-youre-it-backend/releases"><img alt="Release Status" src="https://img.shields.io/github/v/release/nickatnight/tag-youre-it-backend"></a>
<a href="https://github.com/nickatnight/tag-youre-it-backend/releases"><img alt="Python Badge" src="https://img.shields.io/badge/python-3.8%7C3.9%7C3.10%7C3.11-blue"></a>
<a href="https://api.tagyoureitbot.com/docs"><img alt="Docs Badge" src="https://img.shields.io/badge/openapi-docs-blue"></a>
<a href="https://tagyoureitbot.com"><img alt="Site Badge" src="https://img.shields.io/badge/site-React-important"></a>
<a href="https://github.com/psf/black"><img alt="Style Badge" src="https://img.shields.io/badge/code%20style-black-000000"></a>
<a href="https://mypy.readthedocs.io/en/stable/"><img alt="MyPy Badge" src="https://img.shields.io/badge/mypy-1.1.1-FFDD23">
<a href="https://github.com/nickatnight/tag-youre-it-backend/blob/main/LICENSE">
<img alt="License Shield" src="https://img.shields.io/github/license/nickatnight/tag-youre-it-backend">
</a>
Expand All @@ -20,10 +23,6 @@

FastAPI backend for Reddit's TagYoureItBot

Frontend - https://tagyoureitbot.com

API - https://api.tagyoureitbot.com/docs

Project was scaffolded with [cookiecutter-fastapi-backend](https://github.com/nickatnight/cookiecutter-fastapi-backend)

## How To Play (beta)
Expand Down Expand Up @@ -68,15 +67,9 @@ See [r/TagYoureItBot](https://www.reddit.com/r/TagYoureItBot) for more updates.
3. Backend, JSON based web API based on OpenAPI: `http://localhost/v1/`
4. Automatic interactive documentation with Swagger UI (from the OpenAPI backend): `http://localhost/docs`

The entrypoint to the bot can be found in `src.core.bot`. In short, for each sub which the bot is enabled, an async process will be pushed onto the event loop (each sub gets their own game engine).
The entrypoint to the bot can be found in [src.core.bot](/backend/src/core/bot.py). In short, for each sub which the bot is enabled, an async process will be pushed onto the event loop (each sub gets their own game engine).

### Migrations

After adding some models in `src/models/`, you can run the initial making of the migrations
```console
$ make alembic-init
$ make alembic-migrate
```
Every migration after that, you can create new migrations and apply them with
```console
$ make alembic-make-migrations "cool comment dude"
Expand Down
5 changes: 3 additions & 2 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "tag-youre-it-backend"
version = "0.1.0"
version = "0.1.1"
description = "Backend for TagYoureIt Reddit bot"
authors = ["nickatnight <[email protected]>"]

Expand Down Expand Up @@ -29,9 +29,10 @@ pytest = "^7.2.1"
pytest-cov = "^4.0.0"
pytest-mock = "^3.10.0"
pytest-asyncio = "^0.19.0"
mypy = "^0.982"
mypy = "^1.1.1"
ruff = "^0.0.241"
httpx = "^0.23.3"
Faker = "^17.6.0"

[tool.isort]
multi_line_output = 3
Expand Down
2 changes: 1 addition & 1 deletion backend/src/api/v1/game.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,5 @@ async def games(
repo = GameRepository(db=session)
games = await repo.all(skip=skip, limit=limit, sort_field=sort, sort_order=order.lower())

response.headers["x-content-range"] = f"{len(games)}/{10}"
response.headers["x-content-range"] = f"{len(games)}/{limit}"
return IGetResponseBase[List[IGameRead]](data=games)
2 changes: 1 addition & 1 deletion backend/src/api/v1/player.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,5 @@ async def players(
repo = PlayerRepository(db=session)
players = await repo.all(skip=skip, limit=limit, sort_field=sort, sort_order=order.lower())

response.headers["x-content-range"] = f"{len(players)}/{10}"
response.headers["x-content-range"] = f"{len(players)}/{limit}"
return IGetResponseBase[List[IPlayerRead]](data=players)
2 changes: 1 addition & 1 deletion backend/src/api/v1/subreddit.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,5 @@ async def subreddits(
repo = SubRedditRepository(db=session)
subreddits = await repo.all(skip=skip, limit=limit, sort_field=sort, sort_order=order.lower())

response.headers["x-content-range"] = f"{len(subreddits)}/{10}"
response.headers["x-content-range"] = f"{len(subreddits)}/{limit}"
return IGetResponseBase[List[ISubRedditRead]](data=subreddits)
5 changes: 3 additions & 2 deletions backend/src/clients/reddit/inbox.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
from typing import AsyncIterator
from typing import AsyncIterator, Optional

from asyncpraw import Reddit
from asyncpraw.models import Message

from src.clients.reddit.base import RedditResource


class InboxClient(RedditResource):
def __init__(self, reddit=None) -> None:
def __init__(self, reddit: Optional[Reddit] = None) -> None:
self.reddit = reddit or self.configure()

def stream(self) -> AsyncIterator[Message]:
Expand Down
4 changes: 2 additions & 2 deletions backend/src/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ class Settings(BaseSettings):
DB_POOL_SIZE: int = Field(default=83, env="DB_POOL_SIZE")
WEB_CONCURRENCY: int = Field(default=9, env="WEB_CONCURRENCY")
MAX_OVERFLOW: int = Field(default=64, env="MAX_OVERFLOW")
POOL_SIZE: Optional[int]
POSTGRES_URL: Optional[str]
POOL_SIZE: int = Field(default=None, env="POOL_SIZE")
POSTGRES_URL: str = Field(default=None, env="POSTGRES_URL")

CLIENT_ID: str = Field(default="", env="CLIENT_ID")
CLIENT_SECRET: str = Field(default="", env="CLIENT_SECRET")
Expand Down
7 changes: 4 additions & 3 deletions backend/src/core/decorators.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import functools
import logging
from typing import Any
from typing import Any, Callable

import asyncprawcore


logger = logging.getLogger(__name__)


def catch_apraw_and_log(func):
# TODO: revisit this
def catch_apraw_and_log(func: Callable[..., Any]) -> Any:
@functools.wraps(func)
async def wrapper_catch_apraw_and_log(*args, **kwargs):
async def wrapper_catch_apraw_and_log(*args: str, **kwargs: int) -> Any:
try:
result: Any = await func(*args, **kwargs)
except asyncprawcore.exceptions.RequestException as a_exc:
Expand Down
4 changes: 4 additions & 0 deletions backend/src/core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@ class TagYoureItBackendException(Exception):

class ObjectNotFound(TagYoureItBackendException):
pass


class TagTimeNullError(TagYoureItBackendException):
pass
7 changes: 6 additions & 1 deletion backend/src/core/utils.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import inspect
import logging
from datetime import datetime, timezone
from typing import Optional

from pydantic import BaseModel as DanticBaseModel

from src.core.const import TAG_TIME
from src.core.exceptions import TagTimeNullError


logger = logging.getLogger(__name__)


def is_tag_time_expired(tag_time: datetime) -> bool:
def is_tag_time_expired(tag_time: Optional[datetime] = None) -> bool:
if not tag_time:
raise TagTimeNullError()

tag_over_time: int = int((datetime.now(timezone.utc) - tag_time).total_seconds())

return tag_over_time > TAG_TIME
Expand Down
6 changes: 3 additions & 3 deletions backend/src/repositories/sqlalchemy.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ async def create(self, obj_in: CreateSchemaType, **kwargs: Any) -> ModelType:
return db_obj

async def get(self, **kwargs: Any) -> Optional[ModelType]:
logger.info(f"Fetching [{self._model.__table__.name.capitalize()}] object by [{kwargs}]")
logger.info(f"Fetching [{self._model.__table__.name.capitalize()}] object by [{kwargs}]") # type: ignore

query = select(self._model).filter_by(**kwargs)
response = await self.db.execute(query)
Expand All @@ -58,7 +58,7 @@ async def get(self, **kwargs: Any) -> Optional[ModelType]:
return scalar

async def update(self, obj_current: ModelType, obj_in: UpdateSchemaType) -> ModelType:
logger.info(f"Updating [{self._model.__table__.name.capitalize()}] object with [{obj_in}]")
logger.info(f"Updating [{self._model.__table__.name.capitalize()}] object with [{obj_in}]") # type: ignore

update_data = obj_in.dict(
exclude_unset=True
Expand Down Expand Up @@ -101,7 +101,7 @@ async def all(
return response.scalars().all()

async def f(self, **kwargs: Any) -> List[ModelType]:
logger.info(f"Filtering [{self._model.__table__.name.capitalize()}] object by [{kwargs}]")
logger.info(f"Filtering [{self._model.__table__.name.capitalize()}] object by [{kwargs}]") # type: ignore

query = select(self._model).filter_by(**kwargs)
response = await self.db.execute(query)
Expand Down
2 changes: 1 addition & 1 deletion backend/src/services/stream/inbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ async def process(
return game_id

it_player: Player = tag_service.it_player(game)
if is_tag_time_expired(it_player.tag_time): # type: ignore
if is_tag_time_expired(it_player.tag_time):
await tag_service.reset_game(game.ref_id, it_player)
await obj.reply(ReplyEnum.game_over())
return None
Expand Down
8 changes: 1 addition & 7 deletions backend/src/services/tag.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,7 @@ async def current_game(self, subreddit: SubReddit) -> Optional[Game]:
def it_player(self, game: Game) -> Player:
logger.info(f"Fetching 'it' Player for Game[{game.ref_id}]")

# TODO: either prevent a player from tagging someone in a different sub
# while 'it' or add new field to determine when tagged
# players: List[Player] = [p for p in game.players if p.is_it]
# sorted_playes: List[Player] = sorted(
# players, key=lambda p: p.tag_time, reverse=True # type: ignore
# )

# TODO: this will get super slow at some point
for p in game.players:
if p.is_it:
return p
Expand Down
53 changes: 46 additions & 7 deletions backend/tests/unit/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import pytest
import pytest_asyncio
from asyncpraw import Reddit
from faker import Faker
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
Expand All @@ -18,15 +19,16 @@
from src.repositories.player import PlayerRepository
from src.repositories.sqlalchemy import BaseSQLAlchemyRepository
from src.repositories.subreddit import SubRedditRepository
from src.services.base import BaseService
from src.services.game import GameService
from src.services.player import PlayerService
from src.services.subreddit import SubRedditService
from src.services.tag import TagService
from tests.unit import test_redditor_one, test_subreddit
from tests.utils import test_engine


FAKE_SETTINGS = {
"client_id": "dummy",
"client_secret": "dummy",
"user_agent": "dummy",
}
fake = Faker()


@pytest.fixture(scope="module")
Expand Down Expand Up @@ -79,6 +81,28 @@ def player_repo(async_session: AsyncSession) -> BaseSQLAlchemyRepository:
return PlayerRepository(db=async_session)


@pytest.fixture
def mock_game_service(game_repo: BaseSQLAlchemyRepository) -> BaseService:
return GameService(repo=game_repo)


@pytest.fixture
def mock_sub_service(sub_repo: BaseSQLAlchemyRepository) -> BaseService:
return SubRedditService(repo=sub_repo)


@pytest.fixture
def mock_player_service(player_repo: BaseSQLAlchemyRepository) -> BaseService:
return PlayerService(repo=player_repo)


@pytest.fixture
def mock_tag_service(
mock_player_service: BaseService, mock_game_service: BaseService, mock_sub_service: BaseService
) -> TagService:
return TagService(mock_player_service, mock_game_service, mock_sub_service)


# TODO: add base fields here?
@pytest.fixture
def player() -> Player:
Expand All @@ -95,16 +119,31 @@ def player() -> Player:


@pytest.fixture
def it_player(player: Player) -> Player:
def fake_player() -> Player:
return Player(
username=fake.first_name().lower(),
reddit_id=fake.word(),
icon_img=fake.image_url(),
is_employee=fake.boolean(),
created_utc=fake.unix_time(),
verified=fake.boolean(),
is_suspended=False,
has_verified_email=fake.boolean(),
)


@pytest.fixture
def it_player() -> Player:
return Player(
username="testname",
username="iamitplayer",
reddit_id="testid",
icon_img=test_redditor_one["icon_img"],
is_employee=test_redditor_one["is_employee"],
created_utc=test_redditor_one["created_utc"],
verified=False,
is_suspended=False,
has_verified_email=True,
is_it=True,
)


Expand Down
77 changes: 77 additions & 0 deletions backend/tests/unit/src/services/test_tag_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select

from src.models.game import Game
from src.models.player import Player
from src.models.subreddit import SubReddit
from src.services.tag import TagService


@pytest_asyncio.fixture(autouse=True)
async def setup(async_session, player, it_player, subreddit):
async_session.add(player)
async_session.add(it_player)
async_session.add(subreddit)
await async_session.commit()

await async_session.refresh(player)
await async_session.refresh(it_player)
await async_session.refresh(subreddit)

game = Game(subreddit_id=subreddit.id)
async_session.add(game)
await async_session.commit()
await async_session.refresh(game)

game.players.append(player)
game.players.append(it_player)
await async_session.commit()

yield


class TestTagService:
@pytest.mark.asyncio
async def test_reset_game(self, async_session: AsyncSession, mock_tag_service: TagService):
player_response = await async_session.execute(
select(Player).where(Player.reddit_id == "testid")
)
p = player_response.scalar_one()
game_response = await async_session.execute(select(Game))
g = game_response.scalars().all()[0]

assert g.is_active is True
await mock_tag_service.reset_game(g.ref_id, p)

assert g.is_active is False

@pytest.mark.asyncio
async def test_current_game(self, mock_tag_service: TagService, subreddit: SubReddit):
game = await mock_tag_service.current_game(subreddit)
assert game.subreddit.sub_id == subreddit.sub_id

@pytest.mark.asyncio
async def test_add_player_to_game(
self, async_session: AsyncSession, fake_player: Player, mock_tag_service: TagService
):
async_session.add(fake_player)
await async_session.commit()
await async_session.refresh(fake_player)

game_response = await async_session.execute(select(Game))
g = game_response.scalars().all()[0]

await mock_tag_service.add_player_to_game(g.ref_id, fake_player)

assert fake_player in g.players

@pytest.mark.asyncio
async def test_it_player(self, async_session: AsyncSession, mock_tag_service: TagService):
game_response = await async_session.execute(select(Game))
g = game_response.scalars().all()[0]

it = mock_tag_service.it_player(g)

assert it.username == "iamitplayer"

0 comments on commit b0539b9

Please sign in to comment.