diff --git a/rental_backend/routes/rental_session.py b/rental_backend/routes/rental_session.py index e574024..bc9de1f 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)), ): """ Создает новую сессию аренды для указанного типа предмета. @@ -176,7 +176,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"])) ): """ Starts a rental session, changing its status to ACTIVE. @@ -230,7 +232,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"])) ): """ Ends a rental session, changing its status to RETURNED. Issues a strike if specified. @@ -279,7 +281,7 @@ 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 db.session.commit() ActionLogger.log_event( @@ -398,7 +400,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"])), ): """ Retrieves a list of rental sessions with optional status filtering. @@ -543,7 +545,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"])) ): """ Updates the information of a rental session. diff --git a/tests/conftest.py b/tests/conftest.py index 5314ef6..7797b40 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,18 +4,21 @@ from pathlib import Path from typing import Any, Dict, List +import datetime import pytest from _pytest.monkeypatch import MonkeyPatch from alembic import command from alembic.config import Config as AlembicConfig from fastapi.testclient import TestClient -from sqlalchemy import create_engine +from sqlalchemy import create_engine, func from sqlalchemy.orm import sessionmaker from testcontainers.postgres import PostgresContainer from rental_backend.models.db import * from rental_backend.routes import app from rental_backend.settings import Settings, get_settings +from rental_backend.schemas.models import RentStatus +from rental_backend.routes.rental_session import RENTAL_SESSION_EXPIRY class PostgresConfig: @@ -90,11 +93,16 @@ def authlib_user(): return { "auth_methods": ["string"], "session_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": "Полное имя", "value": "Тестов Тест"}, + {"param": "Номер телефона", "value": "+79991234567"} + ], } @@ -107,7 +115,8 @@ def another_authlib_user(): 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, @@ -228,6 +237,19 @@ def item_fixture(dbsession, item_type_fixture): dbsession.commit() return item +@pytest.fixture +def available_item(dbsession, item_type_fixture): + """Создаёт доступный предмет для первого типа.""" + item = Item(type_id=item_type_fixture[0].id, is_available=True) + dbsession.add(item) + dbsession.commit() + return item + +@pytest.fixture +def nonexistent_type_id(dbsession): + """Возвращает заведомо несуществующий ID типа предмета. (для тестов при создании сессий)""" + max_id = dbsession.query(func.max(ItemType.id)).scalar() or 0 + return max_id + 1 @pytest.fixture() def items_with_types(dbsession): @@ -254,12 +276,6 @@ def items_with_types(dbsession): dbsession.add(i) dbsession.commit() yield items - for i in item_types: - for item in i.items: - dbsession.delete(item) - dbsession.flush() - dbsession.delete(i) - dbsession.commit() @pytest.fixture() @@ -289,6 +305,37 @@ def items_with_same_type_id(dbsession): dbsession.delete(i) dbsession.commit() +@pytest.fixture +def two_available_items_same_type(dbsession, item_types): + """ + Создаёт для два доступных предмета к первому типу из item_types и возвращает тип. + """ + item_type = item_types[0] + items = [ + Item(type_id=item_type.id, is_available=True), + Item(type_id=item_type.id, is_available=True), + ] + dbsession.add_all(items) + dbsession.commit() + return item_type + +@pytest.fixture(params=[RentStatus.RESERVED, RentStatus.ACTIVE, RentStatus.OVERDUE]) +def blocking_session(request, dbsession, two_available_items_same_type, authlib_user): + """Создаёт сессию для первого предмета типа с заданным статусом.""" + item_type = two_available_items_same_type + items = item_type.items + now = datetime.datetime.now(datetime.timezone.utc) + session = RentalSession.create( + session=dbsession, + user_id=authlib_user["id"], + item_id=items[0].id, + status=request.param, + reservation_ts=now, + ) + items[0].is_available = False + dbsession.add(session, items[0]) + dbsession.commit() + return item_type @pytest.fixture def items_with_same_type(dbsession, item_types) -> List[Item]: @@ -303,7 +350,7 @@ 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') + fake_check = mocker.patch('rental_backend.routes.rental_session.check_sessions_expiration') fake_check.return_value = True return fake_check @@ -346,7 +393,7 @@ 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) + renting_item.is_available = False dbsession.add(rent) dbsession.commit() return rent @@ -366,6 +413,26 @@ def active_rentses(dbsession, item_fixture, authlib_user) -> RentalSession: dbsession.commit() return rent +@pytest.fixture +def expired_reserved_session(dbsession, rentses): + """ + Принимает сессию rentses (RESERVED) и сдвигает её reservation_ts в прошлое, чтобы она стала просроченной согласно RENTAL_SESSION_EXPIRY. + Возвращает ID сессии. + """ + now = datetime.datetime.now(datetime.timezone.utc) + past_ts = now - RENTAL_SESSION_EXPIRY - datetime.timedelta(seconds=1) + rentses.reservation_ts = past_ts + dbsession.add(rentses) + dbsession.commit() + return rentses.id + +@pytest.fixture +def active_rentses_with_end_ts(dbsession, active_rentses): + """Возвращает активную сессию с предустановленным end_ts.""" + active_rentses.end_ts = datetime.datetime.now(tz=datetime.timezone.utc) + dbsession.add(active_rentses) + dbsession.commit() + return active_rentses # Utils def model_to_dict(model: BaseDbModel) -> Dict[str, Any]: diff --git a/tests/test_routes/test_rental_session.py b/tests/test_routes/test_rental_session.py index 791155f..709a36e 100644 --- a/tests/test_routes/test_rental_session.py +++ b/tests/test_routes/test_rental_session.py @@ -5,7 +5,8 @@ import pytest from sqlalchemy import desc from starlette import status - +from unittest.mock import patch +from fastapi import HTTPException 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 @@ -42,43 +43,49 @@ 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.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), - ], - ids=['avail_item', 'not_avail_item', 'unexisting_itemtype'], -) -def test_create_with_diff_item( - dbsession, - client, - item_fixture, - base_rentses_url, - start_item_avail, - end_item_avail, - itemtype_list_ind, - right_status_code, - num_of_creations, -): - """Проверка старта аренды разных Item от разных ItemType.""" - item_fixture.is_available = start_item_avail - dbsession.add(item_fixture) - dbsession.commit() - try: - type_id = ItemType.query(session=dbsession).all()[itemtype_list_ind].id - except IndexError: - type_id = ItemType.query(session=dbsession).order_by(desc('id'))[0].id + 1 +def test_create_with_available_item(dbsession, client, base_rentses_url, available_item): + """Тест на успешное создание сессии при доступном предмете.""" with ( - check_object_creation(RentalSession, dbsession, num_of_creations=num_of_creations), - check_object_update(item_fixture, session=dbsession, is_available=end_item_avail), + check_object_creation(RentalSession, dbsession, num_of_creations=1), + check_object_update(available_item, dbsession, is_available=False) ): + response = client.post(f'{base_rentses_url}/{available_item.type_id}') + assert response.status_code == status.HTTP_200_OK + + +def test_create_with_unavailable_item(dbsession, client, base_rentses_url, item_fixture): + """Попытка создания сессии при недоступном предмете.""" + with ( + check_object_creation(RentalSession, dbsession, num_of_creations=0), + check_object_update(item_fixture, dbsession, is_available=False) + ): + response = client.post(f'{base_rentses_url}/{item_fixture.type_id}') + assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_create_with_type_no_items(dbsession, client, base_rentses_url, item_type_fixture): + """Тест на создание сессии аренды, когда тип существует, но не имеет предметов.""" + type_id = item_type_fixture[1].id + with check_object_creation(RentalSession, dbsession, num_of_creations=0): response = client.post(f'{base_rentses_url}/{type_id}') - assert response.status_code == right_status_code + assert response.status_code == status.HTTP_404_NOT_FOUND +def test_create_with_nonexistent_type(dbsession, client, base_rentses_url, nonexistent_type_id): + """Тест на создание сессии при несуществующем типе предмета.""" + with check_object_creation(RentalSession, dbsession, num_of_creations=0): + response = client.post(f'{base_rentses_url}/{nonexistent_type_id}') + assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_create_with_existing_blocking_session(client, base_rentses_url, blocking_session): + """ + Проверяет, что нельзя создать новую сессию для типа, если у пользователя уже есть + сессия в статусе RESERVED/ACTIVE/OVERDUE для этого типа. + """ + response = client.post(f"{base_rentses_url}/{blocking_session.id}") + assert response.status_code == status.HTTP_409_CONFLICT + @pytest.mark.usefixtures('expire_mock') @pytest.mark.parametrize( 'invalid_itemtype_id, right_status_code', @@ -99,16 +106,21 @@ 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.""" - item_fixture.is_available = True - dbsession.add(item_fixture) - dbsession.commit() - response = client.post(f'{base_rentses_url}/{item_fixture.type_id}') +def test_create_and_expire(client, base_rentses_url, expired_reserved_session): + """ + Проверяет, что просроченная сессия (RESERVED) переходит в EXPIRED при следующем вызове check_sessions_expiration. + """ + session_id = expired_reserved_session + response = client.get(f"{base_rentses_url}/{session_id}") assert response.status_code == status.HTTP_200_OK - assert ( - RentalSession.get(id=response.json()['id'], session=dbsession).status == RentStatus.EXPIRED - ), 'Убедитесь, что по истечение RENTAL_SESSION_EXPIRY, аренда переходит в RentStatus.CANCELED!' + assert response.json()["status"] == RentStatus.EXPIRED + + +# Тест на начало уже активной сессии +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 @@ -192,7 +204,7 @@ 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 + dbsession, client, base_rentses_url, active_rentses, authlib_user, with_strike, strike_reason, right_status_code, strike_created ): """Проверяет завершение аренды со страйком.""" query_dict = dict() @@ -201,18 +213,35 @@ def test_return_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 + admin_id = authlib_user["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 - - -def test_return_with_set_end_ts(dbsession, client, base_rentses_url, active_rentses): + dbsession.refresh(active_rentses) + if right_status_code == status.HTTP_200_OK: + assert active_rentses.status == RentStatus.RETURNED + assert active_rentses.item.is_available is True + if strike_created: + strike = active_rentses.strike + assert strike is not None, "Страйк должен быть создан" + assert strike.user_id == active_rentses.user_id + assert strike.admin_id == admin_id + expected_reason = strike_reason if strike_reason is not None else "" + assert strike.reason == expected_reason + assert strike.session_id == session_id + else: + assert active_rentses.strike is None, "Страйк не должен быть создан" + else: + assert active_rentses.status == RentStatus.ACTIVE + assert active_rentses.item.is_available is False + assert active_rentses.strike is None + + +def test_return_with_set_end_ts(dbsession, client, base_rentses_url, active_rentses_with_end_ts): """Проверяет, что при обновлении RentalSession с end_ts не None сохраняется именно существующий, а не создается новый.""" - active_rentses.end_ts = datetime.datetime.now(tz=datetime.timezone.utc) - dbsession.add(active_rentses) - dbsession.commit() - with check_object_update(active_rentses, dbsession, end_ts=active_rentses.end_ts): - response = client.patch(f'{base_rentses_url}/{active_rentses.id}/return') + with check_object_update(active_rentses_with_end_ts, dbsession, end_ts=active_rentses_with_end_ts.end_ts): + response = client.patch(f'{base_rentses_url}/{active_rentses_with_end_ts.id}/return') assert response.status_code == status.HTTP_200_OK @@ -222,6 +251,7 @@ 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), ('hihi', status.HTTP_422_UNPROCESSABLE_ENTITY), ('ha-ha', status.HTTP_422_UNPROCESSABLE_ENTITY), ('he-he/hoho', status.HTTP_404_NOT_FOUND), @@ -377,6 +407,49 @@ 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"} + with check_object_update(rentses, dbsession, end_ts=old_end_ts): + response = client.patch(f"/rental-sessions/{rentses.id}", json=payload) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_admin_can_update_any_rental_session(dbsession, client, another_rentses, authlib_user): + """ + Проверка, что администратор может обновить сессию другого пользователя. + Данные в БД должны измениться. + """ + + def mock_unionauth_call(self, request): + 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): + payload = {"end_ts": "2026-12-31T23:59:59.000Z"} + # Преобразуем строку в naive datetime (убираем временную зону) + expected_end_ts = datetime.datetime.fromisoformat(payload["end_ts"].replace('Z', '+00:00')).replace(tzinfo=None) + with check_object_update(another_rentses, dbsession, end_ts=expected_end_ts): + response = client.patch(f"/rental-sessions/{another_rentses.id}", json=payload) + assert response.status_code == status.HTTP_200_OK + + @pytest.mark.usefixtures('dbsession', 'rentses') @pytest.mark.parametrize( 'session_id, right_status_code', @@ -500,7 +573,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), ], ids=['text', 'hyphen', 'trailing_slash', 'negative_num', 'empty', 'excess_query'], )