fix(emission): idempotency replay ignores soft-deleted documents

MINOR (F2 review): _get_document_by_idempotency_key lacked the
deleted_at IS NULL filter that get_fiscal_document already has. No F2
trigger today (nothing soft-deletes a FiscalDocument yet), but a future
cancel-by-soft-delete would replay a dead document as if it were still the
valid idempotent response, instead of treating the key as free for a new
emission.

Test: tests/emission/test_emissao.py::
test_idempotency_key_apontando_para_documento_soft_deletado_e_tratada_como_ausente
-- manually inserts a soft-deleted FiscalDocument + its FiscalIdempotencyKey
row (constructing the pre-cancel scenario directly, since nothing else in
F2 produces one yet), asserts the query-level replay returns None, and that
replaying via POST /v1/emissoes with that key emits a brand-new document
(201, new number allocated) rather than resolving to the dead one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jonatanritter
2026-08-08 15:35:27 -03:00
co-authored by Claude Opus 4.8
parent 0b64602716
commit 260644c961
2 changed files with 80 additions and 1 deletions
+10
View File
@@ -315,12 +315,22 @@ async def _get_series(
async def _get_document_by_idempotency_key( async def _get_document_by_idempotency_key(
session: AsyncSession, product_id: uuid.UUID, idempotency_key: str session: AsyncSession, product_id: uuid.UUID, idempotency_key: str
) -> FiscalDocument | None: ) -> FiscalDocument | None:
"""FIX 4 (F2 review, MINOR): `deleted_at IS NULL` -- matches `get_fiscal_
document` below. Nenhum caminho deste F2 soft-deleta um `FiscalDocument`
hoje (a imutabilidade pós-ASSINADO é preservada via evento futuro, não
soft-delete direto), mas a `FiscalIdempotencyKey` que aponta pra ele
sobrevive PARA SEMPRE (sem soft-delete mixin, por design -- ver o
docstring da própria tabela) -- um futuro "cancelar = soft-delete"
faria esta pré-checagem devolver um documento MORTO como se ainda
fosse a resposta idempotente válida, em vez de tratar a chave como
livre para uma NOVA emissão."""
result = await session.execute( result = await session.execute(
select(FiscalDocument) select(FiscalDocument)
.join(FiscalIdempotencyKey, FiscalIdempotencyKey.document_id == FiscalDocument.id) .join(FiscalIdempotencyKey, FiscalIdempotencyKey.document_id == FiscalDocument.id)
.where( .where(
FiscalIdempotencyKey.product_id == product_id, FiscalIdempotencyKey.product_id == product_id,
FiscalIdempotencyKey.idempotency_key == idempotency_key, FiscalIdempotencyKey.idempotency_key == idempotency_key,
FiscalDocument.deleted_at.is_(None),
) )
) )
return result.scalar_one_or_none() return result.scalar_one_or_none()
+70 -1
View File
@@ -31,7 +31,7 @@ from sqlalchemy import select
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
from fiscal_svc.documents.models import FiscalDocument, FiscalSeries from fiscal_svc.documents.models import FiscalDocument, FiscalIdempotencyKey, FiscalSeries
from fiscal_svc.emission import service as emission_service from fiscal_svc.emission import service as emission_service
from fiscal_svc.emission.schemas import EmissaoRequest from fiscal_svc.emission.schemas import EmissaoRequest
from fiscal_svc.main import app from fiscal_svc.main import app
@@ -284,6 +284,75 @@ async def test_idempotency_key_repetida_devolve_o_mesmo_documento_com_200(db_ses
) )
# --- FIX 4 (F2 review, MINOR): replay de idempotency-key ignora soft-delete
@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."""
product, key = await _product_and_key(db_session)
payload, raw = _payload_from_golden("caso_padrao_intra")
idem_key = f"idem-dead-{uuid.uuid4().hex}"
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
await _setup_certificate_and_series(
db_session, client, key, tenant_ref="t1", branch_ref="b1",
serie=raw["serie"], next_number=raw["numero"],
)
result = await db_session.execute(
select(FiscalSeries).where(
FiscalSeries.product_id == product.id, FiscalSeries.tenant_ref == "t1"
)
)
series = result.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/>",
deleted_at=datetime.now(timezone.utc),
)
db_session.add(dead_document)
await db_session.flush()
db_session.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
# 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"
)
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):
product, key = await _product_and_key(db_session) product, key = await _product_and_key(db_session)