🧪 test(concurrency): cobrir corridas de locacao e revisao

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.
main
parent c8cff5fc3f
commit a3525334ad

@ -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()

@ -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()

Loading…
Cancel
Save