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.
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
"""Task 4: `POST/GET/DELETE /v1/certificados` -- ported from the auto's
|
||||
`tests/modules/fiscal/test_certificate.py`, porte table applied (JWT bearer
|
||||
+ `branch_id` path segment -> `X-Api-Key` + `tenant_ref`/`branch_ref` query
|
||||
params; anti-oracle now by `(product_id, tenant_ref)` instead of
|
||||
`organization_id`).
|
||||
|
||||
Fixture de PFX: gerado em memória via `cryptography` (chave RSA 2048 +
|
||||
certificado self-signed com o CNPJ no subject, formato ICP-Brasil e-CNPJ
|
||||
real -- `CN=RAZAO SOCIAL:CNPJ` + atributo `SERIALNUMBER`) -- NUNCA um
|
||||
certificado real no repo, mesma convenção do auto."""
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from cryptography import x509
|
||||
from cryptography.fernet import Fernet
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.primitives.serialization import pkcs12
|
||||
from cryptography.x509.oid import NameOID
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
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 service as certificate_service
|
||||
from fiscal_svc.core.db import get_session
|
||||
from fiscal_svc.documents.models import FiscalCertificate
|
||||
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()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fiscal_cert_encryption_key(monkeypatch):
|
||||
monkeypatch.setenv("FISCAL_CERT_ENCRYPTION_KEY", Fernet.generate_key().decode("ascii"))
|
||||
certificate_lib._get_fernet.cache_clear()
|
||||
yield
|
||||
certificate_lib._get_fernet.cache_clear()
|
||||
|
||||
|
||||
def _build_test_pfx(
|
||||
*,
|
||||
cnpj: str = "14200166000187",
|
||||
password: str = "correct-horse-battery",
|
||||
not_valid_before: datetime | None = None,
|
||||
not_valid_after: datetime | None = None,
|
||||
cn: str | None = None,
|
||||
) -> bytes:
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
subject = issuer = x509.Name(
|
||||
[
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, cn or f"EMPRESA TESTE LTDA:{cnpj}"),
|
||||
x509.NameAttribute(NameOID.SERIAL_NUMBER, cnpj),
|
||||
]
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
nvb = not_valid_before if not_valid_before is not None else now - timedelta(days=1)
|
||||
nva = not_valid_after if not_valid_after is not None else now + timedelta(days=365)
|
||||
cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(subject)
|
||||
.issuer_name(issuer)
|
||||
.public_key(key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(nvb)
|
||||
.not_valid_after(nva)
|
||||
.sign(key, hashes.SHA256())
|
||||
)
|
||||
return pkcs12.serialize_key_and_certificates(
|
||||
name=b"test",
|
||||
key=key,
|
||||
cert=cert,
|
||||
cas=None,
|
||||
encryption_algorithm=serialization.BestAvailableEncryption(password.encode("utf-8")),
|
||||
)
|
||||
|
||||
|
||||
def _headers(api_key: str) -> dict[str, str]:
|
||||
return {"X-Api-Key": api_key}
|
||||
|
||||
|
||||
async def _upload(client, api_key, tenant_ref, branch_ref, cnpj, pfx_bytes, password):
|
||||
return await client.post(
|
||||
"/v1/certificados",
|
||||
params={"tenant_ref": tenant_ref, "branch_ref": branch_ref},
|
||||
files={"file": ("cert.pfx", pfx_bytes, "application/x-pkcs12")},
|
||||
data={"password": password, "cnpj": cnpj},
|
||||
headers=_headers(api_key),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_valid_pfx_returns_201_with_correct_metadata_and_encrypted_binary(db_session):
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key)
|
||||
pfx_bytes = _build_test_pfx(cnpj="14200166000187", password="senha123")
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await _upload(client, key, "tenant-1", "branch-1", "14200166000187", pfx_bytes, "senha123")
|
||||
|
||||
assert response.status_code == 201, response.text
|
||||
body = response.json()
|
||||
assert body["branch_ref"] == "branch-1"
|
||||
assert body["tenant_ref"] == "tenant-1"
|
||||
assert body["cnpj_certificado"] == "14200166000187"
|
||||
assert "EMPRESA TESTE LTDA" in body["subject_cn"]
|
||||
assert "pfx_encrypted" not in body
|
||||
assert "password_encrypted" not in body
|
||||
|
||||
result = await db_session.execute(
|
||||
select(FiscalCertificate).where(FiscalCertificate.id == uuid.UUID(body["id"]))
|
||||
)
|
||||
row = result.scalar_one()
|
||||
assert row.pfx_encrypted != pfx_bytes
|
||||
assert pfx_bytes not in row.pfx_encrypted
|
||||
assert certificate_lib.decrypt_bytes(row.pfx_encrypted) == pfx_bytes
|
||||
assert certificate_lib.decrypt_bytes(row.password_encrypted) == b"senha123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_wrong_password_is_422(db_session):
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key)
|
||||
pfx_bytes = _build_test_pfx(cnpj="14200166000187", password="correct-pw")
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await _upload(client, key, "t1", "b1", "14200166000187", pfx_bytes, "wrong-pw")
|
||||
|
||||
assert response.status_code == 422, response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_non_pfx_file_is_422(db_session):
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await _upload(client, key, "t1", "b1", "14200166000187", b"isso nao e um pfx", "qualquer")
|
||||
|
||||
assert response.status_code == 422, response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_expired_certificate_is_422(db_session):
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key)
|
||||
now = datetime.now(timezone.utc)
|
||||
pfx_bytes = _build_test_pfx(
|
||||
cnpj="14200166000187", password="senha123",
|
||||
not_valid_before=now - timedelta(days=400), not_valid_after=now - timedelta(days=10),
|
||||
)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await _upload(client, key, "t1", "b1", "14200166000187", pfx_bytes, "senha123")
|
||||
|
||||
assert response.status_code == 422, response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_not_valid_before_in_future_is_422(db_session):
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key)
|
||||
now = datetime.now(timezone.utc)
|
||||
pfx_bytes = _build_test_pfx(
|
||||
cnpj="14200166000187", password="senha123",
|
||||
not_valid_before=now + timedelta(days=5), not_valid_after=now + timedelta(days=400),
|
||||
)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await _upload(client, key, "t1", "b1", "14200166000187", pfx_bytes, "senha123")
|
||||
|
||||
assert response.status_code == 422, response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_cnpj_mismatch_between_payload_and_certificate_is_422_naming_both(db_session):
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key)
|
||||
pfx_bytes = _build_test_pfx(cnpj="99887766000155", password="senha123")
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await _upload(client, key, "t1", "b1", "14200166000187", pfx_bytes, "senha123")
|
||||
|
||||
assert response.status_code == 422, response.text
|
||||
assert "14200166000187" in response.text
|
||||
assert "99887766000155" in response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_certificate_returns_metadata_without_binary(db_session):
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key)
|
||||
pfx_bytes = _build_test_pfx(cnpj="14200166000187", password="senha123")
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
await _upload(client, key, "t1", "b1", "14200166000187", pfx_bytes, "senha123")
|
||||
response = await client.get(
|
||||
"/v1/certificados", params={"tenant_ref": "t1", "branch_ref": "b1"}, headers=_headers(key)
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["cnpj_certificado"] == "14200166000187"
|
||||
assert "pfx_encrypted" not in body
|
||||
assert "password_encrypted" not in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_product_branch_is_404_not_leaked(db_session):
|
||||
key_a = f"k-{uuid.uuid4().hex}"
|
||||
key_b = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key_a)
|
||||
await create_product(db_session, name="crm", api_key=key_b)
|
||||
pfx_bytes = _build_test_pfx(cnpj="14200166000187", password="senha123")
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
await _upload(client, key_a, "t1", "b1", "14200166000187", pfx_bytes, "senha123")
|
||||
get_response = await client.get(
|
||||
"/v1/certificados", params={"tenant_ref": "t1", "branch_ref": "b1"}, headers=_headers(key_b)
|
||||
)
|
||||
|
||||
assert get_response.status_code == 404, get_response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_tenant_ref_same_product_is_404(db_session):
|
||||
"""Anti-oracle boundary is `(product_id, tenant_ref)` (design spec
|
||||
decision #4) -- even under the SAME product, a wrong `tenant_ref` for a
|
||||
real `branch_ref` must 404, not leak the certificate."""
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key)
|
||||
pfx_bytes = _build_test_pfx(cnpj="14200166000187", password="senha123")
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
await _upload(client, key, "tenant-a", "b1", "14200166000187", pfx_bytes, "senha123")
|
||||
get_response = await client.get(
|
||||
"/v1/certificados", params={"tenant_ref": "tenant-b", "branch_ref": "b1"}, headers=_headers(key)
|
||||
)
|
||||
|
||||
assert get_response.status_code == 404, get_response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_second_upload_replaces_the_first_soft_deleting_it(db_session):
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key)
|
||||
first_pfx = _build_test_pfx(cnpj="14200166000187", password="senha123", cn="PRIMEIRO:14200166000187")
|
||||
second_pfx = _build_test_pfx(cnpj="14200166000187", password="senha456", cn="SEGUNDO:14200166000187")
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
first_response = await _upload(client, key, "t1", "b1", "14200166000187", first_pfx, "senha123")
|
||||
second_response = await _upload(client, key, "t1", "b1", "14200166000187", second_pfx, "senha456")
|
||||
get_response = await client.get(
|
||||
"/v1/certificados", params={"tenant_ref": "t1", "branch_ref": "b1"}, headers=_headers(key)
|
||||
)
|
||||
|
||||
assert first_response.status_code == 201, first_response.text
|
||||
assert second_response.status_code == 201, second_response.text
|
||||
assert get_response.json()["id"] == second_response.json()["id"]
|
||||
assert "SEGUNDO" in get_response.json()["subject_cn"]
|
||||
|
||||
result = await db_session.execute(
|
||||
select(FiscalCertificate).where(FiscalCertificate.branch_ref == "b1")
|
||||
)
|
||||
rows = result.scalars().all()
|
||||
assert len(rows) == 2
|
||||
live = [r for r in rows if r.deleted_at is None]
|
||||
dead = [r for r in rows if r.deleted_at is not None]
|
||||
assert len(live) == 1
|
||||
assert len(dead) == 1
|
||||
assert live[0].id == uuid.UUID(second_response.json()["id"])
|
||||
assert dead[0].id == uuid.UUID(first_response.json()["id"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_soft_deletes_and_get_then_404s(db_session):
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key)
|
||||
pfx_bytes = _build_test_pfx(cnpj="14200166000187", password="senha123")
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
await _upload(client, key, "t1", "b1", "14200166000187", pfx_bytes, "senha123")
|
||||
delete_response = await client.delete(
|
||||
"/v1/certificados", params={"tenant_ref": "t1", "branch_ref": "b1"}, headers=_headers(key)
|
||||
)
|
||||
get_response = await client.get(
|
||||
"/v1/certificados", params={"tenant_ref": "t1", "branch_ref": "b1"}, headers=_headers(key)
|
||||
)
|
||||
|
||||
assert delete_response.status_code == 204, delete_response.text
|
||||
assert get_response.status_code == 404, get_response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_without_certificate_is_404(db_session):
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.delete(
|
||||
"/v1/certificados", params={"tenant_ref": "t1", "branch_ref": "b1"}, headers=_headers(key)
|
||||
)
|
||||
|
||||
assert response.status_code == 404, response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_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/certificados", params={"tenant_ref": "t1", "branch_ref": "b1"})
|
||||
|
||||
assert response.status_code == 401, response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_uploads_for_same_product_branch_only_one_wins_the_other_gets_409(
|
||||
test_engine, db_session
|
||||
):
|
||||
"""Mesma prova do auto (`test_concurrent_uploads_for_same_branch_only_
|
||||
one_wins_the_other_gets_409`): duas `AsyncSession` distintas contra o
|
||||
MESMO `test_engine`, disparadas via `asyncio.gather` -- concorrência
|
||||
REAL, não simulada."""
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
product = await create_product(db_session, name="auto", api_key=key)
|
||||
pfx_a = _build_test_pfx(cnpj="14200166000187", password="senha123", cn="A:14200166000187")
|
||||
pfx_b = _build_test_pfx(cnpj="14200166000187", password="senha456", cn="B:14200166000187")
|
||||
|
||||
session_maker = async_sessionmaker(test_engine, expire_on_commit=False)
|
||||
session_a = session_maker()
|
||||
session_b = session_maker()
|
||||
try:
|
||||
results = await asyncio.gather(
|
||||
certificate_service.upload_certificate(
|
||||
session_a, product.id, "t1", "b1", "14200166000187", pfx_a, "senha123"
|
||||
),
|
||||
certificate_service.upload_certificate(
|
||||
session_b, product.id, "t1", "b1", "14200166000187", pfx_b, "senha456"
|
||||
),
|
||||
return_exceptions=True,
|
||||
)
|
||||
finally:
|
||||
await session_a.close()
|
||||
await session_b.close()
|
||||
|
||||
successes = [r for r in results if not isinstance(r, BaseException)]
|
||||
errors = [r for r in results if isinstance(r, BaseException)]
|
||||
|
||||
assert len(successes) == 1, f"expected exactly 1 winner, got {len(successes)}: {results!r}"
|
||||
assert len(errors) == 1, f"expected exactly 1 conflict error, got {len(errors)}: {results!r}"
|
||||
assert isinstance(errors[0], certificate_service.CertificateUploadConflictError), errors[0]
|
||||
|
||||
live_result = await db_session.execute(
|
||||
select(FiscalCertificate).where(
|
||||
FiscalCertificate.product_id == product.id,
|
||||
FiscalCertificate.branch_ref == "b1",
|
||||
FiscalCertificate.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
live_rows = live_result.scalars().all()
|
||||
assert len(live_rows) == 1, (
|
||||
f"expected exactly 1 live certificate after the race, found {len(live_rows)} -- "
|
||||
"the DB-level partial unique index should have blocked the second insert"
|
||||
)
|
||||
@@ -0,0 +1,263 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user