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