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 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,50 +317,46 @@ async def test_idempotency_key_apontando_para_documento_soft_deletado_e_tratada_
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(
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(
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="<NFe/>",
numero=raw["numero"], chave_acesso=chave_morta,
codigo_numerico="87654321", status="ASSINADO",
ambiente="homologacao", xml_assinado="<NFe/>",
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},
# 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
)
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"
assert replay is None, (
"sem o filtro `deleted_at IS NULL` a query devolveria o documento morto"
)
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
async def test_idempotency_key_ausente_e_422(db_session):