test: force the concurrent-upload race deterministically with asyncio.Event

Plain asyncio.gather on one event loop could let the first upload commit
before the second's pre-check, making the second a legitimate replace (2
successes, 1 live row) -- correct behavior that fails the '1 conflict'
assertion. Same Event-synchronization pattern as the auto's HTTP race tests:
hold the first past its pre-check until the second also pre-checks, so both
read None before either writes and the partial-unique index decides
deterministically. Code under test untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jonatanritter
2026-08-08 02:15:16 -03:00
co-authored by Claude Opus 4.8
parent fb2b8ce372
commit 39ba81efad
+42 -2
View File
@@ -345,17 +345,57 @@ async def test_missing_api_key_is_401(db_session):
@pytest.mark.asyncio
async def test_concurrent_uploads_for_same_product_branch_only_one_wins_the_other_gets_409(
test_engine, db_session
test_engine, db_session, monkeypatch
):
"""Mesma prova do auto (`test_concurrent_uploads_for_same_branch_only_
one_wins_the_other_gets_409`): duas `AsyncSession` distintas contra o
MESMO `test_engine`, disparadas via `asyncio.gather` -- concorrência
REAL, não simulada."""
REAL, não simulada.
Sem sincronização explícita, as duas corrotinas rodam no MESMO event
loop e podem interleavear de um jeito que NÃO exercita a corrida real:
se a primeira `upload_certificate` COMMITA inteiro antes de a segunda
fazer o pre-check `_get_live_certificate_by_branch`, a segunda enxerga a
linha viva da primeira e faz um REPLACE LEGÍTIMO (soft-delete + insert)
-- 2 sucessos, 1 linha viva, comportamento CORRETO do serviço, mas que
quebraria a asserção abaixo (que exige exatamente 1 sucesso + 1
conflito). Mesma técnica de sincronização determinística de
`auto/backend/tests/modules/financeiro/test_pay_account_payable.py::
test_pay_concurrent_with_cancel_via_http_lock_serializes_the_race`:
monkeypatch no ponto de await entre o pre-check e o commit, com um
`asyncio.Event`, para FORÇAR a janela vulnerável -- as duas chamadas
fazem o pre-check (ambas leem `None`) ANTES de qualquer uma commitar,
e só depois disso o resultado passa a depender só do índice parcial
único do banco (determinístico: 1 vencedor, 1 `IntegrityError`
traduzido em `CertificateUploadConflictError`)."""
key = f"k-{uuid.uuid4().hex}"
product = await create_product(db_session, name="auto", api_key=key)
pfx_a = _build_test_pfx(cnpj="14200166000187", password="senha123", cn="A:14200166000187")
pfx_b = _build_test_pfx(cnpj="14200166000187", password="senha456", cn="B:14200166000187")
original_precheck = certificate_service._get_live_certificate_by_branch
precheck_done = asyncio.Event()
first_precheck_claimed = False
async def _precheck_forcing_both_before_any_commit(session, product_id, branch_ref):
nonlocal first_precheck_claimed
if not first_precheck_claimed:
first_precheck_claimed = True
result = await original_precheck(session, product_id, branch_ref)
precheck_done.set()
# Segura ESTA chamada (ainda antes do commit em upload_certificate)
# até depois que a outra também tenha feito seu pre-check --
# garante que as DUAS leem "nenhum certificado vivo" antes de
# qualquer uma escrever.
await asyncio.sleep(0.3)
return result
await precheck_done.wait()
return await original_precheck(session, product_id, branch_ref)
monkeypatch.setattr(
certificate_service, "_get_live_certificate_by_branch", _precheck_forcing_both_before_any_commit
)
session_maker = async_sessionmaker(test_engine, expire_on_commit=False)
session_a = session_maker()
session_b = session_maker()