From 260644c961d6ccef1094017234a135f3121f5c03 Mon Sep 17 00:00:00 2001 From: jonatanritter Date: Sat, 8 Aug 2026 15:35:27 -0300 Subject: [PATCH] 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 --- src/fiscal_svc/emission/service.py | 10 +++++ tests/emission/test_emissao.py | 71 +++++++++++++++++++++++++++++- 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/src/fiscal_svc/emission/service.py b/src/fiscal_svc/emission/service.py index d625ab2..79c0b2f 100644 --- a/src/fiscal_svc/emission/service.py +++ b/src/fiscal_svc/emission/service.py @@ -315,12 +315,22 @@ async def _get_series( async def _get_document_by_idempotency_key( session: AsyncSession, product_id: uuid.UUID, idempotency_key: str ) -> 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( select(FiscalDocument) .join(FiscalIdempotencyKey, FiscalIdempotencyKey.document_id == FiscalDocument.id) .where( FiscalIdempotencyKey.product_id == product_id, FiscalIdempotencyKey.idempotency_key == idempotency_key, + FiscalDocument.deleted_at.is_(None), ) ) return result.scalar_one_or_none() diff --git a/tests/emission/test_emissao.py b/tests/emission/test_emissao.py index 78df007..1c43447 100644 --- a/tests/emission/test_emissao.py +++ b/tests/emission/test_emissao.py @@ -31,7 +31,7 @@ from sqlalchemy import select from fiscal_svc.certificates import crypto as certificate_lib 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.schemas import EmissaoRequest 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="", + 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 async def test_idempotency_key_ausente_e_422(db_session): product, key = await _product_and_key(db_session)