🧪 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 import unittest
from datetime import datetime from datetime import datetime
from types import SimpleNamespace from types import SimpleNamespace
@ -6,6 +9,7 @@ from unittest.mock import patch
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.db.mock_database import MockBase from app.db.mock_database import MockBase
from app.db.mock_models import RentalContract, RentalFine, RentalPayment, RentalVehicle from app.db.mock_models import RentalContract, RentalFine, RentalPayment, RentalVehicle
@ -62,6 +66,17 @@ class RentalServiceTests(unittest.IsolatedAsyncioTestCase):
self.addCleanup(engine.dispose) self.addCleanup(engine.dispose)
return SessionLocal 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( def _create_rental_vehicle(
self, self,
db, db,
@ -256,6 +271,89 @@ class RentalServiceTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(session.added, []) self.assertEqual(session.added, [])
self.assertTrue(session.closed) 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): async def test_registrar_devolucao_aluguel_fecha_contrato_e_libera_veiculo(self):
SessionLocal = self._build_session_local() SessionLocal = self._build_session_local()
db = SessionLocal() db = SessionLocal()

@ -1,10 +1,17 @@
import asyncio
import threading
import time
import unittest import unittest
from datetime import datetime from datetime import datetime
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
from fastapi import HTTPException 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.db.mock_models import ReviewSchedule
from app.services.domain import review_service from app.services.domain import review_service
@ -60,6 +67,17 @@ class ReviewLockingSession:
class ReviewServiceLockingTests(unittest.IsolatedAsyncioTestCase): 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): def test_acquire_review_slot_lock_returns_conflict_when_slot_is_busy(self):
session = ReviewLockingSession(lock_acquired=0) session = ReviewLockingSession(lock_acquired=0)
@ -128,6 +146,89 @@ class ReviewServiceLockingTests(unittest.IsolatedAsyncioTestCase):
self.assertFalse(session.committed) self.assertFalse(session.committed)
self.assertTrue(session.closed) 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__": if __name__ == "__main__":
unittest.main() unittest.main()

Loading…
Cancel
Save