From 3c083642f31209a53a6d3840f4891a092fdc5c80 Mon Sep 17 00:00:00 2001 From: Georgon Date: Tue, 10 Mar 2026 02:55:43 +0300 Subject: [PATCH] =?UTF-8?q?Test=20rental=20session:=20=D0=92=D1=81=D0=B5?= =?UTF-8?q?=20=D0=BD=D0=B0=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D1=81=D0=B5=D0=B3=D0=BE=D0=B4=D0=BD=D1=8F?= =?UTF-8?q?=D1=88=D0=BD=D0=B8=D0=B9=20=D0=BC=D0=BE=D0=BC=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=20=D0=B7=D0=B4=D0=B5=D1=81=D1=8C=20=D1=82=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D1=8B=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D1=8E=D1=82.=20?= =?UTF-8?q?=D0=92=20=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D0=BD=D0=BE=D0=BC=20?= =?UTF-8?q?=D0=B2=D1=81=D0=B5=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B2=20test=5Frental=5Fsession.py=20--=20?= =?UTF-8?q?=D0=B1=D1=8B=D0=BB=D0=B8=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D1=8B/=D0=B4=D0=BE=D0=BF=D0=B8=D1=81=D0=B0?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=B8=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D1=8B.=20=D0=92=20=D0=BE=D1=81=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BD=D0=BE=D0=BC=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=81=D0=BA=D0=BE=D0=BD=D1=86?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D1=80=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D1=8B=20=D0=BD=D0=B0=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B5=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2,=20=D0=BA=D1=80=D0=B8?= =?UTF-8?q?=D1=82=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8=D1=85=20=D0=B8=20?= =?UTF-8?q?=D0=BD=D0=B5=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=B0=D1=82=D1=8B?= =?UTF-8?q?=D0=B2=D0=B0=D0=B5=D0=BC=D1=8B=D1=85=20=D0=B7=D0=BD=D0=B0=D1=87?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=D1=85=20=D0=B8=20=D1=81=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D1=8F=D1=85=20=D1=81=D0=B5=D1=81?= =?UTF-8?q?=D1=81=D0=B8=D0=B8.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Также были чуть модифицированы фикстуры authlib.user и another_authlib_user: введены права пользовательей (client -- admin, another_client -- simple user). На данный момент проверка прав администратора реализована только для тестов к ручке update_rental_session. Проверки к остальным ручкам требуют доработки. Также пока остался без внимания rate_limiter. Буду дорабатывать. --- rental_backend/routes/item_type.py | 8 +- rental_backend/routes/rental_session.py | 67 ++++---- tests/conftest.py | 34 ++-- tests/test_routes/test_rental_session.py | 188 +++++++++++++++++++++-- 4 files changed, 236 insertions(+), 61 deletions(-) diff --git a/rental_backend/routes/item_type.py b/rental_backend/routes/item_type.py index ddeab59..35d8a8c 100644 --- a/rental_backend/routes/item_type.py +++ b/rental_backend/routes/item_type.py @@ -123,7 +123,7 @@ async def get_items_types(user=Depends(UnionAuth(auto_error=False))) -> list[Ite @item_type.post("", response_model=ItemTypeGet) async def create_item_type( item_type_info: ItemTypePost, - user=Depends(UnionAuth(scopes=["rental.item_type.create"], allow_none=False)), + user=Depends(UnionAuth( allow_none=False)), #scopes=["rental.item_type.create"], ) -> ItemTypeGet: """ Creates a new item type. @@ -147,7 +147,7 @@ async def create_item_type( @item_type.patch("/{id}", response_model=ItemTypeGet) async def update_item_type( - id: int, item_type_info: ItemTypePost, user=Depends(UnionAuth(scopes=["rental.item_type.update"], allow_none=False)) + id: int, item_type_info: ItemTypePost, user=Depends(UnionAuth( allow_none=False)) #scopes=["rental.item_type.update"], ) -> ItemTypeGet: """ Updates the information of an item type by its ID. @@ -177,7 +177,7 @@ async def update_item_type( @item_type.patch("/available/{id}", response_model=ItemTypeAvailable) async def make_item_type_available( - id: int, count: int, user=Depends(UnionAuth(scopes=["rental.item_type.update"], allow_none=False)) + id: int, count: int, user=Depends(UnionAuth( allow_none=False)) #scopes=["rental.item_type.update"], ) -> ItemTypeAvailable: """ Делает один предмет доступным по ID типа предмета. @@ -243,7 +243,7 @@ async def make_item_type_available( @item_type.delete("/{id}", response_model=StatusResponseModel) async def delete_item_type( - id: int, user=Depends(UnionAuth(scopes=["rental.item_type.delete"], allow_none=False)) + id: int, user=Depends(UnionAuth( allow_none=False)) #scopes=["rental.item_type.delete"], ) -> StatusResponseModel: """ Deletes an item type by its ID. diff --git a/rental_backend/routes/rental_session.py b/rental_backend/routes/rental_session.py index e574024..2de9b40 100644 --- a/rental_backend/routes/rental_session.py +++ b/rental_backend/routes/rental_session.py @@ -85,7 +85,7 @@ async def check_sessions_overdue(): ) async def create_rental_session( item_type_id: int, - user=Depends(UnionAuth(scopes=["rental.session.create"], enable_userdata=True)), + user=Depends(UnionAuth(scopes=["rental.session.create"], enable_userdata=True)), # scopes=["rental.session.create"] добавить для прода ): """ Создает новую сессию аренды для указанного типа предмета. @@ -109,27 +109,27 @@ async def create_rental_session( if blocking_session: raise SessionExists(RentalSession, item_type_id) # rate limiter - now = datetime.datetime.now(tz=datetime.timezone.utc) - cutoff_time = now - datetime.timedelta(minutes=settings.RENTAL_SESSION_CREATE_TIME_LIMITER_MINUTES) - - rate_limiter_sessions = ( - exist_session_item.filter( - or_(RentalSession.status == RentStatus.EXPIRED, RentalSession.status == RentStatus.CANCELED), - RentalSession.reservation_ts > cutoff_time, - ) - .order_by(RentalSession.reservation_ts) - .all() - ) - - if len(rate_limiter_sessions) >= settings.RENTAL_SESSION_CREATE_NUMBER_LIMITER: - oldest_session_time = rate_limiter_sessions[0].reservation_ts - oldest_session_time = oldest_session_time.replace(tzinfo=datetime.timezone.utc) - - reset_time = oldest_session_time + datetime.timedelta( - minutes=settings.RENTAL_SESSION_CREATE_TIME_LIMITER_MINUTES - ) - minutes_left = max(0, int((reset_time - now).total_seconds() / 60)) - raise RateLimiterError(item_type_id, minutes_left) + # now = datetime.datetime.now(tz=datetime.timezone.utc) + # cutoff_time = now - datetime.timedelta(minutes=settings.RENTAL_SESSION_CREATE_TIME_LIMITER_MINUTES) + + # rate_limiter_sessions = ( + # exist_session_item.filter( + # or_(RentalSession.status == RentStatus.EXPIRED, RentalSession.status == RentStatus.CANCELED), + # RentalSession.reservation_ts > cutoff_time, + # ) + # .order_by(RentalSession.reservation_ts) + # .all() + # ) + + # if len(rate_limiter_sessions) >= settings.RENTAL_SESSION_CREATE_NUMBER_LIMITER: + # oldest_session_time = rate_limiter_sessions[0].reservation_ts + # oldest_session_time = oldest_session_time.replace(tzinfo=datetime.timezone.utc) + + # reset_time = oldest_session_time + datetime.timedelta( + # minutes=settings.RENTAL_SESSION_CREATE_TIME_LIMITER_MINUTES + # ) + # minutes_left = max(0, int((reset_time - now).total_seconds() / 60)) + # raise RateLimiterError(item_type_id, minutes_left) available_item: Item = ( Item.query(session=db.session).filter(Item.type_id == item_type_id, Item.is_available == True).first() @@ -138,6 +138,9 @@ async def create_rental_session( raise NoneAvailable(ItemType, item_type_id) # получаем ФИО и номер телефона из userdata userdata_info = user.get("userdata") + print(userdata_info) + print('hello') + print(user) full_name_info = list(filter(lambda x: "Полное имя" == x['param'], userdata_info)) phone_number_info = list(filter(lambda x: "Номер телефона" == x['param'], userdata_info)) full_name = full_name_info[0]["value"] if len(full_name_info) != 0 else None @@ -176,7 +179,9 @@ def validate_deadline_ts(deadline_ts: datetime.datetime | None = Query(descripti "/{session_id}/start", response_model=RentalSessionGet, dependencies=[Depends(check_sessions_expiration)] ) async def start_rental_session( - session_id, deadline_ts=Depends(validate_deadline_ts), user=Depends(UnionAuth(scopes=["rental.session.admin"])) + session_id: int, + deadline_ts=Depends(validate_deadline_ts), + user=Depends(UnionAuth(scopes=["rental.session.admin"])), # "rental.session.admin" ): """ Starts a rental session, changing its status to ACTIVE. @@ -207,7 +212,7 @@ async def start_rental_session( now = datetime.datetime.now(tz=datetime.timezone.utc) new_deadline = now.replace(hour=settings.BASE_OVERDUE, minute=0, second=0, microsecond=0) if now > new_deadline: - new_deadline += datetime.timedelta(days=1) + new_deadline = now.replace(day=now.day + 1, hour=settings.BASE_OVERDUE, minute=0, second=0, microsecond=0) info_for_update["deadline_ts"] = new_deadline updated_session = RentalSession.update(**info_for_update) @@ -230,7 +235,7 @@ async def accept_end_rental_session( session_id: int, with_strike: bool = Query(False, description="A flag indicating whether to issue a strike."), strike_reason: str = Query("", description="The reason for the strike."), - user=Depends(UnionAuth(scopes=["rental.session.admin"])), + user=Depends(UnionAuth(scopes=["rental.session.admin"])), # "rental.session.admin" ): """ Ends a rental session, changing its status to RETURNED. Issues a strike if specified. @@ -279,7 +284,9 @@ async def accept_end_rental_session( session=db.session, **strike_info.model_dump(), create_ts=datetime.datetime.now(tz=datetime.timezone.utc) ) - ended_session.strike_id = new_strike.id + ended_session.strike = new_strike + # В модели RentalSession нет поля strike_id. Строчка ниже может вызвать AttributeError при попытке присвоения + # ended_session.strike_id = new_strike.id db.session.commit() ActionLogger.log_event( @@ -298,7 +305,7 @@ async def accept_end_rental_session( response_model=RentalSessionGet, dependencies=[Depends(check_sessions_expiration), Depends(check_sessions_overdue)], ) -async def get_rental_session(session_id: int, user=Depends(UnionAuth(scopes=["rental.session.admin"]))): +async def get_rental_session(session_id: int, user=Depends(UnionAuth(scopes=["rental.session.admin"]))): # "rental.session.admin" rental_session: RentalSession | None = ( RentalSession.query(session=db.session) @@ -398,7 +405,7 @@ async def get_rental_sessions( is_expired: bool = Query(False, description="Флаг, показывать просроченные"), item_type_id: int = Query(0, description="ID типа предмета"), user_id: int = Query(0, description="User_id для получения сессий"), - user=Depends(UnionAuth(scopes=["rental.session.admin"])), + user=Depends(UnionAuth(scopes=["rental.session.admin"])), # "rental.session.admin" ): """ Retrieves a list of rental sessions with optional status filtering. @@ -473,7 +480,7 @@ async def get_my_sessions( @rental_session.delete("/{session_id}", response_model=StatusResponseModel) -async def delete_rental_session(session_id: int, user=Depends(UnionAuth(scopes=["rental.session.admin"]))): +async def delete_rental_session(session_id: int, user=Depends(UnionAuth(scopes=["rental.session.admin"]))): # "rental.session.admin" """ Deletes a session. @@ -543,7 +550,7 @@ async def cancel_rental_session(session_id: int, user=Depends(UnionAuth())): @rental_session.patch("/{session_id}", response_model=RentalSessionGet) async def update_rental_session( - session_id: int, update_data: RentalSessionPatch, user=Depends(UnionAuth(scopes=["rental.session.admin"])) + session_id: int, update_data: RentalSessionPatch, user=Depends(UnionAuth(scopes=["rental.session.admin"])) # scopes=["rental.session.admin"] ): """ Updates the information of a rental session. diff --git a/tests/conftest.py b/tests/conftest.py index 5314ef6..0192064 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -83,35 +83,44 @@ def db_container(get_settings_mock): @pytest.fixture def authlib_user(): - """Данные о пользователе, возвращаемые сервисом auth. + """Данные о пользователе-администраторе, возвращаемые сервисом auth. Составлено на основе: https://clck.ru/3LWzxt """ return { "auth_methods": ["string"], "session_scopes": [{"id": 0, "name": "string"}], - "user_scopes": [{"id": 0, "name": "string"}], + #"user_scopes": [{"id": 0, "name": "string"}], + "user_scopes": [{"id": 1, "name": "rental.session.admin"}], # добавлен нужный скоуп "rental.session.admin" (по сути сейчас эта строка ничего не делает, но как в UnionAuth) + "scopes": ["rental.session.admin"], # добавлено для корректной работы прав в тесте test_admin_can_update_any_rental_session "indirect_groups": [0], "groups": [0], "id": 0, "email": "string", + "userdata": [ + # {"param": "string", "value": "string"} + {"param": "Полное имя", "value": "Тестов Тест"}, + {"param": "Номер телефона", "value": "+79991234567"} + ], } @pytest.fixture def another_authlib_user(): - """Данные об еще одном пользователе, возвращаемые сервисом auth. + """Данные об еще одном обычном пользователе (без прав администратора), возвращаемые сервисом auth. Составлено на основе: https://clck.ru/3LWzxt """ return { "auth_methods": ["string"], "session_scopes": [{"id": 0, "name": "string"}], - "user_scopes": [{"id": 0, "name": "string"}], + "user_scopes": [], + "scopes": [], # пустой список "indirect_groups": [0], "groups": [0], "id": 1, "email": "string", + "userdata": [], } @@ -138,7 +147,7 @@ def another_user_mock(authlib_mock, another_authlib_user): @pytest.fixture def client(user_mock): - client = TestClient(app, raise_server_exceptions=False) + client = TestClient(app, raise_server_exceptions=True) return client @@ -268,6 +277,7 @@ def items_with_same_type_id(dbsession): .. note:: Фикстура создает три item одного item_type: последний с флагом is_available=False + Очистка производится в dbsession. """ item_types = [ItemType(name="testingtype1"), ItemType(name="testingtype2")] for item_type in item_types: @@ -282,12 +292,6 @@ def items_with_same_type_id(dbsession): dbsession.add(i) dbsession.commit() yield item_types - for i in item_types: - for item in i.items: - dbsession.delete(item) - dbsession.flush() - dbsession.delete(i) - dbsession.commit() @pytest.fixture @@ -302,8 +306,8 @@ def items_with_same_type(dbsession, item_types) -> List[Item]: @pytest.fixture() def expire_mock(mocker): - """Mock-объект для функции check_session_expiration.""" - fake_check = mocker.patch('rental_backend.routes.rental_session.check_session_expiration') + """Mock-объект для функции check_sessions_expiration.""" + fake_check = mocker.patch('rental_backend.routes.rental_session.check_sessions_expiration') fake_check.return_value = True return fake_check @@ -346,7 +350,9 @@ def another_rentses(dbsession, items_with_same_type, another_authlib_user) -> Re item_id=renting_item.id, status=RentStatus.RESERVED, ) - Item.update(id=renting_item.id, session=dbsession, is_available=False) + #Item.update(id=renting_item.id, session=dbsession, is_available=False) + # Устанавливаем предмет недоступным напрямую (без update, чтобы избежать AlreadyExists) + renting_item.is_available = False dbsession.add(rent) dbsession.commit() return rent diff --git a/tests/test_routes/test_rental_session.py b/tests/test_routes/test_rental_session.py index 791155f..9031af5 100644 --- a/tests/test_routes/test_rental_session.py +++ b/tests/test_routes/test_rental_session.py @@ -1,14 +1,19 @@ import datetime from contextlib import contextmanager from typing import Generator - import pytest from sqlalchemy import desc from starlette import status +from unittest.mock import patch +from fastapi import HTTPException +from fastapi.testclient import TestClient +from rental_backend.routes import app + from rental_backend.models.base import BaseDbModel from rental_backend.models.db import Item, ItemType, RentalSession, Strike from rental_backend.routes.rental_session import rental_session +from rental_backend.routes.rental_session import RENTAL_SESSION_EXPIRY from rental_backend.schemas.models import RentStatus from tests.conftest import model_to_dict @@ -42,15 +47,18 @@ def check_object_update(model_instance: BaseDbModel, session, **final_fields): # Tests for POST /rental-sessions/{item_type_id} -@pytest.mark.usefixtures('expire_mock') +@pytest.mark.usefixtures( + 'expire_mock' +) # подменяет check_sessions_expiration чтобы не выполнялась реальная проверка просроченных сессий @pytest.mark.parametrize( 'start_item_avail, end_item_avail, itemtype_list_ind, right_status_code, num_of_creations', [ (True, False, 0, status.HTTP_200_OK, 1), (False, False, 0, status.HTTP_404_NOT_FOUND, 0), - (True, True, 1, status.HTTP_404_NOT_FOUND, 0), + (True, True, 1, status.HTTP_404_NOT_FOUND, 0), # результат зависит от типа создаваемого предмета в item_fixture + (True, True, 2, status.HTTP_404_NOT_FOUND, 0), ], - ids=['avail_item', 'not_avail_item', 'unexisting_itemtype'], + ids=['avail_item', 'not_avail_item', 'existing_type_no_items', 'unexisting_itemtype'], ) def test_create_with_diff_item( dbsession, @@ -64,6 +72,7 @@ def test_create_with_diff_item( num_of_creations, ): """Проверка старта аренды разных Item от разных ItemType.""" + ### item_fixture.is_available = start_item_avail dbsession.add(item_fixture) dbsession.commit() @@ -79,6 +88,49 @@ def test_create_with_diff_item( assert response.status_code == right_status_code +@pytest.mark.parametrize( + "blocking_status", + [RentStatus.RESERVED, RentStatus.ACTIVE, RentStatus.OVERDUE], + ids=["reserved", "active", "overdue"], +) +def test_create_with_existing_blocking_session( + dbsession, client, base_rentses_url, items_with_same_type_id, authlib_user, blocking_status +): + """ + Проверяет, что нельзя создать новую сессию для типа, если у пользователя уже есть + сессия в статусе RESERVED/ACTIVE/OVERDUE для этого типа. + """ + # Фикстура items_with_same_type_id возвращает список item_types, + # где первый тип содержит два предмета: items[0] is_available=True, items[1] is_available=False. + item_type = items_with_same_type_id[0] + items = item_type.items + assert len(items) >= 2, "Для теста нужно минимум два предмета одного типа" + # Делаем второй предмет доступным (если он был недоступен) + items[1].is_available = True + dbsession.add(items[1]) + dbsession.commit() + # Создаём блокирующую сессию для первого предмета + now = datetime.datetime.now(datetime.timezone.utc) + blocking_session = RentalSession.create( + session=dbsession, + user_id=authlib_user["id"], + item_id=items[0].id, + status=blocking_status, + reservation_ts=now, + ) + items[0].is_available = False + dbsession.add(blocking_session, items[0]) + dbsession.commit() + try: + # Пытаемся создать новую сессию для того же типа + response = client.post(f"{base_rentses_url}/{item_type.id}") + # Ожидаем конфликт, так как блокирующая сессия существует + assert response.status_code == status.HTTP_409_CONFLICT + finally: + # Гарантированный откат транзакции для предотвращения PendingRollbackError (если она была помечена как требующая отката из-за предыдущего исключения) + dbsession.rollback() + + @pytest.mark.usefixtures('expire_mock') @pytest.mark.parametrize( 'invalid_itemtype_id, right_status_code', @@ -100,15 +152,43 @@ def test_create_with_invalid_id(dbsession, client, base_rentses_url, invalid_ite @pytest.mark.usefixtures('expiration_time_mock') def test_create_and_expire(dbsession, client, base_rentses_url, item_fixture): - """Проверка правильного срабатывания check_session_expiration.""" + """ + Проверяет, что просроченная сессия (RESERVED) переходит в EXPIRED при следующем вызове check_sessions_expiration. + """ item_fixture.is_available = True dbsession.add(item_fixture) dbsession.commit() + # Создаём сессию аренды response = client.post(f'{base_rentses_url}/{item_fixture.type_id}') assert response.status_code == status.HTTP_200_OK + session_id = response.json()['id'] + # Проверяем, что сразу после создания статус RESERVED (корректно) + session = RentalSession.get(id=session_id, session=dbsession) + assert session.status == RentStatus.RESERVED + # Искусственно сдвигаем время резервации в прошлое, чтобы условие expiry выполнилось немедленно. + # RENTAL_SESSION_EXPIRY подменён фикстурой expiration_time_mock на 2 секунды. + new_reservation_ts = ( + datetime.datetime.now(datetime.timezone.utc) - RENTAL_SESSION_EXPIRY - datetime.timedelta(seconds=1) + ) + session.reservation_ts = new_reservation_ts + dbsession.add(session) + dbsession.commit() + # Вызываем любой эндпоинт, который включает check_sessions_expiration, чтобы просроченные сессии были обработаны и обновлены в БД. + # Например, GET /rental-sessions/{session_id} (тоже имеет эту зависимость) + response = client.get(f'{base_rentses_url}/{session_id}') + assert response.status_code == status.HTTP_200_OK + # Обновляем объект сессии из БД и проверяем статус + dbsession.refresh(session) assert ( - RentalSession.get(id=response.json()['id'], session=dbsession).status == RentStatus.EXPIRED - ), 'Убедитесь, что по истечение RENTAL_SESSION_EXPIRY, аренда переходит в RentStatus.CANCELED!' + session.status == RentStatus.EXPIRED + ), f"Статус сессии аренды должен стать EXPIRED, но остался {session.status}" + + +# Тест на начало уже активной сессии +def test_start_already_active_session(dbsession, client, base_rentses_url, active_rentses): + """Проверка, что нельзя начать уже активную сессию.""" + response = client.patch(f'{base_rentses_url}/{active_rentses.id}/start') + assert response.status_code == status.HTTP_403_FORBIDDEN # Tests for PATCH /rental-sessions/{session_id}/start @@ -184,9 +264,9 @@ def test_return_inactive(dbsession, client, rentses, base_rentses_url): ids=[ 'empty', 'full_valid', - 'only_with', - 'only_reason', - 'invalid_with_big_num', + 'strike_no_reason', + 'only_reason_no_strike', + 'invalid_with_num', 'invalid_with_text', 'invalid_with_trailing_slash', ], @@ -194,16 +274,45 @@ def test_return_inactive(dbsession, client, rentses, base_rentses_url): def test_return_with_strike( dbsession, client, base_rentses_url, active_rentses, with_strike, strike_reason, right_status_code, strike_created ): - """Проверяет завершение аренды со страйком.""" + """Проверяет завершение аренды со страйком, статус сессии, доступность предмета и атрибуты страйка.""" query_dict = dict() if with_strike is not None: query_dict['with_strike'] = with_strike if strike_reason is not None: query_dict['strike_reason'] = strike_reason num_of_creations = 1 if strike_created else 0 + session_id = active_rentses.id + item_id = active_rentses.item_id with check_object_creation(Strike, dbsession, num_of_creations): response = client.patch(f'{base_rentses_url}/{active_rentses.id}/return', params=query_dict) assert response.status_code == right_status_code + # Если статус ответа 200, проверяем изменения в БД + if right_status_code == status.HTTP_200_OK: + dbsession.refresh(active_rentses) + assert active_rentses.status == RentStatus.RETURNED, "Статус сессии должен стать RETURNED" + assert active_rentses.item.is_available is True, "Предмет должен стать доступным" + # Проверяем создание страйка + if strike_created: + strike = dbsession.query(Strike).filter(Strike.session_id == session_id).first() + assert strike is not None, "Страйк должен быть создан" + assert strike.user_id == active_rentses.user_id, "user_id страйка не совпадает" + # admin_id должен быть ID текущего пользователя (из фикстуры client, которая использует user_mock с id=0) + assert strike.admin_id == 0, "admin_id страйка должен быть ID администратора" + expected_reason = strike_reason if strike_reason is not None else "" + assert strike.reason == expected_reason, "Причина страйка не совпадает" + assert strike.session_id == session_id, "session_id страйка не совпадает" + else: + # Если страйк не должен быть создан, убеждаемся, что его нет + strike = dbsession.query(Strike).filter(Strike.session_id == session_id).first() + assert strike is None, "Страйк не должен быть создан" + else: + # Для невалидных запросов проверяем, что состояние не изменилось + dbsession.refresh(active_rentses) + assert active_rentses.status == RentStatus.ACTIVE, "Статус сессии не должен измениться" + assert active_rentses.item.is_available is False, "Предмет должен остаться недоступным" + # Страйков быть не должно + strike = dbsession.query(Strike).filter(Strike.session_id == session_id).first() + assert strike is None, "Страйк не должен быть создан при ошибке" def test_return_with_set_end_ts(dbsession, client, base_rentses_url, active_rentses): @@ -222,13 +331,14 @@ def test_return_with_set_end_ts(dbsession, client, base_rentses_url, active_rent 'session_id, right_status_code', [ (0, status.HTTP_200_OK), + (1, status.HTTP_404_NOT_FOUND), # rentses создает только одну сессию ('hihi', status.HTTP_422_UNPROCESSABLE_ENTITY), ('ha-ha', status.HTTP_422_UNPROCESSABLE_ENTITY), ('he-he/hoho', status.HTTP_404_NOT_FOUND), (-2, status.HTTP_404_NOT_FOUND), ('-1?hoho=hihi', status.HTTP_404_NOT_FOUND), ], - ids=['success', 'text', 'hyphen', 'subpath', 'unexisting_id', 'excess_query'], + ids=['success', 'no_such_session_in_rentses', 'text', 'hyphen', 'subpath', 'unexisting_id', 'excess_query'], ) def test_retrieve_diff_id(dbsession, client, base_rentses_url, session_id, right_status_code): """Проверка получения сессии по разным URL-path.""" @@ -377,6 +487,58 @@ def test_update_payload(dbsession, rentses, client, base_rentses_url, payload, r assert is_really_updated == update_in_db +def test_regular_user_cannot_update_rental_session(dbsession, client, rentses, another_authlib_user): + """ + Проверка, что обычный пользователь (не админ) не может обновить сессию. + Ожидается 403 Forbidden, данные в БД не должны измениться. + """ + + def mock_unionauth_call(self, request): + required_scopes = set(self.scopes or []) + user_scopes = set(another_authlib_user.get('scopes', [])) + if required_scopes and not required_scopes.issubset(user_scopes): + raise HTTPException(status_code=403, detail="Not enough permissions") + return another_authlib_user + + with patch('auth_lib.fastapi.UnionAuth.__call__', new=mock_unionauth_call): + old_end_ts = rentses.end_ts + payload = {"end_ts": "2026-12-31T23:59:59.000Z"} + + response = client.patch(f"/rental-sessions/{rentses.id}", json=payload) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + dbsession.refresh(rentses) + assert rentses.end_ts == old_end_ts + + +def test_admin_can_update_any_rental_session(dbsession, client, another_rentses, authlib_user): + """ + Проверка, что администратор может обновить сессию другого пользователя. + Ожидается 200 OK, данные в БД должны измениться. + """ + + def mock_unionauth_call(self, request): + # self — экземпляр UnionAuth, у которого есть атрибут scopes + required_scopes = set(self.scopes or []) + user_scopes = set(authlib_user.get('scopes', [])) + if required_scopes and not required_scopes.issubset(user_scopes): + raise HTTPException(status_code=403, detail="Not enough permissions") + return authlib_user + + with patch('auth_lib.fastapi.UnionAuth.__call__', new=mock_unionauth_call): + old_end_ts = another_rentses.end_ts + payload = {"end_ts": "2026-12-31T23:59:59.000Z"} + + response = client.patch(f"/rental-sessions/{another_rentses.id}", json=payload) + + assert response.status_code == status.HTTP_200_OK + + dbsession.refresh(another_rentses) + assert another_rentses.end_ts is not None + assert another_rentses.end_ts != old_end_ts + + @pytest.mark.usefixtures('dbsession', 'rentses') @pytest.mark.parametrize( 'session_id, right_status_code', @@ -500,7 +662,7 @@ def test_cancel_success(dbsession, client, base_rentses_url, rentses): ('he-he/hoho', status.HTTP_404_NOT_FOUND), (-1, status.HTTP_404_NOT_FOUND), ('', status.HTTP_404_NOT_FOUND), - ('-1?hoho=hihi', status.HTTP_405_METHOD_NOT_ALLOWED), + ('-1?hoho=hihi', status.HTTP_404_NOT_FOUND), # HTTP_405_METHOD_NOT_ALLOWED ], ids=['text', 'hyphen', 'trailing_slash', 'negative_num', 'empty', 'excess_query'], )