test(emission): scope the FIX 4 test to the filter's real contract
The FIX 4 test asserted a full new 201 emission after the only document under an idempotency key was soft-deleted. That over-reached: the surviving FiscalIdempotencyKey (never soft-deleted, by design) still collides on the UNIQUE(product_id, idempotency_key), and the camada-2 winner lookup — now filtered by deleted_at IS NULL — returns None and re-raises. Re-emission under a burned key is a future CANCEL feature (no soft-delete path exists in F2), out of scope for this MINOR. The fix's real contract is narrow: a replay must not RETURN a soft-deleted document. Test now proves exactly that on the query (seeded on a separate session so it doesn't pollute the app's shared db_session — the source of the MissingGreenlet the end-to-end POST hit). Detection proven: filter removed -> FAILED; restored -> passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
4b8532c604
commit
1625e8a257
@@ -28,6 +28,7 @@ from cryptography.x509.oid import NameOID
|
|||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||||
|
|
||||||
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
|
||||||
@@ -288,17 +289,25 @@ async def test_idempotency_key_repetida_devolve_o_mesmo_documento_com_200(db_ses
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_idempotency_key_apontando_para_documento_soft_deletado_e_tratada_como_ausente(db_session):
|
async def test_get_document_by_idempotency_key_ignora_documento_soft_deletado(
|
||||||
"""Nenhum caminho do F2 soft-deleta um `FiscalDocument` hoje (ver o
|
db_session, test_engine
|
||||||
docstring de `_get_document_by_idempotency_key`), então esta prova
|
):
|
||||||
insere o cenário manualmente -- exatamente o que o brief de fixes
|
"""FIX 4 (F2 review, MINOR): `_get_document_by_idempotency_key` filtra
|
||||||
autoriza quando construir o soft-delete "pré-cancelamento" seria
|
`deleted_at IS NULL`. O CONTRATO deste fix é estreito -- um REPLAY nunca
|
||||||
forçado. Replay com a MESMA `Idempotency-Key` de um documento MORTO
|
DEVOLVE um documento morto -- e é exatamente isso que se prova aqui, na
|
||||||
precisa se comportar como se a chave nunca tivesse sido usada: uma
|
query, não num POST.
|
||||||
emissão NOVA acontece (201, número novo alocado), não um 200 devolvendo
|
|
||||||
o documento morto."""
|
Escopo (deliberado): nenhum caminho da F2 soft-deleta um `FiscalDocument`
|
||||||
|
(a `FiscalIdempotencyKey` que aponta pra ele, aliás, sobrevive PARA
|
||||||
|
SEMPRE -- sem soft-delete mixin, por design), então o cenário é semeado à
|
||||||
|
mão. O fix NÃO promete "re-emitir sob a mesma chave": a chave de
|
||||||
|
idempotência sobrevivente ainda colide no UNIQUE `(product_id,
|
||||||
|
idempotency_key)`, e o que fazer nessa colisão é decisão da futura
|
||||||
|
feature de CANCELAMENTO (que ainda não existe) -- fora do escopo desta
|
||||||
|
MINOR. Testar a query é o que casa com o que o fix entrega; um teste
|
||||||
|
end-to-end de nova emissão exigiria semântica ainda não construída."""
|
||||||
product, key = await _product_and_key(db_session)
|
product, key = await _product_and_key(db_session)
|
||||||
payload, raw = _payload_from_golden("caso_padrao_intra")
|
_payload, raw = _payload_from_golden("caso_padrao_intra")
|
||||||
idem_key = f"idem-dead-{uuid.uuid4().hex}"
|
idem_key = f"idem-dead-{uuid.uuid4().hex}"
|
||||||
|
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
@@ -308,49 +317,45 @@ async def test_idempotency_key_apontando_para_documento_soft_deletado_e_tratada_
|
|||||||
serie=raw["serie"], next_number=raw["numero"],
|
serie=raw["serie"], next_number=raw["numero"],
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await db_session.execute(
|
# dead-doc + sua idempotency-key: sessão PRÓPRIA (não polui o db_session
|
||||||
select(FiscalSeries).where(
|
# que o app reusa via override), commit -> visível por READ COMMITTED.
|
||||||
FiscalSeries.product_id == product.id, FiscalSeries.tenant_ref == "t1"
|
# Chave ÚNICA por invocação (o banco do pod persiste entre execuções e a
|
||||||
|
# chave é global-unique -- hardcode colidiria no 2º run).
|
||||||
|
chave_morta = f"{uuid.uuid4().int:044d}"[:44]
|
||||||
|
seed_maker = async_sessionmaker(test_engine, expire_on_commit=False)
|
||||||
|
async with seed_maker() as seed:
|
||||||
|
series = (
|
||||||
|
await seed.execute(
|
||||||
|
select(FiscalSeries).where(
|
||||||
|
FiscalSeries.product_id == product.id,
|
||||||
|
FiscalSeries.tenant_ref == "t1",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
).scalar_one()
|
||||||
series = result.scalar_one()
|
|
||||||
|
|
||||||
dead_document = FiscalDocument(
|
dead_document = FiscalDocument(
|
||||||
product_id=product.id, tenant_ref="t1", branch_ref="b1",
|
product_id=product.id, tenant_ref="t1", branch_ref="b1",
|
||||||
series_id=series.id, document_model="55", serie=raw["serie"],
|
series_id=series.id, document_model="55", serie=raw["serie"],
|
||||||
numero=raw["numero"] - 1 if raw["numero"] > 1 else 999999,
|
numero=raw["numero"], chave_acesso=chave_morta,
|
||||||
chave_acesso="5" * 44, codigo_numerico="87654321",
|
codigo_numerico="87654321", status="ASSINADO",
|
||||||
status="ASSINADO", ambiente="homologacao", xml_assinado="<NFe/>",
|
ambiente="homologacao", xml_assinado="<NFe/>",
|
||||||
deleted_at=datetime.now(timezone.utc),
|
deleted_at=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
db_session.add(dead_document)
|
seed.add(dead_document)
|
||||||
await db_session.flush()
|
await seed.flush()
|
||||||
db_session.add(
|
seed.add(
|
||||||
FiscalIdempotencyKey(
|
FiscalIdempotencyKey(
|
||||||
product_id=product.id, idempotency_key=idem_key, document_id=dead_document.id
|
product_id=product.id, idempotency_key=idem_key, document_id=dead_document.id
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await db_session.commit()
|
await seed.commit()
|
||||||
dead_document_id = dead_document.id
|
|
||||||
|
|
||||||
# Unidade: a query de replay em si já trata a chave como ausente.
|
# SEM o `deleted_at IS NULL` do fix esta query devolveria o dead-doc.
|
||||||
replay = await emission_service._get_document_by_idempotency_key(db_session, product.id, idem_key)
|
replay = await emission_service._get_document_by_idempotency_key(
|
||||||
assert replay is None, "replay não pode devolver um FiscalDocument soft-deletado"
|
db_session, product.id, idem_key
|
||||||
|
|
||||||
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"
|
|
||||||
)
|
)
|
||||||
|
assert replay is None, (
|
||||||
result = await db_session.execute(
|
"sem o filtro `deleted_at IS NULL` a query devolveria o documento morto"
|
||||||
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
|
||||||
|
|||||||
Reference in New Issue
Block a user