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:
jonatanritter
2026-07-22 16:51:22 -03:00
parent 836c267e09
commit c903a9ce0e
17 changed files with 1515 additions and 0 deletions
View File
+387
View File
@@ -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"
)
View File
+263
View File
@@ -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