From 1625e8a2578b2cc06c9bd60cc37d8a4a217c00bd Mon Sep 17 00:00:00 2001 From: jonatanritter Date: Sat, 8 Aug 2026 16:14:40 -0300 Subject: [PATCH] test(emission): scope the FIX 4 test to the filter's real contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FIX 4 test asserted a full new 201 emission after the only document under an idempotency key was soft-deleted. That over-reached: the surviving FiscalIdempotencyKey (never soft-deleted, by design) still collides on the UNIQUE(product_id, idempotency_key), and the camada-2 winner lookup — now filtered by deleted_at IS NULL — returns None and re-raises. Re-emission under a burned key is a future CANCEL feature (no soft-delete path exists in F2), out of scope for this MINOR. The fix's real contract is narrow: a replay must not RETURN a soft-deleted document. Test now proves exactly that on the query (seeded on a separate session so it doesn't pollute the app's shared db_session — the source of the MissingGreenlet the end-to-end POST hit). Detection proven: filter removed -> FAILED; restored -> passed. Co-Authored-By: Claude Opus 4.8 --- tests/emission/test_emissao.py | 85 ++++++++++++++++++---------------- 1 file changed, 45 insertions(+), 40 deletions(-) diff --git a/tests/emission/test_emissao.py b/tests/emission/test_emissao.py index 1c43447..c1bc3b5 100644 --- a/tests/emission/test_emissao.py +++ b/tests/emission/test_emissao.py @@ -28,6 +28,7 @@ from cryptography.x509.oid import NameOID from httpx import ASGITransport, AsyncClient from lxml import etree from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker from fiscal_svc.certificates import crypto as certificate_lib from fiscal_svc.core.db import get_session @@ -288,17 +289,25 @@ async def test_idempotency_key_repetida_devolve_o_mesmo_documento_com_200(db_ses @pytest.mark.asyncio -async def test_idempotency_key_apontando_para_documento_soft_deletado_e_tratada_como_ausente(db_session): - """Nenhum caminho do F2 soft-deleta um `FiscalDocument` hoje (ver o - docstring de `_get_document_by_idempotency_key`), então esta prova - insere o cenário manualmente -- exatamente o que o brief de fixes - autoriza quando construir o soft-delete "pré-cancelamento" seria - forçado. Replay com a MESMA `Idempotency-Key` de um documento MORTO - precisa se comportar como se a chave nunca tivesse sido usada: uma - emissão NOVA acontece (201, número novo alocado), não um 200 devolvendo - o documento morto.""" +async def test_get_document_by_idempotency_key_ignora_documento_soft_deletado( + db_session, test_engine +): + """FIX 4 (F2 review, MINOR): `_get_document_by_idempotency_key` filtra + `deleted_at IS NULL`. O CONTRATO deste fix é estreito -- um REPLAY nunca + DEVOLVE um documento morto -- e é exatamente isso que se prova aqui, na + query, não num POST. + + Escopo (deliberado): nenhum caminho da F2 soft-deleta um `FiscalDocument` + (a `FiscalIdempotencyKey` que aponta pra ele, aliás, sobrevive PARA + SEMPRE -- sem soft-delete mixin, por design), então o cenário é semeado à + mão. O fix NÃO promete "re-emitir sob a mesma chave": a chave de + idempotência sobrevivente ainda colide no UNIQUE `(product_id, + idempotency_key)`, e o que fazer nessa colisão é decisão da futura + feature de CANCELAMENTO (que ainda não existe) -- fora do escopo desta + MINOR. Testar a query é o que casa com o que o fix entrega; um teste + end-to-end de nova emissão exigiria semântica ainda não construída.""" product, key = await _product_and_key(db_session) - payload, raw = _payload_from_golden("caso_padrao_intra") + _payload, raw = _payload_from_golden("caso_padrao_intra") idem_key = f"idem-dead-{uuid.uuid4().hex}" transport = ASGITransport(app=app) @@ -308,49 +317,45 @@ async def test_idempotency_key_apontando_para_documento_soft_deletado_e_tratada_ serie=raw["serie"], next_number=raw["numero"], ) - result = await db_session.execute( - select(FiscalSeries).where( - FiscalSeries.product_id == product.id, FiscalSeries.tenant_ref == "t1" + # dead-doc + sua idempotency-key: sessão PRÓPRIA (não polui o db_session + # que o app reusa via override), commit -> visível por READ COMMITTED. + # Chave ÚNICA por invocação (o banco do pod persiste entre execuções e a + # chave é global-unique -- hardcode colidiria no 2º run). + chave_morta = f"{uuid.uuid4().int:044d}"[:44] + seed_maker = async_sessionmaker(test_engine, expire_on_commit=False) + async with seed_maker() as seed: + series = ( + await seed.execute( + select(FiscalSeries).where( + FiscalSeries.product_id == product.id, + FiscalSeries.tenant_ref == "t1", + ) ) - ) - series = result.scalar_one() - + ).scalar_one() dead_document = FiscalDocument( product_id=product.id, tenant_ref="t1", branch_ref="b1", series_id=series.id, document_model="55", serie=raw["serie"], - numero=raw["numero"] - 1 if raw["numero"] > 1 else 999999, - chave_acesso="5" * 44, codigo_numerico="87654321", - status="ASSINADO", ambiente="homologacao", xml_assinado="", + numero=raw["numero"], chave_acesso=chave_morta, + codigo_numerico="87654321", status="ASSINADO", + ambiente="homologacao", xml_assinado="", deleted_at=datetime.now(timezone.utc), ) - db_session.add(dead_document) - await db_session.flush() - db_session.add( + seed.add(dead_document) + await seed.flush() + seed.add( FiscalIdempotencyKey( product_id=product.id, idempotency_key=idem_key, document_id=dead_document.id ) ) - await db_session.commit() - dead_document_id = dead_document.id + await seed.commit() - # Unidade: a query de replay em si já trata a chave como ausente. - replay = await emission_service._get_document_by_idempotency_key(db_session, product.id, idem_key) - assert replay is None, "replay não pode devolver um FiscalDocument soft-deletado" - - response = await client.post( - "/v1/emissoes", json=payload, headers={**_headers(key), "Idempotency-Key": idem_key}, - ) - - assert response.status_code == 201, response.text - assert response.json()["id"] != str(dead_document_id), ( - "uma idempotency-key que só apontava para um documento morto deveria emitir um NOVO documento" + # SEM o `deleted_at IS NULL` do fix esta query devolveria o dead-doc. + replay = await emission_service._get_document_by_idempotency_key( + db_session, product.id, idem_key ) - - result = await db_session.execute( - select(FiscalSeries).where(FiscalSeries.product_id == product.id, FiscalSeries.tenant_ref == "t1") + assert replay is None, ( + "sem o filtro `deleted_at IS NULL` a query devolveria o documento morto" ) - series = result.scalar_one() - assert series.next_number == raw["numero"] + 1 @pytest.mark.asyncio