From 55c6df3130bebce211bacae60e80c0954d4a196c Mon Sep 17 00:00:00 2001 From: FireFading Date: Fri, 14 Apr 2023 16:21:37 +0300 Subject: [PATCH] refactor --- app/models/products.py | 8 ++++ app/routers/rating.py | 24 ++++++++++- app/schemas/users.py | 7 ++-- app/settings.py | 2 +- app/utils/messages.py | 2 + app/utils/password.py | 4 +- tests/conftest.py | 29 ++++++++++---- tests/settings.py | 91 ++++++++++++++++++++++++++---------------- tests/test_accounts.py | 47 +++++++++------------- tests/test_products.py | 14 +++---- tests/test_rating.py | 15 ++++++- 11 files changed, 156 insertions(+), 87 deletions(-) diff --git a/app/models/products.py b/app/models/products.py index d20caf7..c60611d 100644 --- a/app/models/products.py +++ b/app/models/products.py @@ -28,6 +28,14 @@ def upgrade_rating(self, rating: float): self.avg_rating = rating else: self.avg_rating = (self.avg_rating * self.reviews_count + rating) / (self.reviews_count + 1) + self.reviews_count += 1 + + def downgrade_rating(self, rating: float): + if self.reviews_count > 1: + self.avg_rating = (self.avg_rating * self.reviews_count - rating) / (self.reviews_count - 1) + else: + self.avg_rating = 0 + self.reviews_count -= 1 fields = [column.name for column in Product.__table__.columns] diff --git a/app/routers/rating.py b/app/routers/rating.py index f61f0cc..699be7c 100644 --- a/app/routers/rating.py +++ b/app/routers/rating.py @@ -36,7 +36,7 @@ async def get_product_ratings(product_id: uuid.UUID, session: AsyncSession = Dep @router.get( - "/get/avg/{product_id}/", + "/avg/{product_id}/", status_code=status.HTTP_200_OK, summary="Получение среднего рейтинга продукта по guid", ) @@ -65,3 +65,25 @@ async def create_new_ratings( product.upgrade_rating(rating=create_rating.stars) await product.update(session=session) return {"detail": messages.RATING_CREATED} + + +@router.delete("/delete/{product_id}/", status_code=status.HTTP_200_OK, summary="Удаление оценки") +async def delete_rating( + product_id: uuid.UUID, + session: AsyncSession = Depends(get_session), + authorize: AuthJWT = Depends(), + credentials: HTTPAuthorizationCredentials = Security(security), +): + authorize.jwt_required() + email = authorize.get_jwt_subject() + user = await get_user_or_404(email=email, session=session) + if not (rating := await m_Rating.get(session=session, user_id=user.guid, product_id=product_id)): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=messages.RATING_NOT_FOUND, + ) + product = await get_product_or_404(guid=product_id, session=session) + product.downgrade_rating(rating=rating.stars) + await product.update(session=session) + await m_Rating.delete(session=session, instances=rating) + return {"detail": messages.RATING_DELETED} diff --git a/app/schemas/users.py b/app/schemas/users.py index ce97439..da35d0c 100644 --- a/app/schemas/users.py +++ b/app/schemas/users.py @@ -1,7 +1,6 @@ import uuid from app.utils.validators import validate_name, validate_password -from fastapi import HTTPException from pydantic import BaseModel, EmailStr, validator @@ -17,7 +16,7 @@ class CreateUser(Email, BaseModel): password: str @validator("password") - def validate_password(cls, password: str) -> str | HTTPException: + def validate_password(cls, password: str) -> str | ValueError: return validate_password(password=password) @@ -25,7 +24,7 @@ class Name(BaseModel): name: str @validator("name") - def validate_name(cls, name: str | None = None) -> str | None | HTTPException: + def validate_name(cls, name: str | None = None) -> str | None | ValueError: return validate_name(name=name) @@ -48,5 +47,5 @@ class UpdatePassword(BaseModel): confirm_password: str @validator("confirm_password") - def validate_password(cls, confirm_password: str) -> str | HTTPException: + def validate_password(cls, confirm_password: str) -> str | ValueError: return validate_password(password=confirm_password) diff --git a/app/settings.py b/app/settings.py index b8a29a2..0dc00b6 100644 --- a/app/settings.py +++ b/app/settings.py @@ -17,7 +17,7 @@ class Config: class Settings(BaseSettings): - database_url: PostgresDsn = Field(env="DATABASE_URL") + database_url: PostgresDsn | str = Field(env="DATABASE_URL") postgres_db: str = Field(env="POSTGRES_DB") postgres_host: str = Field(env="POSTGRES_HOST") diff --git a/app/utils/messages.py b/app/utils/messages.py index b692e17..44b7ea6 100644 --- a/app/utils/messages.py +++ b/app/utils/messages.py @@ -32,6 +32,8 @@ class Messages: RATING_ALREADY_EXISTS = "Продукт уже оценен данным пользователем" RATING_CREATED = "Оценка продукта принята" + RATING_NOT_FOUND = "Оценка не найдена" + RATING_DELETED = "Оценка удалена" ACCESS_DENIED = "Доступ запрещен" diff --git a/app/utils/password.py b/app/utils/password.py index 0feee49..0e52862 100644 --- a/app/utils/password.py +++ b/app/utils/password.py @@ -1,9 +1,9 @@ import bcrypt -def get_hashed_password(password: str) -> str: +def get_hashed_password(password: str) -> bytes: return bcrypt.hashpw(password.encode(), bcrypt.gensalt()) -def verify_password(password: str, hashed_password: str) -> bool: +def verify_password(password: str, hashed_password: bytes) -> bool: return bcrypt.checkpw(password.encode(), hashed_password) diff --git a/tests/conftest.py b/tests/conftest.py index 61d9f4d..e185f81 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,15 +9,15 @@ from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool -from tests.settings import SQLALCHEMY_DATABASE_URL, Urls, User, test_product +from tests.settings import Urls, create_product_schema, login_credentials_schema, rating, settings engine = create_async_engine( - SQLALCHEMY_DATABASE_URL, + settings.database_url, connect_args={"check_same_thread": False}, poolclass=StaticPool, ) -Session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) +async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) @pytest_asyncio.fixture() @@ -33,7 +33,7 @@ async def app() -> AsyncGenerator: async def db_session(app: FastAPI) -> AsyncGenerator: connection = await engine.connect() transaction = await connection.begin() - session = Session(bind=connection) + session = async_session(bind=connection) yield session await session.close() await transaction.rollback() @@ -41,7 +41,7 @@ async def db_session(app: FastAPI) -> AsyncGenerator: @pytest_asyncio.fixture -async def client(app: FastAPI, db_session: Session) -> AsyncGenerator | TestClient: +async def client(app: FastAPI, db_session: async_session) -> AsyncGenerator | TestClient: async def _get_test_db(): yield db_session @@ -53,13 +53,13 @@ async def _get_test_db(): @pytest_asyncio.fixture async def register_user(client: AsyncGenerator | TestClient, mocker: MockerFixture) -> AsyncGenerator: mocker.patch("app.routers.users.send_mail", return_value=True) - response = client.post(Urls.REGISTER, json={"email": User.EMAIL, "password": User.PASSWORD}) + response = client.post(Urls.REGISTER, json=login_credentials_schema) assert response.status_code == status.HTTP_201_CREATED @pytest_asyncio.fixture async def auth_client(register_user, client: AsyncGenerator | TestClient) -> AsyncGenerator | TestClient: - response = client.post(Urls.LOGIN, json={"email": User.EMAIL, "password": User.PASSWORD}) + response = client.post(Urls.LOGIN, json=login_credentials_schema) assert response.status_code == status.HTTP_200_OK access_token = response.json().get("access_token") client.headers.update({"Authorization": f"Bearer {access_token}"}) @@ -70,5 +70,18 @@ async def auth_client(register_user, client: AsyncGenerator | TestClient) -> Asy async def create_product( auth_client: AsyncGenerator | TestClient, ) -> AsyncGenerator | TestClient: - response = auth_client.post(Urls.CREATE_PRODUCT, json=test_product) + response = auth_client.post(Urls.CREATE_PRODUCT, json=create_product_schema) assert response.status_code == status.HTTP_201_CREATED + + +@pytest_asyncio.fixture +async def create_rating(auth_client: AsyncGenerator | TestClient, create_product) -> AsyncGenerator | TestClient: + response = auth_client.post(Urls.CREATE_RATING, json=rating) + assert response.status_code == status.HTTP_201_CREATED + + +# @pytest_asyncio.fixture +# async def get_product(auth_client: AsyncGenerator | TestClient, create_product) -> AsyncGenerator | TestClient: +# response = auth_client.get(Urls.GET_PRODUCTS) +# assert response.status_code == status.HTTP_200_OK +# result = response.json()[0] diff --git a/tests/settings.py b/tests/settings.py index 87fa758..271d121 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -1,3 +1,4 @@ +import uuid from dataclasses import dataclass from datetime import datetime @@ -8,49 +9,58 @@ load_dotenv(dotenv_path="../") -SQLALCHEMY_DATABASE_URL = "sqlite+aiosqlite://" - @dataclass class Urls: - LOGIN = "/accounts/login/" - REGISTER = "/accounts/register/" - LOGOUT = "/accounts/logout/" - USER_INFO = "/accounts/profile/" + LOGIN: str = "/accounts/login/" + REGISTER: str = "/accounts/register/" + LOGOUT: str = "/accounts/logout/" + USER_INFO: str = "/accounts/profile/" - UPDATE_EMAIL = "/accounts/profile/update/email/" - UPDATE_PHONE = "/accounts/profile/update/phone/" - UPDATE_NAME = "/accounts/profile/update/name/" + UPDATE_EMAIL: str = "/accounts/profile/update/email/" + UPDATE_PHONE: str = "/accounts/profile/update/phone/" + UPDATE_NAME: str = "/accounts/profile/update/name/" - FORGOT_PASSWORD = "/accounts/forgot-password/" - RESET_PASSWORD = "/accounts/reset-password/" - CHANGE_PASSWORD = "/accounts/change-password/" + FORGOT_PASSWORD: str = "/accounts/forgot-password/" + RESET_PASSWORD: str = "/accounts/reset-password/" + CHANGE_PASSWORD: str = "/accounts/change-password/" - DELETE_PROFILE = "/accounts/profile/delete/" + DELETE_PROFILE: str = "/accounts/profile/delete/" - CREATE_PRODUCT = "/products/new/" - GET_PRODUCTS = "/products/get/" - DELETE_PRODUCT = "/products/delete/" + CREATE_PRODUCT: str = "/products/new/" + GET_PRODUCTS: str = "/products/get/" + DELETE_PRODUCT: str = "/products/delete/" - CREATE_RATING = "/products/ratings/new/" - GET_RATINGS = "/products/ratings/get/" + CREATE_RATING: str = "/products/ratings/new/" + GET_RATINGS: str = "/products/ratings/get/" + GET_AVG_RATING: str = "/products/ratings/avg/" + DELETE_RATING: str = "/products/ratings/delete/" @dataclass class User: - EMAIL = "test@mail.ru" - NEW_EMAIL = "new_test@mail.ru" - WRONG_EMAIL = "wrong_test@mail.ru" + EMAIL: str = "test@mail.ru" + NEW_EMAIL: str = "new_test@mail.ru" + WRONG_EMAIL: str = "wrong_test@mail.ru" + + PHONE: str | None = None + NEW_PHONE: str = "89101111111" - PHONE = None - NEW_PHONE = "89101111111" + NAME: str | None = None + NEW_NAME: str = "UserName" - NAME = None - NEW_NAME = "UserName" + PASSWORD: str = "Abc123!@#def456$%^" + NEW_PASSWORD: str = "NewAbc123!@#def456$%^" + WRONG_PASSWORD: str = "WrongAbc123!@#def456$%^" - PASSWORD = "Abc123!@#def456$%^" - NEW_PASSWORD = "NewAbc123!@#def456$%^" - WRONG_PASSWORD = "WrongAbc123!@#def456$%^" + +@dataclass +class Product: + GUID: uuid.UUID = uuid.UUID("00000000-0000-0000-0000-000000000000") + NAME: str = "test_product" + DESCRIPTION: str = "test_description" + PRODUCER: str = "test_producer" + PRICE: float = 10000.0 class BaseTestSettings(Settings): @@ -63,13 +73,26 @@ def create_fake_token(expires_in: datetime = datetime(1999, 1, 1), email: str = settings = BaseTestSettings(_env_file=".env.example") +zero_uuid = "00000000-0000-0000-0000-000000000000" +login_credentials_schema = {"email": User.EMAIL, "password": User.PASSWORD} + +change_password_schema = { + "password": User.NEW_PASSWORD, + "confirm_password": User.NEW_PASSWORD, +} + +wrong_change_password_schema = { + "password": User.NEW_PASSWORD, + "confirm_password": User.WRONG_PASSWORD, +} -test_product = { - "name": "test_product", - "description": "test_description", - "producer": "test_producer", - "price": 10000.0, +create_product_schema = { + "guid": Product.GUID, + "name": Product.NAME, + "description": Product.DESCRIPTION, + "producer": Product.PRODUCER, + "price": Product.PRICE, } -rating = {"stars": 2, "product_id": "00000000-0000-0000-0000-000000000000"} +rating = {"stars": 2, "product_id": Product.GUID} diff --git a/tests/test_accounts.py b/tests/test_accounts.py index 50afdb9..d75bbb7 100644 --- a/tests/test_accounts.py +++ b/tests/test_accounts.py @@ -2,7 +2,14 @@ from app.utils.tokens import create_token from fastapi import status from pytest_mock import MockerFixture -from tests.settings import Urls, User, create_fake_token +from tests.settings import ( + Urls, + User, + change_password_schema, + create_fake_token, + login_credentials_schema, + wrong_change_password_schema, +) class TestRegister: @@ -10,7 +17,7 @@ async def test_register_user(self, client, mocker: MockerFixture): mocker.patch("app.routers.users.send_mail", return_value=True) response = client.post( Urls.REGISTER, - json={"email": User.EMAIL, "password": User.PASSWORD}, + json=login_credentials_schema, ) assert response.status_code == status.HTTP_201_CREATED assert response.json().get("email") == User.EMAIL @@ -19,20 +26,20 @@ async def test_register_user(self, client, mocker: MockerFixture): async def test_failed_repeat_register_user(self, register_user, client): response = client.post( Urls.REGISTER, - json={"email": User.EMAIL, "password": User.PASSWORD}, + json=login_credentials_schema, ) assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.json().get("detail") == messages.USER_ALREADY_EXISTS async def test_login_unregistered_user(self, client): - response = client.post(Urls.LOGIN, json={"email": User.EMAIL, "password": User.PASSWORD}) + response = client.post(Urls.LOGIN, json=login_credentials_schema) assert response.status_code == status.HTTP_404_NOT_FOUND assert response.json().get("detail") == messages.USER_NOT_FOUND class TestLogin: async def test_login_user(self, register_user, client): - response = client.post(Urls.LOGIN, json={"email": User.EMAIL, "password": User.PASSWORD}) + response = client.post(Urls.LOGIN, json=login_credentials_schema) assert response.status_code == status.HTTP_200_OK assert "access_token" in response.json() assert "refresh_token" in response.json() @@ -72,10 +79,7 @@ async def test_user_reset_password(self, register_user, client): reset_password_token = create_token(email=User.EMAIL) response = client.post( f"{Urls.RESET_PASSWORD}{reset_password_token}", - json={ - "password": User.NEW_PASSWORD, - "confirm_password": User.NEW_PASSWORD, - }, + json=change_password_schema, ) assert response.status_code == status.HTTP_202_ACCEPTED assert response.json().get("detail") == messages.PASSWORD_RESET @@ -84,10 +88,7 @@ async def test_user_reset_password_with_invalid_token(self, register_user, clien reset_password_token = create_fake_token() response = client.post( f"{Urls.RESET_PASSWORD}{reset_password_token}", - json={ - "password": User.NEW_PASSWORD, - "confirm_password": User.NEW_PASSWORD, - }, + json=change_password_schema, ) assert response.status_code == status.HTTP_203_NON_AUTHORITATIVE_INFORMATION assert response.json().get("detail") == messages.INVALID_TOKEN @@ -96,10 +97,7 @@ async def test_unregistered_user_reset_password(self, client): reset_password_token = create_token(email=User.EMAIL) response = client.post( f"{Urls.RESET_PASSWORD}{reset_password_token}", - json={ - "password": User.NEW_PASSWORD, - "confirm_password": User.NEW_PASSWORD, - }, + json=change_password_schema, ) assert response.status_code == status.HTTP_404_NOT_FOUND assert response.json().get("detail") == messages.USER_NOT_FOUND @@ -108,10 +106,7 @@ async def test_user_reset_password_not_match_password(self, register_user, clien reset_password_token = create_token(email=User.EMAIL) response = client.post( f"{Urls.RESET_PASSWORD}{reset_password_token}", - json={ - "password": User.NEW_PASSWORD, - "confirm_password": User.WRONG_PASSWORD, - }, + json=wrong_change_password_schema, ) assert response.status_code == status.HTTP_203_NON_AUTHORITATIVE_INFORMATION assert response.json().get("detail") == messages.PASSWORDS_NOT_MATCH @@ -121,10 +116,7 @@ class TestChangePassword: async def test_user_change_password(self, auth_client): response = auth_client.post( Urls.CHANGE_PASSWORD, - json={ - "password": User.NEW_PASSWORD, - "confirm_password": User.NEW_PASSWORD, - }, + json=change_password_schema, ) assert response.status_code == status.HTTP_202_ACCEPTED assert response.json().get("detail") == messages.PASSWORD_UPDATED @@ -132,10 +124,7 @@ async def test_user_change_password(self, auth_client): async def test_user_change_password_not_match(self, auth_client): response = auth_client.post( Urls.CHANGE_PASSWORD, - json={ - "password": User.NEW_PASSWORD, - "confirm_password": User.WRONG_PASSWORD, - }, + json=wrong_change_password_schema, ) assert response.status_code == status.HTTP_203_NON_AUTHORITATIVE_INFORMATION assert response.json().get("detail") == messages.PASSWORDS_NOT_MATCH diff --git a/tests/test_products.py b/tests/test_products.py index 32f7426..60c207d 100644 --- a/tests/test_products.py +++ b/tests/test_products.py @@ -1,25 +1,25 @@ from app.utils.messages import messages from fastapi import status -from tests.settings import Urls, test_product +from tests.settings import Product, Urls, create_product_schema class TestProducts: async def test_not_available_without_auth(self, client): - response = client.post(Urls.CREATE_PRODUCT, json=test_product) + response = client.post(Urls.CREATE_PRODUCT, json=create_product_schema) assert response.status_code == status.HTTP_403_FORBIDDEN async def test_create_product(self, auth_client): - response = auth_client.post(Urls.CREATE_PRODUCT, json=test_product) + response = auth_client.post(Urls.CREATE_PRODUCT, json=create_product_schema) assert response.status_code == status.HTTP_201_CREATED assert response.json().get("detail") == messages.PRODUCT_CREATED response = auth_client.get(Urls.GET_PRODUCTS) assert response.status_code == status.HTTP_200_OK result = response.json()[0] - assert result.get("name") == test_product.get("name") - assert result.get("description") == test_product.get("description") - assert result.get("producer") == test_product.get("producer") - assert result.get("price") == test_product.get("price") + assert result.get("name") == Product.NAME + assert result.get("description") == Product.DESCRIPTION + assert result.get("producer") == Product.PRODUCER + assert result.get("price") == Product.PRICE async def test_delete_product(self, create_product, auth_client): response = auth_client.get(Urls.GET_PRODUCTS) diff --git a/tests/test_rating.py b/tests/test_rating.py index 708d963..e69a15a 100644 --- a/tests/test_rating.py +++ b/tests/test_rating.py @@ -1,6 +1,6 @@ from app.utils.messages import messages from fastapi import status -from tests.settings import Urls, User, rating +from tests.settings import Product, Urls, User, rating class TestRating: @@ -18,3 +18,16 @@ async def test_create_new_rating(self, create_product, auth_client): assert result.get("stars") == rating.get("stars") assert result.get("product_id") == rating.get("product_id") assert result.get("user").get("email") == User.EMAIL + + async def test_delete_rating(self, create_rating, auth_client): + response = auth_client.delete(f"{Urls.DELETE_RATING}{Product.GUID}") + assert response.status_code == status.HTTP_200_OK + assert response.json().get("detail") == messages.RATING_DELETED + + response = auth_client.get(f"{Urls.GET_RATINGS}{Product.GUID}") + assert response.status_code == status.HTTP_200_OK + assert response.json() is None + + response = auth_client.get(f"{Urls.GET_AVG_RATING}{Product.GUID}") + assert response.status_code == status.HTTP_200_OK + assert float(response.json().get("avg_rating")) == 0