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