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(
|
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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user