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)