From 4207c627b7af4bddf045da4e693400bfa418e617 Mon Sep 17 00:00:00 2001 From: Sergey Natalenko Date: Fri, 1 Mar 2024 20:33:13 +0300 Subject: [PATCH 1/2] Add release workflow --- .github/workflows/release.yml | 59 +++++++++++++++ .gitignore | 130 ++++++++++++++++++++++++++++++++++ docker-compose.yaml | 20 +++--- 3 files changed, 199 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..055881d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,59 @@ +name: Make release + +on: + workflow_dispatch: + push: + branches: + - master + +jobs: + checking: + name: Check repository + uses: ./.github/workflows/check.yml + + build_and_push_docker: + name: Build and push AMD64 and ARM64 images + needs: checking + runs-on: ubuntu-latest + steps: + + - name: Set up tag + id: vars + run: echo "sha_short=`echo ${GITHUB_SHA} | cut -c1-8`" >> $GITHUB_OUTPUT + + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push backend image + uses: docker/build-push-action@v5 + with: + push: true + platforms: linux/amd64,linux/arm64 + context: ./ + file: ./docker/rest.dockerfile + tags: | + andytakker/industry-game-rest:latest + andytakker/vk-parser:${{ steps.vars.outputs.sha_short }} + + - name: Build and push frontend image + uses: docker/build-push-action@v5 + with: + push: true + platforms: linux/amd64,linux/arm64 + context: ./ + file: ./docker/frontend.dockerfile + tags: | + andytakker/industry-game-frontend:latest + andytakker/vk-parser:${{ steps.vars.outputs.sha_short }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7b0c7e0..8826363 100644 --- a/.gitignore +++ b/.gitignore @@ -161,3 +161,133 @@ cython_debug/ .DS_Store ssl_keys/ *.pem +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* diff --git a/docker-compose.yaml b/docker-compose.yaml index b533a8e..04bd060 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -12,10 +12,10 @@ services: rest: restart: on-failure - # image: andytakker/industry-game-rest:latest - build: - dockerfile: ./docker/rest.dockerfile - context: . + image: andytakker/industry-game-rest:latest + # build: + # dockerfile: ./docker/rest.dockerfile + # context: . entrypoint: python -m industry_game environment: APP_API_ADDRESS: 0.0.0.0 @@ -34,12 +34,12 @@ services: frontend: restart: on-failure - # image: andytakker/industry-game-frontend:latest - build: - dockerfile: ./docker/frontend.dockerfile - context: . - args: - BASE_URL: https://vk.com + image: andytakker/industry-game-frontend:latest + # build: + # dockerfile: ./docker/frontend.dockerfile + # context: . + # args: + # BASE_URL: https://vk.com ports: - 80:80 - 443:443 From 0b575e53f387b60959221a9a5de9dbb3ebcdcf85 Mon Sep 17 00:00:00 2001 From: Sergey Natalenko Date: Sat, 2 Mar 2024 12:06:18 +0300 Subject: [PATCH 2/2] Add release workflow --- .github/workflows/release.yml | 2 +- Makefile | 2 +- .../games/{__init__.py => game_create.py} | 0 .../{read_by_id_game.py => game_details.py} | 4 +- .../games/{list_game.py => game_list.py} | 0 .../games/{update_game.py => game_update.py} | 0 .../handlers/games/lobby/list_lobby.py | 7 +- industry_game/handlers/ping.py | 3 +- .../handlers/players/login_player.py | 2 +- industry_game/services/rest.py | 8 +- industry_game/utils/lobby/models.py | 3 +- industry_game/utils/lobby/storage.py | 11 ++- industry_game/utils/timer.py | 7 +- industry_game/utils/users/base.py | 4 +- industry_game/utils/users/storage.py | 1 - poetry.lock | 56 ++++++------- pyproject.toml | 2 +- tests/plugins/factories/games.py | 29 ++++--- tests/plugins/factories/users.py | 30 ++++--- tests/plugins/jwt_auth.py | 17 ++++ tests/plugins/rest.py | 1 - tests/plugins/users.py | 2 + .../test_api/test_games/test_game_details.py | 76 ++++++++++++++++++ .../{test_games_list.py => test_game_list.py} | 4 +- .../test_players/test_login_player.py | 80 +++++++++++++++++++ 25 files changed, 269 insertions(+), 82 deletions(-) rename industry_game/handlers/games/{__init__.py => game_create.py} (100%) rename industry_game/handlers/games/{read_by_id_game.py => game_details.py} (81%) rename industry_game/handlers/games/{list_game.py => game_list.py} (100%) rename industry_game/handlers/games/{update_game.py => game_update.py} (100%) create mode 100644 tests/test_api/test_games/test_game_details.py rename tests/test_api/test_games/{test_games_list.py => test_game_list.py} (97%) create mode 100644 tests/test_api/test_players/test_login_player.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 055881d..e9ae9e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,7 +46,7 @@ jobs: tags: | andytakker/industry-game-rest:latest andytakker/vk-parser:${{ steps.vars.outputs.sha_short }} - + - name: Build and push frontend image uses: docker/build-push-action@v5 with: diff --git a/Makefile b/Makefile index 5419808..f76114e 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ lint-ci: lint-py lint-js ##@Linting Run all linters in CI lint-py: flake ruff bandit mypy ##@Linting Run all python linters in CI lint-js: ##@Linting Run JS linter in CI - cd ./industry_game/static && yarn lint --no-fix + cd ./frontend && yarn lint --no-fix flake: ##@Linting Run flake8 .venv/bin/flake8 --max-line-length 88 --format=default $(PROJECT_PATH) 2>&1 | tee flake8.txt diff --git a/industry_game/handlers/games/__init__.py b/industry_game/handlers/games/game_create.py similarity index 100% rename from industry_game/handlers/games/__init__.py rename to industry_game/handlers/games/game_create.py diff --git a/industry_game/handlers/games/read_by_id_game.py b/industry_game/handlers/games/game_details.py similarity index 81% rename from industry_game/handlers/games/read_by_id_game.py rename to industry_game/handlers/games/game_details.py index 62cd404..aeb0376 100644 --- a/industry_game/handlers/games/read_by_id_game.py +++ b/industry_game/handlers/games/game_details.py @@ -2,13 +2,13 @@ from aiohttp.web_exceptions import HTTPNotFound from aiohttp.web_response import Response -from industry_game.utils.http.auth.base import require_authorization +from industry_game.utils.http.auth.base import AuthMixin, require_authorization from industry_game.utils.http.deps import DependenciesMixin from industry_game.utils.http.params import parse_path_param from industry_game.utils.http.response import msgspec_json_response -class ReadByIdGameHandler(View, DependenciesMixin): +class GameDetailsHandler(View, DependenciesMixin, AuthMixin): @require_authorization async def get(self) -> Response: game_id = parse_path_param(self.request, "game_id", int) diff --git a/industry_game/handlers/games/list_game.py b/industry_game/handlers/games/game_list.py similarity index 100% rename from industry_game/handlers/games/list_game.py rename to industry_game/handlers/games/game_list.py diff --git a/industry_game/handlers/games/update_game.py b/industry_game/handlers/games/game_update.py similarity index 100% rename from industry_game/handlers/games/update_game.py rename to industry_game/handlers/games/game_update.py diff --git a/industry_game/handlers/games/lobby/list_lobby.py b/industry_game/handlers/games/lobby/list_lobby.py index 298ed48..6e63d46 100644 --- a/industry_game/handlers/games/lobby/list_lobby.py +++ b/industry_game/handlers/games/lobby/list_lobby.py @@ -1,6 +1,9 @@ from aiohttp.web import HTTPNotFound, Response, View -from industry_game.utils.http.auth.base import AuthMixin, require_authorization +from industry_game.utils.http.auth.base import ( + AuthMixin, + require_admin_authorization, +) from industry_game.utils.http.deps import DependenciesMixin from industry_game.utils.http.params import ( PaginationParamsModel, @@ -11,7 +14,7 @@ class ListGameLobbyHandler(View, DependenciesMixin, AuthMixin): - @require_authorization + @require_admin_authorization async def get(self) -> Response: game_id = parse_path_param(self.request, "game_id", int) game = await self.game_storage.read_by_id(game_id=game_id) diff --git a/industry_game/handlers/ping.py b/industry_game/handlers/ping.py index ac74585..f87cdbc 100644 --- a/industry_game/handlers/ping.py +++ b/industry_game/handlers/ping.py @@ -1,4 +1,3 @@ -import asyncio import logging from http import HTTPStatus @@ -17,7 +16,7 @@ class PingHandler(View, DependenciesMixin): async def get(self) -> Response: try: db = await self._ping() - except asyncio.TimeoutError: + except TimeoutError: db = False deps = { "db": db, diff --git a/industry_game/handlers/players/login_player.py b/industry_game/handlers/players/login_player.py index 5f7d92e..7c488f2 100644 --- a/industry_game/handlers/players/login_player.py +++ b/industry_game/handlers/players/login_player.py @@ -37,4 +37,4 @@ async def parse_player_model(self) -> AuthUserModel: try: return AuthUserModel.model_validate_json(body) except ValidationError: - raise HTTPBadRequest + raise HTTPBadRequest(reason="Incorrect user auth data") diff --git a/industry_game/services/rest.py b/industry_game/services/rest.py index 6e1b435..dee003b 100644 --- a/industry_game/services/rest.py +++ b/industry_game/services/rest.py @@ -11,7 +11,9 @@ from yarl import URL from industry_game.handlers.games.create_game import CreateGameHandler -from industry_game.handlers.games.list_game import ListGameHandler +from industry_game.handlers.games.game_details import GameDetailsHandler +from industry_game.handlers.games.game_list import ListGameHandler +from industry_game.handlers.games.game_update import UpdateGameHandler from industry_game.handlers.games.lobby.add_user_to_lobby import ( AddUserToGameLobbyHandler, ) @@ -22,8 +24,6 @@ from industry_game.handlers.games.lobby.read_lobby import ( ReadGameUserLobbyHandler, ) -from industry_game.handlers.games.read_by_id_game import ReadByIdGameHandler -from industry_game.handlers.games.update_game import UpdateGameHandler from industry_game.handlers.ping import PingHandler from industry_game.handlers.players.list_player import ListPlayerHandler from industry_game.handlers.players.login_player import LoginPlayerHandler @@ -83,7 +83,7 @@ class REST(AIOHTTPService): # game handlers (hdrs.METH_GET, "/api/v1/games/", ListGameHandler), (hdrs.METH_POST, "/api/v1/games/", CreateGameHandler), - (hdrs.METH_GET, "/api/v1/games/{game_id}/", ReadByIdGameHandler), + (hdrs.METH_GET, "/api/v1/games/{game_id}/", GameDetailsHandler), (hdrs.METH_POST, "/api/v1/games/{game_id}/", UpdateGameHandler), # lobby handlers (hdrs.METH_GET, "/api/v1/games/{game_id}/lobby/", ListGameLobbyHandler), diff --git a/industry_game/utils/lobby/models.py b/industry_game/utils/lobby/models.py index c2b681c..892647c 100644 --- a/industry_game/utils/lobby/models.py +++ b/industry_game/utils/lobby/models.py @@ -2,6 +2,7 @@ from industry_game.db.models import UserGameLobby as UserGameLobbyDb from industry_game.utils.pagination import MetaPagination +from industry_game.utils.users.models import ShortUser class Lobby(msgspec.Struct, frozen=True): @@ -18,4 +19,4 @@ def from_model(cls, obj: UserGameLobbyDb) -> "Lobby": class LobbyPagination(msgspec.Struct, frozen=True): meta: MetaPagination - items: list[Lobby] + items: list[ShortUser] diff --git a/industry_game/utils/lobby/storage.py b/industry_game/utils/lobby/storage.py index 5a24593..d0d2069 100644 --- a/industry_game/utils/lobby/storage.py +++ b/industry_game/utils/lobby/storage.py @@ -3,10 +3,12 @@ from sqlalchemy import delete, func, insert, select from sqlalchemy.ext.asyncio import AsyncSession +from industry_game.db.models import User as UserDb from industry_game.db.models import UserGameLobby as UserGameLobbyDb from industry_game.utils.db import AbstractStorage, inject_session from industry_game.utils.lobby.models import Lobby, LobbyPagination from industry_game.utils.pagination import MetaPagination +from industry_game.utils.users.models import ShortUser class LobbyStorage(AbstractStorage): @@ -80,16 +82,17 @@ async def count(self, session: AsyncSession) -> int: @inject_session async def get_items( self, session: AsyncSession, game_id: int, page: int, page_size: int - ) -> list[Lobby]: + ) -> list[ShortUser]: query = ( - select(UserGameLobbyDb) + select(UserDb) + .join(UserGameLobbyDb, UserDb.id == UserGameLobbyDb.user_id) .where(UserGameLobbyDb.game_id == game_id) .limit(page_size) .offset((page - 1) * page_size) ) games = await session.scalars(query) - items: list[Lobby] = [] + items: list[ShortUser] = [] for game in games: - items.append(Lobby.from_model(game)) + items.append(ShortUser.from_model(game)) return items diff --git a/industry_game/utils/timer.py b/industry_game/utils/timer.py index 47683de..cdcffdd 100644 --- a/industry_game/utils/timer.py +++ b/industry_game/utils/timer.py @@ -13,7 +13,12 @@ class Timer: _end_time: float | None _task: asyncio.Task | None - def __init__(self, coroutine: Awaitable, seconds: float, speed: float = 1) -> None: + def __init__( + self, + coroutine: Awaitable, + seconds: float, + speed: float = 1, + ) -> None: self._coroutine = coroutine self._seconds = seconds self._re_seconds = seconds diff --git a/industry_game/utils/users/base.py b/industry_game/utils/users/base.py index 82985aa..8071cf3 100644 --- a/industry_game/utils/users/base.py +++ b/industry_game/utils/users/base.py @@ -1,6 +1,6 @@ from enum import StrEnum -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from industry_game.utils.msgspec import CustomStruct @@ -16,6 +16,8 @@ class RegisterPlayerModel(BaseModel): class AuthUserModel(BaseModel): + model_config = ConfigDict(str_min_length=8) + username: str password: str diff --git a/industry_game/utils/users/storage.py b/industry_game/utils/users/storage.py index 650759c..a028449 100644 --- a/industry_game/utils/users/storage.py +++ b/industry_game/utils/users/storage.py @@ -65,7 +65,6 @@ async def get_by_username_and_password_hash( password_hash: str, ) -> FullUser | None: stmt = select(UserDb).where( - UserDb.type == UserType.PLAYER, UserDb.username == username, UserDb.password_hash == password_hash, ) diff --git a/poetry.lock b/poetry.lock index 54b9ac5..3db21ef 100644 --- a/poetry.lock +++ b/poetry.lock @@ -708,15 +708,33 @@ files = [ {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, ] +[[package]] +name = "factory-boy" +version = "3.3.0" +description = "A versatile test fixtures replacement based on thoughtbot's factory_bot for Ruby." +optional = false +python-versions = ">=3.7" +files = [ + {file = "factory_boy-3.3.0-py2.py3-none-any.whl", hash = "sha256:a2cdbdb63228177aa4f1c52f4b6d83fab2b8623bf602c7dedd7eb83c0f69c04c"}, + {file = "factory_boy-3.3.0.tar.gz", hash = "sha256:bc76d97d1a65bbd9842a6d722882098eb549ec8ee1081f9fb2e8ff29f0c300f1"}, +] + +[package.dependencies] +Faker = ">=0.7.0" + +[package.extras] +dev = ["Django", "Pillow", "SQLAlchemy", "coverage", "flake8", "isort", "mongoengine", "sqlalchemy-utils", "tox", "wheel (>=0.32.0)", "zest.releaser[recommended]"] +doc = ["Sphinx", "sphinx-rtd-theme", "sphinxcontrib-spelling"] + [[package]] name = "faker" -version = "23.2.1" +version = "23.3.0" description = "Faker is a Python package that generates fake data for you." optional = false python-versions = ">=3.8" files = [ - {file = "Faker-23.2.1-py3-none-any.whl", hash = "sha256:0520a6b97e07c658b2798d7140971c1d5bc4bcd5013e7937fe075fd054aa320c"}, - {file = "Faker-23.2.1.tar.gz", hash = "sha256:f07b64d27f67b62c7f0536a72f47813015b3b51cd4664918454011094321e464"}, + {file = "Faker-23.3.0-py3-none-any.whl", hash = "sha256:117ce1a2805c1bc5ca753b3dc6f9d567732893b2294b827d3164261ee8f20267"}, + {file = "Faker-23.3.0.tar.gz", hash = "sha256:458d93580de34403a8dec1e8d5e6be2fee96c4deca63b95d71df7a6a80a690de"}, ] [package.dependencies] @@ -1474,30 +1492,6 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] -[[package]] -name = "polyfactory" -version = "2.14.1" -description = "Mock data generation factories" -optional = false -python-versions = "<4.0,>=3.8" -files = [ - {file = "polyfactory-2.14.1-py3-none-any.whl", hash = "sha256:8aff3be75e046501ec5c411c78c23db284322c760fef50d560ee6ed683f217c8"}, - {file = "polyfactory-2.14.1.tar.gz", hash = "sha256:8c1d5f15dad1ebfd0845d65d4a55f9791cddfa6b3096ad9f9e2fd02a4804631b"}, -] - -[package.dependencies] -faker = "*" -typing-extensions = ">=4.6.0" - -[package.extras] -attrs = ["attrs (>=22.2.0)"] -beanie = ["beanie", "pydantic[email]"] -full = ["attrs", "beanie", "msgspec", "odmantic", "pydantic", "sqlalchemy"] -msgspec = ["msgspec"] -odmantic = ["odmantic (<1.0.0)", "pydantic[email]"] -pydantic = ["pydantic[email]"] -sqlalchemy = ["sqlalchemy (>=1.4.29)"] - [[package]] name = "pre-commit" version = "3.6.0" @@ -1760,13 +1754,13 @@ testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "no [[package]] name = "python-dateutil" -version = "2.8.2" +version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" files = [ - {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, - {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, ] [package.dependencies] @@ -2328,4 +2322,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "91a1636d2a583b638fe7b8fceb4bd392c97b389418efd298eb4538af0da8a884" +content-hash = "d5520e2070c3ef1234bbe8f7dc217e1384e5bd95f91947092479ae5efb5fa3e1" diff --git a/pyproject.toml b/pyproject.toml index b6104be..8d873d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ pre-commit = "^3.6.0" bandit = "^1.7.7" ruff = "^0.2.0" aiomisc-pytest = "^1.1.1" -polyfactory = "^2.14.1" +factory-boy = "^3.3.0" [build-system] requires = ["poetry-core"] diff --git a/tests/plugins/factories/games.py b/tests/plugins/factories/games.py index 37eb0d4..5d54562 100644 --- a/tests/plugins/factories/games.py +++ b/tests/plugins/factories/games.py @@ -1,32 +1,31 @@ from collections.abc import Callable -from datetime import UTC, datetime +import factory import pytest -from polyfactory.factories.sqlalchemy_factory import SQLAlchemyFactory from sqlalchemy.ext.asyncio import AsyncSession -from industry_game.db.models import Game, User +from industry_game.db.models import Game +from industry_game.utils.users.base import UserType +from tests.plugins.factories.users import UserFactory -def utc_now() -> datetime: - return datetime.now(tz=UTC) +class GameFactory(factory.Factory): + class Meta: + model = Game - -class GameFactory(SQLAlchemyFactory[Game]): - __set_primary_key__ = False - __set_foreign_keys__ = False - __use_defaults__ = True - - created_at = utc_now - updated_at = utc_now + id = factory.Sequence(lambda n: n + 1) + name = "New game" + description = "New game description" finished_at = None started_at = None + created_by = factory.SubFactory(UserFactory, type=UserType.ADMIN) + @pytest.fixture def create_game(session: AsyncSession) -> Callable: - async def factory(created_by: User, **kwargs) -> Game: - game = GameFactory.build(created_by=created_by, **kwargs) + async def factory(**kwargs) -> Game: + game = GameFactory(**kwargs) session.add(game) await session.commit() await session.flush(game) diff --git a/tests/plugins/factories/users.py b/tests/plugins/factories/users.py index b470f5e..b2b704d 100644 --- a/tests/plugins/factories/users.py +++ b/tests/plugins/factories/users.py @@ -1,33 +1,41 @@ from collections.abc import Callable -from typing import Any +import factory import pytest -from polyfactory.factories.sqlalchemy_factory import SQLAlchemyFactory from sqlalchemy.ext.asyncio import AsyncSession from industry_game.db.models import User from industry_game.utils.security import Passgen +from industry_game.utils.users.base import UserType -def empty_properties() -> dict[str, Any]: - return dict() +class UserPropertiesFactory(factory.Factory): + class Meta: + model = dict + name = "First Last Name" + telegram = "@tg_username" -class UserFactory(SQLAlchemyFactory[User]): - __set_primary_key__ = False - properties = empty_properties +class UserFactory(factory.Factory): + class Meta: + model = User + + id = factory.Sequence(lambda n: n + 1) + username = "username" + type = UserType.PLAYER + password_hash = "" + properties = factory.SubFactory(UserPropertiesFactory) @pytest.fixture def create_user(session: AsyncSession, passgen: Passgen) -> Callable: async def factory(**kwargs) -> User: - password = "secret" + password = kwargs.get("password", "secret00") if "password" in kwargs: - password = kwargs["password"] del kwargs["password"] - password_hash = passgen.hashpw(password) - user = UserFactory.build(**kwargs, password_hash=password_hash) + kwargs["password_hash"] = passgen.hashpw(password) + user = UserFactory(**kwargs) session.add(user) await session.commit() await session.flush(user) diff --git a/tests/plugins/jwt_auth.py b/tests/plugins/jwt_auth.py index 79701a8..cbb6bb0 100644 --- a/tests/plugins/jwt_auth.py +++ b/tests/plugins/jwt_auth.py @@ -1,6 +1,9 @@ +from collections.abc import Callable + import pytest from cryptography.hazmat.primitives.asymmetric import rsa +from industry_game.db.models import User from industry_game.utils.http.auth.jwt import ( JwtAuthrorizationProvider, JwtProcessor, @@ -52,3 +55,17 @@ def admin_token(admin: AuthUser, jwt_processor: JwtProcessor) -> str: @pytest.fixture def player_token(player: AuthUser, jwt_processor: JwtProcessor) -> str: return jwt_processor.encode(player.to_dict()) + + +@pytest.fixture +def token_from_user(jwt_processor: JwtProcessor) -> Callable: + def _factory(user: User) -> str: + return jwt_processor.encode( + { + "id": user.id, + "username": user.username, + "type": user.type, + } + ) + + return _factory diff --git a/tests/plugins/rest.py b/tests/plugins/rest.py index cdaf66e..72c0817 100644 --- a/tests/plugins/rest.py +++ b/tests/plugins/rest.py @@ -1,4 +1,3 @@ -from argparse import Namespace from collections.abc import AsyncIterator, Mapping, Sequence import pytest diff --git a/tests/plugins/users.py b/tests/plugins/users.py index 9d2418c..ad0cc7c 100644 --- a/tests/plugins/users.py +++ b/tests/plugins/users.py @@ -15,6 +15,7 @@ def player_storage( ) -> PlayerStorage: return PlayerStorage(session_factory=session_factory) + @pytest.fixture def player_processor( player_storage: PlayerStorage, @@ -27,6 +28,7 @@ def player_processor( authorization_provider=authorization_provider, ) + @pytest.fixture def admin() -> AuthUser: return AuthUser( diff --git a/tests/test_api/test_games/test_game_details.py b/tests/test_api/test_games/test_game_details.py new file mode 100644 index 0000000..6e89903 --- /dev/null +++ b/tests/test_api/test_games/test_game_details.py @@ -0,0 +1,76 @@ +from collections.abc import Mapping +from http import HTTPStatus + +from aiohttp.test_utils import TestClient +from yarl import URL + +from tests.utils.datetime import format_tz + +API_URL = URL("/api/v1/games/1/") + + +async def test_game_details_unauthorized(api_client: TestClient): + response = await api_client.get(API_URL) + assert response.status == HTTPStatus.UNAUTHORIZED + + +async def test_game_details_player_status_ok( + api_client: TestClient, + player_headers: Mapping[str, str], + create_game, +): + await create_game(id=1) + response = await api_client.get(API_URL, headers=player_headers) + assert response.status == HTTPStatus.OK + + +async def test_game_details_player_format( + api_client: TestClient, + player_headers: Mapping[str, str], + create_game, +): + game = await create_game(id=1) + response = await api_client.get(API_URL, headers=player_headers) + result = await response.json() + assert result == { + "id": game.id, + "name": game.name, + "description": game.description, + "status": game.status.value, + "created_by_id": game.created_by_id, + "finished_at": format_tz(game.finished_at), + "started_at": format_tz(game.started_at), + "created_at": format_tz(game.created_at), + "updated_at": format_tz(game.updated_at), + } + + +async def test_game_details_admin_status_ok( + api_client: TestClient, + admin_headers: Mapping[str, str], + create_game, +): + await create_game(id=1) + response = await api_client.get(API_URL, headers=admin_headers) + assert response.status == HTTPStatus.OK + + +async def test_game_details_admin_format( + api_client: TestClient, + admin_headers: Mapping[str, str], + create_game, +): + game = await create_game(id=1) + response = await api_client.get(API_URL, headers=admin_headers) + result = await response.json() + assert result == { + "id": game.id, + "name": game.name, + "description": game.description, + "status": game.status.value, + "created_by_id": game.created_by_id, + "finished_at": format_tz(game.finished_at), + "started_at": format_tz(game.started_at), + "created_at": format_tz(game.created_at), + "updated_at": format_tz(game.updated_at), + } diff --git a/tests/test_api/test_games/test_games_list.py b/tests/test_api/test_games/test_game_list.py similarity index 97% rename from tests/test_api/test_games/test_games_list.py rename to tests/test_api/test_games/test_game_list.py index 0080da4..b9facf6 100644 --- a/tests/test_api/test_games/test_games_list.py +++ b/tests/test_api/test_games/test_game_list.py @@ -11,7 +11,7 @@ API_URL = URL("/api/v1/games/") -async def test_games_list_unauthorized(api_client: TestClient) -> None: +async def test_games_list_unauthorized(api_client: TestClient): response = await api_client.get(API_URL) assert response.status == HTTPStatus.UNAUTHORIZED @@ -19,7 +19,7 @@ async def test_games_list_unauthorized(api_client: TestClient) -> None: async def test_games_list_players_status_ok( api_client: TestClient, player_headers: Mapping[str, str], -) -> None: +): response = await api_client.get(API_URL, headers=player_headers) assert response.status == HTTPStatus.OK diff --git a/tests/test_api/test_players/test_login_player.py b/tests/test_api/test_players/test_login_player.py new file mode 100644 index 0000000..fc31658 --- /dev/null +++ b/tests/test_api/test_players/test_login_player.py @@ -0,0 +1,80 @@ +from http import HTTPStatus + +import pytest +from aiohttp.test_utils import TestClient +from yarl import URL + +from industry_game.utils.http.auth.jwt import AUTH_COOKIE +from industry_game.utils.users.base import UserType + +API_URL = URL("/api/v1/players/login/") + + +async def test_login_empty_json_error(api_client: TestClient): + response = await api_client.post(API_URL) + assert response.status == HTTPStatus.BAD_REQUEST + + +async def test_login_user_not_found_error(api_client: TestClient): + response = await api_client.post( + API_URL, + json={ + "username": "new_user", + "password": "password", + }, + ) + assert response.status == HTTPStatus.NOT_FOUND + + +@pytest.mark.parametrize("user_type", (UserType.PLAYER, UserType.ADMIN)) +async def test_login_player_successful_status_ok( + api_client: TestClient, create_user, user_type +): + player = await create_user(type=user_type) + response = await api_client.post( + API_URL, + json={ + "username": player.username, + "password": "secret00", + }, + ) + assert response.status == HTTPStatus.OK + + +@pytest.mark.parametrize("user_type", (UserType.PLAYER, UserType.ADMIN)) +async def test_login_player_successful_format( + api_client: TestClient, + token_from_user, + create_user, + user_type, +): + player = await create_user(type=user_type) + response = await api_client.post( + API_URL, + json={ + "username": player.username, + "password": "secret00", + }, + ) + + result = await response.json() + assert result == {"token": token_from_user(player)} + + +@pytest.mark.parametrize("user_type", (UserType.PLAYER, UserType.ADMIN)) +async def test_login_player_successful_set_cookie( + api_client: TestClient, + token_from_user, + create_user, + user_type, +): + user = await create_user(type=user_type) + response = await api_client.post( + API_URL, + json={ + "username": user.username, + "password": "secret00", + }, + ) + + assert response.cookies[AUTH_COOKIE].value == token_from_user(user)