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