test(emission): scope the FIX 4 test to the filter's real contract

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 <noreply@anthropic.com>
This commit is contained in:
jonatanritter
2026-08-08 16:14:40 -03:00
co-authored by Claude Opus 4.8
parent 4b8532c604
commit 1625e8a257
+43 -38
View File
@@ -28,6 +28,7 @@ from cryptography.x509.oid import NameOID
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from lxml import etree from lxml import etree
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker
from fiscal_svc.certificates import crypto as certificate_lib from fiscal_svc.certificates import crypto as certificate_lib
from fiscal_svc.core.db import get_session 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 @pytest.mark.asyncio
async def test_idempotency_key_apontando_para_documento_soft_deletado_e_tratada_como_ausente(db_session): async def test_get_document_by_idempotency_key_ignora_documento_soft_deletado(
"""Nenhum caminho do F2 soft-deleta um `FiscalDocument` hoje (ver o db_session, test_engine
docstring de `_get_document_by_idempotency_key`), então esta prova ):
insere o cenário manualmente -- exatamente o que o brief de fixes """FIX 4 (F2 review, MINOR): `_get_document_by_idempotency_key` filtra
autoriza quando construir o soft-delete "pré-cancelamento" seria `deleted_at IS NULL`. O CONTRATO deste fix é estreito -- um REPLAY nunca
forçado. Replay com a MESMA `Idempotency-Key` de um documento MORTO DEVOLVE um documento morto -- e é exatamente isso que se prova aqui, na
precisa se comportar como se a chave nunca tivesse sido usada: uma query, não num POST.
emissão NOVA acontece (201, número novo alocado), não um 200 devolvendo
o documento morto.""" 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) 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}" idem_key = f"idem-dead-{uuid.uuid4().hex}"
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
@@ -308,50 +317,46 @@ async def test_idempotency_key_apontando_para_documento_soft_deletado_e_tratada_
serie=raw["serie"], next_number=raw["numero"], serie=raw["serie"], next_number=raw["numero"],
) )
result = await db_session.execute( # 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( select(FiscalSeries).where(
FiscalSeries.product_id == product.id, FiscalSeries.tenant_ref == "t1" FiscalSeries.product_id == product.id,
FiscalSeries.tenant_ref == "t1",
) )
) )
series = result.scalar_one() ).scalar_one()
dead_document = FiscalDocument( dead_document = FiscalDocument(
product_id=product.id, tenant_ref="t1", branch_ref="b1", product_id=product.id, tenant_ref="t1", branch_ref="b1",
series_id=series.id, document_model="55", serie=raw["serie"], series_id=series.id, document_model="55", serie=raw["serie"],
numero=raw["numero"] - 1 if raw["numero"] > 1 else 999999, numero=raw["numero"], chave_acesso=chave_morta,
chave_acesso="5" * 44, codigo_numerico="87654321", codigo_numerico="87654321", status="ASSINADO",
status="ASSINADO", ambiente="homologacao", xml_assinado="<NFe/>", ambiente="homologacao", xml_assinado="<NFe/>",
deleted_at=datetime.now(timezone.utc), deleted_at=datetime.now(timezone.utc),
) )
db_session.add(dead_document) seed.add(dead_document)
await db_session.flush() await seed.flush()
db_session.add( seed.add(
FiscalIdempotencyKey( FiscalIdempotencyKey(
product_id=product.id, idempotency_key=idem_key, document_id=dead_document.id product_id=product.id, idempotency_key=idem_key, document_id=dead_document.id
) )
) )
await db_session.commit() await seed.commit()
dead_document_id = dead_document.id
# Unidade: a query de replay em si já trata a chave como ausente. # 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) replay = await emission_service._get_document_by_idempotency_key(
assert replay is None, "replay não pode devolver um FiscalDocument soft-deletado" db_session, product.id, idem_key
response = await client.post(
"/v1/emissoes", json=payload, headers={**_headers(key), "Idempotency-Key": idem_key},
) )
assert replay is None, (
assert response.status_code == 201, response.text "sem o filtro `deleted_at IS NULL` a query devolveria o documento morto"
assert response.json()["id"] != str(dead_document_id), (
"uma idempotency-key que só apontava para um documento morto deveria emitir um NOVO documento"
) )
result = await db_session.execute(
select(FiscalSeries).where(FiscalSeries.product_id == product.id, FiscalSeries.tenant_ref == "t1")
)
series = result.scalar_one()
assert series.next_number == raw["numero"] + 1
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_idempotency_key_ausente_e_422(db_session): async def test_idempotency_key_ausente_e_422(db_session):