diff --git a/rental_backend/routes/rental_session.py b/rental_backend/routes/rental_session.py index e574024..ab94ca9 100644 --- a/rental_backend/routes/rental_session.py +++ b/rental_backend/routes/rental_session.py @@ -176,7 +176,7 @@ 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. diff --git a/tests/conftest.py b/tests/conftest.py index 5314ef6..dba7c9e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import datetime import importlib import sys from functools import lru_cache @@ -9,12 +10,14 @@ 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.routes.rental_session import RENTAL_SESSION_EXPIRY +from rental_backend.schemas.models import RentStatus from rental_backend.settings import Settings, get_settings @@ -95,6 +98,10 @@ def authlib_user(): "groups": [0], "id": 0, "email": "string", + "userdata": [ + {"param": "Полное имя", "value": "Тестов Тест"}, + {"param": "Номер телефона", "value": "+79991234567"}, + ], } @@ -229,6 +236,22 @@ def item_fixture(dbsession, item_type_fixture): return item +@pytest.fixture +def available_item(dbsession, item_types): + """Создаёт доступный предмет для первого типа.""" + item = Item(type_id=item_types[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): """Фикстура Item. @@ -254,12 +277,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() @@ -290,6 +307,40 @@ def items_with_same_type_id(dbsession): 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]: """Создает 2 Item с одним itemType в БД и возвращает их.""" @@ -303,7 +354,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 +397,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 @@ -367,6 +418,29 @@ def active_rentses(dbsession, item_fixture, authlib_user) -> RentalSession: 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..9aa6aa9 100644 --- a/tests/test_routes/test_rental_session.py +++ b/tests/test_routes/test_rental_session.py @@ -1,13 +1,11 @@ -import datetime from contextlib import contextmanager from typing import Generator import pytest -from sqlalchemy import desc from starlette import status from rental_backend.models.base import BaseDbModel -from rental_backend.models.db import Item, ItemType, RentalSession, Strike +from rental_backend.models.db import Item, RentalSession, Strike from rental_backend.routes.rental_session import rental_session from rental_backend.schemas.models import RentStatus from tests.conftest import model_to_dict @@ -42,41 +40,58 @@ 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', + "case_name, expected_status, should_create, expected_available", [ - (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), + ("available_item", status.HTTP_200_OK, True, False), + ("unavailable_item", status.HTTP_404_NOT_FOUND, False, False), + ("no_items", status.HTTP_404_NOT_FOUND, False, None), + ("nonexistent_type", status.HTTP_404_NOT_FOUND, False, None), ], - ids=['avail_item', 'not_avail_item', 'unexisting_itemtype'], + ids=["available_item", "unavailable_item", "no_items", "nonexistent_type"], ) -def test_create_with_diff_item( +def test_create_rental_session( dbsession, client, - item_fixture, base_rentses_url, - start_item_avail, - end_item_avail, - itemtype_list_ind, - right_status_code, - num_of_creations, + available_item, + item_fixture, + item_type_fixture, + nonexistent_type_id, + case_name, + expected_status, + should_create, + expected_available, ): - """Проверка старта аренды разных 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 - with ( - check_object_creation(RentalSession, dbsession, num_of_creations=num_of_creations), - check_object_update(item_fixture, session=dbsession, is_available=end_item_avail), - ): + if case_name == "available_item": + type_id = available_item.type_id + item = available_item + elif case_name == "unavailable_item": + type_id = item_fixture.type_id + item = item_fixture + elif case_name == "no_items": + type_id = item_type_fixture[1].id + item = None + else: + type_id = nonexistent_type_id + item = None + + with check_object_creation(RentalSession, dbsession, num_of_creations=1 if should_create else 0): + if item is not None and expected_available is not None: + with check_object_update(item, dbsession, is_available=expected_available): + response = client.post(f'{base_rentses_url}/{type_id}') + else: + response = client.post(f'{base_rentses_url}/{type_id}') + + assert response.status_code == expected_status + + +def test_create_rental_session_blocking(dbsession, client, base_rentses_url, blocking_session): + """Попытка создания сессии для предмета с уже созданной сессией с разными статусами.""" + type_id = blocking_session.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_409_CONFLICT @pytest.mark.usefixtures('expire_mock') @@ -99,16 +114,20 @@ 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 +211,15 @@ 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 +228,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 @@ -500,7 +544,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'], )