From a3525334ad86c8cf3cc41b959837932381562964 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vitor=20Hugo=20Belorio=20Sim=C3=A3o?= Date: Fri, 20 Mar 2026 14:29:01 -0300 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=AA=20test(concurrency):=20cobrir=20co?= =?UTF-8?q?rridas=20de=20locacao=20e=20revisao?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adiciona cenarios de corrida para abertura de locacao e agendamento de revisao, disparando tentativas simultaneas sobre o mesmo recurso critico para validar que apenas uma operacao vence a disputa. Usa sessoes SQLite compartilhadas entre threads e locks de teste controlados para reproduzir contencao real sem alterar a logica de producao. Garante por assercoes de resultado e estado persistido que sobra apenas um contrato de locacao e um agendamento valido apos a concorrencia. --- tests/test_rental_service.py | 98 +++++++++++++++++++++++++++++++++ tests/test_review_service.py | 101 +++++++++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+) diff --git a/tests/test_rental_service.py b/tests/test_rental_service.py index 2f5c630..66f7f14 100644 --- a/tests/test_rental_service.py +++ b/tests/test_rental_service.py @@ -1,3 +1,6 @@ +import asyncio +import threading +import time import unittest from datetime import datetime from types import SimpleNamespace @@ -6,6 +9,7 @@ from unittest.mock import patch from fastapi import HTTPException from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool from app.db.mock_database import MockBase from app.db.mock_models import RentalContract, RentalFine, RentalPayment, RentalVehicle @@ -62,6 +66,17 @@ class RentalServiceTests(unittest.IsolatedAsyncioTestCase): self.addCleanup(engine.dispose) return SessionLocal + def _build_threadsafe_session_local(self): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + MockBase.metadata.create_all(bind=engine) + self.addCleanup(engine.dispose) + return SessionLocal + def _create_rental_vehicle( self, db, @@ -256,6 +271,89 @@ class RentalServiceTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(session.added, []) self.assertTrue(session.closed) + async def test_abrir_locacao_aluguel_allows_single_success_under_race(self): + SessionLocal = self._build_threadsafe_session_local() + db = SessionLocal() + try: + vehicle = self._create_rental_vehicle(db, placa="RAC1E01") + vehicle_id = vehicle.id + finally: + db.close() + + attempts = 4 + start_barrier = threading.Barrier(attempts) + vehicle_lock = threading.Lock() + + def _get_locked_vehicle(db, *, rental_vehicle_id=None, placa=None): + acquired = vehicle_lock.acquire(timeout=2) + if not acquired: + raise AssertionError("failed to acquire rental race lock") + + if not db.info.get("_test_rental_lock_wrapped"): + original_close = db.close + + def close_with_lock_release(): + try: + original_close() + finally: + held_lock = db.info.pop("_test_rental_vehicle_lock", None) + if held_lock and held_lock.locked(): + held_lock.release() + + db.close = close_with_lock_release + db.info["_test_rental_lock_wrapped"] = True + + db.info["_test_rental_vehicle_lock"] = vehicle_lock + time.sleep(0.05) + return rental_service._build_rental_vehicle_query( + db, + rental_vehicle_id=rental_vehicle_id, + placa=placa, + ).first() + + def _sync_open_rental(): + start_barrier.wait(timeout=5) + return asyncio.run( + rental_service.abrir_locacao_aluguel( + rental_vehicle_id=vehicle_id, + data_inicio="17/03/2026 10:00", + data_fim_prevista="20/03/2026 10:00", + ) + ) + + with patch.object(rental_service, "SessionMockLocal", SessionLocal), patch.object( + rental_service, + "_get_rental_vehicle_for_update", + side_effect=_get_locked_vehicle, + ): + results = await asyncio.gather( + *[asyncio.to_thread(_sync_open_rental) for _ in range(attempts)], + return_exceptions=True, + ) + + successes = [result for result in results if isinstance(result, dict)] + conflicts = [ + result + for result in results + if isinstance(result, HTTPException) + and isinstance(result.detail, dict) + and result.detail.get("code") == "rental_vehicle_unavailable" + ] + unexpected = [result for result in results if result not in successes and result not in conflicts] + + self.assertEqual(len(successes), 1) + self.assertEqual(len(conflicts), attempts - 1) + self.assertEqual(unexpected, []) + + db = SessionLocal() + try: + contracts = db.query(RentalContract).all() + stored_vehicle = db.query(RentalVehicle).filter(RentalVehicle.id == vehicle_id).one() + self.assertEqual(len(contracts), 1) + self.assertEqual(stored_vehicle.status, "alugado") + finally: + db.close() + async def test_registrar_devolucao_aluguel_fecha_contrato_e_libera_veiculo(self): SessionLocal = self._build_session_local() db = SessionLocal() diff --git a/tests/test_review_service.py b/tests/test_review_service.py index cb4aaf7..e21863f 100644 --- a/tests/test_review_service.py +++ b/tests/test_review_service.py @@ -1,10 +1,17 @@ +import asyncio +import threading +import time import unittest from datetime import datetime from types import SimpleNamespace from unittest.mock import patch from fastapi import HTTPException +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool +from app.db.mock_database import MockBase from app.db.mock_models import ReviewSchedule from app.services.domain import review_service @@ -60,6 +67,17 @@ class ReviewLockingSession: class ReviewServiceLockingTests(unittest.IsolatedAsyncioTestCase): + def _build_threadsafe_session_local(self): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + MockBase.metadata.create_all(bind=engine) + self.addCleanup(engine.dispose) + return SessionLocal + def test_acquire_review_slot_lock_returns_conflict_when_slot_is_busy(self): session = ReviewLockingSession(lock_acquired=0) @@ -128,6 +146,89 @@ class ReviewServiceLockingTests(unittest.IsolatedAsyncioTestCase): self.assertFalse(session.committed) self.assertTrue(session.closed) + async def test_agendar_revisao_allows_single_success_under_race(self): + SessionLocal = self._build_threadsafe_session_local() + attempts = 4 + start_barrier = threading.Barrier(attempts) + slot_locks: dict[str, threading.Lock] = {} + slot_locks_guard = threading.Lock() + + def _acquire_slot_lock(db, *, requested_dt, timeout_seconds=5, field_name="data_hora"): + lock_name = review_service._review_slot_lock_name(requested_dt) + with slot_locks_guard: + slot_lock = slot_locks.setdefault(lock_name, threading.Lock()) + acquired = slot_lock.acquire(timeout=timeout_seconds) + if not acquired: + review_service.raise_tool_http_error( + status_code=409, + code="review_slot_busy", + message="Outro atendimento esta finalizando este horario de revisao. Tente novamente.", + retryable=True, + field=field_name, + ) + db.info.setdefault("_test_review_slot_locks", {})[lock_name] = slot_lock + time.sleep(0.05) + return lock_name + + def _release_slot_lock(db, lock_name): + if not lock_name: + return + held_lock = db.info.get("_test_review_slot_locks", {}).pop(lock_name, None) + if held_lock and held_lock.locked(): + held_lock.release() + + def _sync_schedule_review(): + start_barrier.wait(timeout=5) + return asyncio.run( + review_service.agendar_revisao( + placa="ABC1234", + data_hora="18/03/2026 09:00", + modelo="Onix", + ano=2022, + km=15000, + revisao_previa_concessionaria=False, + user_id=7, + ) + ) + + with patch.object(review_service, "SessionMockLocal", SessionLocal), patch.object( + review_service, + "_acquire_review_slot_lock", + side_effect=_acquire_slot_lock, + ), patch.object( + review_service, + "_release_review_slot_lock", + side_effect=_release_slot_lock, + ): + results = await asyncio.gather( + *[asyncio.to_thread(_sync_schedule_review) for _ in range(attempts)], + return_exceptions=True, + ) + + successes = [result for result in results if isinstance(result, dict)] + conflict_codes = {"review_schedule_conflict", "review_slot_busy"} + conflicts = [ + result + for result in results + if isinstance(result, HTTPException) + and isinstance(result.detail, dict) + and result.detail.get("code") in conflict_codes + ] + unexpected = [result for result in results if result not in successes and result not in conflicts] + + self.assertEqual(len(successes), 1) + self.assertEqual(len(conflicts), attempts - 1) + self.assertEqual(unexpected, []) + + db = SessionLocal() + try: + schedules = db.query(ReviewSchedule).all() + self.assertEqual(len(schedules), 1) + self.assertEqual(schedules[0].status, "agendado") + self.assertEqual(schedules[0].placa, "ABC1234") + finally: + db.close() + if __name__ == "__main__": unittest.main()