Files
sowai-fiscal-svc/tests/series/test_series.py
T
jonatanritter c903a9ce0e feat: certificates + series API v1 (Task 4)
Port certificate lifecycle (parse/encrypt/upload/deactivate) and
FiscalSeries CRUD from the auto, adapted to (product_id, tenant_ref,
branch_ref) tenancy. Closes the "guard retroativo" PATCH /v1/series
next_number regression check that Task 3 deferred to this task.

Routes: POST/GET/DELETE /v1/certificados, POST/GET/PATCH /v1/series.
2026-07-22 16:51:22 -03:00

264 lines
10 KiB
Python

"""Task 4: `POST/GET /v1/series`, `PATCH /v1/series/{id}` -- ported from the
auto's `tests/modules/tenants/test_fiscal_series.py` CRUD half (allocation
itself, and its concurrency proofs, are Task 3's
`tests/documents/test_allocate_fiscal_number.py`). Covers: CRUD happy path,
duplicate tuple -> 409, anti-oracle 404 (cross-product), missing API key ->
401, and the "guard retroativo" this Task closes: `PATCH .../next_number`
regressing below the highest `numero` a series has already emitted -> 409."""
import uuid
from datetime import datetime, timezone
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from fiscal_svc.core.db import get_session
from fiscal_svc.documents.models import FiscalDocument, FiscalSeries
from fiscal_svc.main import app
from fiscal_svc.tenancy.service import create_product
@pytest.fixture(autouse=True)
def _override_db(db_session):
async def _get_session_override():
yield db_session
app.dependency_overrides[get_session] = _get_session_override
yield
app.dependency_overrides.clear()
def _headers(api_key: str) -> dict[str, str]:
return {"X-Api-Key": api_key}
async def _product_and_key(db_session, name="auto"):
key = f"k-{uuid.uuid4().hex}"
product = await create_product(db_session, name=name, api_key=key)
return product, key
@pytest.mark.asyncio
async def test_create_fiscal_series_returns_201(db_session):
_, key = await _product_and_key(db_session)
payload = {"tenant_ref": "t1", "branch_ref": "b1", "document_model": "55", "serie": 1, "next_number": 1014}
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post("/v1/series", json=payload, headers=_headers(key))
assert response.status_code == 201, response.text
body = response.json()
assert body["tenant_ref"] == "t1"
assert body["branch_ref"] == "b1"
assert body["document_model"] == "55"
assert body["serie"] == 1
assert body["next_number"] == 1014
@pytest.mark.asyncio
async def test_list_fiscal_series_returns_created_series(db_session):
_, key = await _product_and_key(db_session)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
await client.post(
"/v1/series",
json={"tenant_ref": "t1", "branch_ref": "b1", "document_model": "55", "serie": 1, "next_number": 1},
headers=_headers(key),
)
await client.post(
"/v1/series",
json={"tenant_ref": "t1", "branch_ref": "b1", "document_model": "55", "serie": 2, "next_number": 1},
headers=_headers(key),
)
response = await client.get(
"/v1/series", params={"tenant_ref": "t1", "branch_ref": "b1"}, headers=_headers(key)
)
assert response.status_code == 200, response.text
series_list = response.json()
assert len(series_list) == 2
assert {s["serie"] for s in series_list} == {1, 2}
@pytest.mark.asyncio
async def test_patch_fiscal_series_updates_next_number(db_session):
_, key = await _product_and_key(db_session)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
create_response = await client.post(
"/v1/series",
json={"tenant_ref": "t1", "branch_ref": "b1", "document_model": "55", "serie": 1, "next_number": 1},
headers=_headers(key),
)
series_id = create_response.json()["id"]
response = await client.patch(
f"/v1/series/{series_id}", json={"next_number": 500}, headers=_headers(key)
)
assert response.status_code == 200, response.text
assert response.json()["next_number"] == 500
async def _series_with_documents(db_session, product, *, max_numero: int, next_number: int) -> FiscalSeries:
"""Fabrica uma série + um `FiscalDocument` VIVO com `numero=max_numero`
-- exatamente como o guard vai ler (`numero`/`series_id`/`deleted_at IS
NULL`), sem passar pelo fluxo real de emissão (Task 5)."""
series = FiscalSeries(
product_id=product.id, tenant_ref="t1", branch_ref="b1",
document_model="55", serie=1, next_number=next_number,
)
db_session.add(series)
await db_session.flush()
document = FiscalDocument(
product_id=product.id, tenant_ref="t1", branch_ref="b1", series_id=series.id,
document_model="55", serie=1, numero=max_numero,
chave_acesso=str(uuid.uuid4().int)[:44].zfill(44), codigo_numerico="12345678",
status="ASSINADO", ambiente="homologacao", xml_assinado="<NFe/>",
)
db_session.add(document)
await db_session.commit()
await db_session.refresh(series)
return series
@pytest.mark.asyncio
async def test_patch_fiscal_series_next_number_regression_is_409(db_session):
product, key = await _product_and_key(db_session)
series = await _series_with_documents(db_session, product, max_numero=1014, next_number=1015)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
regressed = await client.patch(
f"/v1/series/{series.id}", json={"next_number": 1000}, headers=_headers(key)
)
equal_to_max = await client.patch(
f"/v1/series/{series.id}", json={"next_number": 1014}, headers=_headers(key)
)
allowed = await client.patch(
f"/v1/series/{series.id}", json={"next_number": 1015}, headers=_headers(key)
)
assert regressed.status_code == 409, regressed.text
assert regressed.json()["detail"]["code"] == "fiscal_series_number_regression"
assert equal_to_max.status_code == 409, equal_to_max.text
assert allowed.status_code == 200, allowed.text
assert allowed.json()["next_number"] == 1015
@pytest.mark.asyncio
async def test_patch_fiscal_series_next_number_ignores_soft_deleted_documents(db_session):
"""O guard só olha documentos VIVOS -- um `FiscalDocument`
soft-deletado não deve travar o `next_number` para sempre."""
product, key = await _product_and_key(db_session)
series = await _series_with_documents(db_session, product, max_numero=1014, next_number=1015)
result = await db_session.execute(select(FiscalDocument).where(FiscalDocument.series_id == series.id))
document = result.scalar_one()
document.deleted_at = datetime.now(timezone.utc)
await db_session.commit()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.patch(
f"/v1/series/{series.id}", json={"next_number": 1}, headers=_headers(key)
)
assert response.status_code == 200, response.text
assert response.json()["next_number"] == 1
@pytest.mark.asyncio
async def test_patch_fiscal_series_rejects_explicit_null(db_session):
_, key = await _product_and_key(db_session)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
create_response = await client.post(
"/v1/series",
json={"tenant_ref": "t1", "branch_ref": "b1", "document_model": "55", "serie": 1, "next_number": 1},
headers=_headers(key),
)
series_id = create_response.json()["id"]
response = await client.patch(
f"/v1/series/{series_id}", json={"next_number": None}, headers=_headers(key)
)
assert response.status_code == 422, response.text
@pytest.mark.asyncio
async def test_create_duplicate_fiscal_series_is_409(db_session):
_, key = await _product_and_key(db_session)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
await client.post(
"/v1/series",
json={"tenant_ref": "t1", "branch_ref": "b1", "document_model": "55", "serie": 1, "next_number": 1},
headers=_headers(key),
)
response = await client.post(
"/v1/series",
json={"tenant_ref": "t1", "branch_ref": "b1", "document_model": "55", "serie": 1, "next_number": 999},
headers=_headers(key),
)
assert response.status_code == 409, response.text
assert response.json()["detail"]["code"] == "duplicate_fiscal_series"
@pytest.mark.asyncio
async def test_different_serie_same_model_is_ok(db_session):
_, key = await _product_and_key(db_session)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
await client.post(
"/v1/series",
json={"tenant_ref": "t1", "branch_ref": "b1", "document_model": "55", "serie": 1, "next_number": 1},
headers=_headers(key),
)
response = await client.post(
"/v1/series",
json={"tenant_ref": "t1", "branch_ref": "b1", "document_model": "55", "serie": 2, "next_number": 1},
headers=_headers(key),
)
assert response.status_code == 201, response.text
@pytest.mark.asyncio
async def test_patch_fiscal_series_other_product_series_is_404(db_session):
product_a, key_a = await _product_and_key(db_session, name="auto")
_, key_b = await _product_and_key(db_session, name="crm")
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
create_response = await client.post(
"/v1/series",
json={"tenant_ref": "t1", "branch_ref": "b1", "document_model": "55", "serie": 1, "next_number": 1},
headers=_headers(key_a),
)
series_id = create_response.json()["id"]
response = await client.patch(
f"/v1/series/{series_id}", json={"next_number": 42}, headers=_headers(key_b)
)
assert response.status_code == 404, response.text
@pytest.mark.asyncio
async def test_list_fiscal_series_missing_api_key_is_401(db_session):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/v1/series", params={"tenant_ref": "t1", "branch_ref": "b1"})
assert response.status_code == 401, response.text