"""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="", ) 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