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:
co-authored by
Claude Opus 4.8
parent
0b64602716
commit
260644c961
@@ -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()
|
||||
|
||||
@@ -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="<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
|
||||
async def test_idempotency_key_ausente_e_422(db_session):
|
||||
product, key = await _product_and_key(db_session)
|
||||
|
||||
Reference in New Issue
Block a user