This commit is contained in:
2026-03-18 18:16:44 +01:00
parent c89c08c397
commit b3c8b77f12
20 changed files with 1934 additions and 36 deletions
+157
View File
@@ -0,0 +1,157 @@
"""
Router API Invio PEC (Fase 4).
Endpoint:
POST /send invia una nuova PEC (crea Message + SendJob, accoda job)
GET /send/jobs lista job di invio del tenant (paginata)
GET /send/jobs/{id} dettaglio di un singolo job
DELETE /send/jobs/{id} annulla job se ancora pending/retrying
"""
import uuid
from typing import Annotated
from fastapi import APIRouter, Query, status
from app.core.exceptions import ForbiddenError
from app.dependencies import AdminUser, CurrentUser, DB
from app.schemas.send import SendJobListResponse, SendJobResponse, SendPecRequest
from app.services.permission_service import PermissionService
from app.services.send_service import SendService
router = APIRouter(prefix="/send", tags=["Invio PEC"])
# ─── Helpers ──────────────────────────────────────────────────────────────────
def _svc(db) -> SendService:
return SendService(db)
def _job_response(job) -> SendJobResponse:
return SendJobResponse.model_validate(job)
# ─── Endpoints ────────────────────────────────────────────────────────────────
@router.post(
"",
response_model=SendJobResponse,
status_code=status.HTTP_201_CREATED,
summary="Invia una PEC",
description=(
"Crea un messaggio PEC in uscita e accoda il job di invio SMTP. "
"Il job viene eseguito in background con retry automatico. "
"Richiede permesso **can_send** sulla casella (gli admin possono inviare da qualsiasi casella del tenant)."
),
)
async def create_send_job(
data: SendPecRequest,
current_user: CurrentUser,
db: DB,
) -> SendJobResponse:
svc = _svc(db)
job = await svc.create_send_job(current_user=current_user, data=data)
await db.commit()
# Refresh per ottenere tutti i valori default dal DB
await db.refresh(job)
return _job_response(job)
@router.get(
"/jobs",
response_model=SendJobListResponse,
summary="Lista job di invio",
)
async def list_send_jobs(
current_user: CurrentUser,
db: DB,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
mailbox_id: uuid.UUID | None = Query(None),
status_filter: str | None = Query(
None,
alias="status",
description="Filtra per stato: pending | sending | sent | failed | retrying",
),
) -> SendJobListResponse:
"""
Elenca i job di invio del tenant.
Gli admin vedono tutti i job; gli operatori vedono solo i job
delle caselle su cui hanno permesso can_read.
"""
svc = _svc(db)
# Filtro opzionale per casella: verifica accesso se non admin
if mailbox_id and not current_user.is_admin:
perm_svc = PermissionService(db)
if not await perm_svc.check_can_read(current_user, mailbox_id):
raise ForbiddenError("Accesso alla casella non autorizzato")
items, total = await svc.list_send_jobs(
tenant_id=current_user.tenant_id,
page=page,
page_size=page_size,
mailbox_id=mailbox_id,
status_filter=status_filter,
)
return SendJobListResponse(
items=[_job_response(j) for j in items],
total=total,
page=page,
page_size=page_size,
)
@router.get(
"/jobs/{job_id}",
response_model=SendJobResponse,
summary="Dettaglio job di invio",
)
async def get_send_job(
job_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> SendJobResponse:
"""Recupera lo stato di un singolo job di invio."""
svc = _svc(db)
job = await svc.get_send_job(job_id, current_user.tenant_id)
# Verifica accesso alla casella se non admin
if not current_user.is_admin:
perm_svc = PermissionService(db)
if not await perm_svc.check_can_read(current_user, job.mailbox_id):
raise ForbiddenError("Accesso non autorizzato")
return _job_response(job)
@router.delete(
"/jobs/{job_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Annulla job di invio",
)
async def cancel_send_job(
job_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> None:
"""
Annulla un job di invio se è ancora in stato **pending** o **retrying**.
Non è possibile annullare un invio già partito (stato sending) o
completato (sent).
"""
svc = _svc(db)
# Verifica che l'utente possa agire su questo job
job = await svc.get_send_job(job_id, current_user.tenant_id)
if not current_user.is_admin:
perm_svc = PermissionService(db)
if not await perm_svc.check_can_send(current_user, job.mailbox_id):
raise ForbiddenError("Autorizzazione insufficiente per annullare questo invio")
await svc.cancel_send_job(job_id, current_user.tenant_id)
await db.commit()
+20 -9
View File
@@ -21,6 +21,23 @@ security = HTTPBearer()
# ─── Database con RLS ─────────────────────────────────────────────────────────
async def _set_rls_tenant_id(db: AsyncSession, tenant_id: uuid.UUID) -> None:
"""
Imposta la variabile di sessione PostgreSQL per RLS.
È un no-op su SQLite (test environment) poiché SQLite non supporta
il comando SET LOCAL né il concetto di Row Level Security.
"""
try:
await db.execute(
text(f"SET LOCAL app.current_tenant_id = '{tenant_id!s}'")
)
except Exception:
# SQLite (usato nei test di integrazione) non supporta SET LOCAL.
# In produzione (PostgreSQL) questo comando funziona sempre.
pass
async def get_db_with_rls(
tenant_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
@@ -29,10 +46,7 @@ async def get_db_with_rls(
Imposta la variabile di sessione PostgreSQL per RLS.
Da usare dopo aver estratto il tenant_id dall'utente autenticato.
"""
await db.execute(
text("SET LOCAL app.current_tenant_id = :tenant_id"),
{"tenant_id": str(tenant_id)},
)
await _set_rls_tenant_id(db, tenant_id)
return db
@@ -68,11 +82,8 @@ async def get_current_user(
except ValueError:
raise TokenInvalidError()
# Imposta RLS per questo tenant
# SET LOCAL non supporta parametri $1, usiamo text() con valore inline
await db.execute(
text(f"SET LOCAL app.current_tenant_id = '{tenant_id!s}'")
)
# Imposta RLS per questo tenant (no-op su SQLite/test)
await _set_rls_tenant_id(db, tenant_id)
# Carica utente
result = await db.execute(
+5 -1
View File
@@ -13,7 +13,7 @@ from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from slowapi.util import get_remote_address
from app.api.v1 import auth, mailboxes, permissions, tenants, users, ws
from app.api.v1 import auth, mailboxes, permissions, send, tenants, users, ws
from app.config import get_settings
from app.core.logging import get_logger, setup_logging
from app.database import engine
@@ -48,6 +48,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
await redis_task
except asyncio.CancelledError:
pass
# Chiudi pool arq se aperto
from app.services.send_service import close_arq_pool
await close_arq_pool()
await engine.dispose()
logger.info("🛑 PecFlow Backend fermato")
@@ -85,6 +88,7 @@ app.include_router(users.router, prefix=API_PREFIX)
app.include_router(tenants.router, prefix=API_PREFIX)
app.include_router(permissions.router, prefix=API_PREFIX)
app.include_router(mailboxes.router, prefix=API_PREFIX)
app.include_router(send.router, prefix=API_PREFIX)
app.include_router(ws.router, prefix=API_PREFIX)
+81
View File
@@ -0,0 +1,81 @@
"""
Schemi Pydantic per l'invio PEC (Fase 4).
"""
import uuid
from datetime import datetime
from pydantic import BaseModel, EmailStr, field_validator
class SendPecRequest(BaseModel):
"""
Richiesta di invio PEC.
Accettato come JSON body; gli allegati vengono gestiti
in una fase successiva tramite endpoint multipart dedicato.
"""
mailbox_id: uuid.UUID
"""Casella PEC mittente."""
to_addresses: list[EmailStr]
"""Destinatari principali (almeno uno)."""
cc_addresses: list[EmailStr] = []
"""Destinatari in copia (opzionale)."""
subject: str
"""Oggetto del messaggio."""
body_text: str = ""
"""Corpo in testo semplice."""
body_html: str | None = None
"""Corpo HTML (opzionale)."""
reply_to_message_id: uuid.UUID | None = None
"""UUID del messaggio a cui si risponde (per threading, opzionale)."""
@field_validator("to_addresses")
@classmethod
def at_least_one_recipient(cls, v: list[EmailStr]) -> list[EmailStr]:
if not v:
raise ValueError("Almeno un destinatario è obbligatorio")
return v
@field_validator("subject")
@classmethod
def subject_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("Il campo Oggetto non può essere vuoto")
return v.strip()
class SendJobResponse(BaseModel):
"""Stato di un job di invio PEC."""
model_config = {"from_attributes": True}
id: uuid.UUID
tenant_id: uuid.UUID
mailbox_id: uuid.UUID
message_id: uuid.UUID | None = None
status: str
"""Stato: pending | sending | sent | failed | retrying"""
attempt_count: int
max_attempts: int
next_retry_at: datetime | None = None
last_error: str | None = None
queued_at: datetime
sent_at: datetime | None = None
created_by: uuid.UUID | None = None
class SendJobListResponse(BaseModel):
"""Lista paginata di job di invio."""
items: list[SendJobResponse]
total: int
page: int
page_size: int
+250
View File
@@ -0,0 +1,250 @@
"""
SendService logica di business per l'invio PEC (Fase 4).
Responsabilità:
1. Valida permessi (check_can_send) sulla casella selezionata
2. Crea il record Message (direction=outbound, state=queued)
3. Crea il record SendJob (status=pending)
4. Enqueue il job arq 'send_pec' tramite il pool Redis/arq
5. Ritorna SendJobResponse
Il worker (arq) gestisce la connessione SMTP, il retry e la scrittura
del raw EML su MinIO.
"""
import asyncio
import uuid
from datetime import datetime, timezone
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import ForbiddenError, NotFoundError
from app.models.mailbox import Mailbox
from app.models.message import Message, SendJob
from app.models.user import User
from app.schemas.send import SendJobResponse, SendPecRequest
from app.services.permission_service import PermissionService
# ─── Pool arq (singleton lazy) ────────────────────────────────────────────────
# Usato per fare enqueue dei job send_pec senza avviare un worker nel backend.
_arq_pool = None
_arq_pool_lock = asyncio.Lock()
async def _get_arq_pool():
"""Restituisce il pool arq condiviso, creandolo se necessario."""
global _arq_pool
if _arq_pool is not None:
return _arq_pool
async with _arq_pool_lock:
if _arq_pool is None:
from arq import create_pool
from arq.connections import RedisSettings
from app.config import get_settings
settings = get_settings()
import urllib.parse
parsed = urllib.parse.urlparse(settings.redis_url)
rs = RedisSettings(
host=parsed.hostname or "localhost",
port=parsed.port or 6379,
database=int(parsed.path.lstrip("/") or "0"),
password=parsed.password or None,
)
_arq_pool = await create_pool(rs)
return _arq_pool
async def close_arq_pool() -> None:
"""Chiude il pool arq alla shutdown dell'applicazione."""
global _arq_pool
if _arq_pool is not None:
await _arq_pool.aclose()
_arq_pool = None
# ─── SendService ──────────────────────────────────────────────────────────────
class SendService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
# ── Crea e accoda un invio ────────────────────────────────────────────────
async def create_send_job(
self,
current_user: User,
data: SendPecRequest,
) -> SendJob:
"""
Crea Message + SendJob e accoda il job di invio.
Args:
current_user: utente autenticato che richiede l'invio
data: dati della PEC da inviare
Returns:
SendJob appena creato
Raises:
NotFoundError: casella non trovata o non appartenente al tenant
ForbiddenError: utente senza can_send sulla casella
"""
# ── Verifica casella ──────────────────────────────────────────────────
mailbox = await self.db.get(Mailbox, data.mailbox_id)
if (
not mailbox
or mailbox.tenant_id != current_user.tenant_id
or mailbox.status == "deleted"
):
raise NotFoundError("casella PEC mittente")
if mailbox.status != "active":
from app.core.exceptions import ForbiddenError
raise ForbiddenError(
f"La casella è in stato '{mailbox.status}' e non può inviare"
)
# ── Verifica permesso can_send ────────────────────────────────────────
if not current_user.is_admin:
perm_svc = PermissionService(self.db)
if not await perm_svc.check_can_send(current_user, data.mailbox_id):
raise ForbiddenError(
"Non hai il permesso di inviare da questa casella"
)
# ── Crea il messaggio outbound ────────────────────────────────────────
now = datetime.now(tz=timezone.utc)
message = Message(
tenant_id=current_user.tenant_id,
mailbox_id=data.mailbox_id,
direction="outbound",
pec_type="posta_certificata",
state="queued",
subject=data.subject,
from_address=mailbox.email_address,
to_addresses=[str(a) for a in data.to_addresses],
cc_addresses=[str(a) for a in data.cc_addresses] if data.cc_addresses else [],
body_text=data.body_text or "",
body_html=data.body_html,
has_attachments=False, # allegati in Fase 5
sent_at=None,
received_at=None,
)
# Collegamento a messaggio originale (per risposta/threading)
if data.reply_to_message_id:
parent = await self.db.get(Message, data.reply_to_message_id)
if parent and parent.tenant_id == current_user.tenant_id:
message.parent_message_id = data.reply_to_message_id
self.db.add(message)
await self.db.flush()
# ── Crea il SendJob ───────────────────────────────────────────────────
job = SendJob(
tenant_id=current_user.tenant_id,
mailbox_id=data.mailbox_id,
message_id=message.id,
status="pending",
attempt_count=0,
max_attempts=5,
created_by=current_user.id,
queued_at=now,
)
self.db.add(job)
await self.db.flush()
# ── Enqueue job arq ───────────────────────────────────────────────────
try:
arq_pool = await _get_arq_pool()
await arq_pool.enqueue_job("send_pec", str(job.id))
except Exception as e:
from app.core.logging import get_logger
logger = get_logger(__name__)
logger.warning(
f"[send_service] Impossibile enqueue send_pec job {job.id}: {e}. "
"Il job resterà in stato 'pending' per pickup manuale."
)
# Non alziamo eccezione: il job è nel DB e verrà processato
# dal cron di polling se disponibile
return job
# ── Lista job di invio ────────────────────────────────────────────────────
async def list_send_jobs(
self,
tenant_id: uuid.UUID,
page: int = 1,
page_size: int = 50,
mailbox_id: uuid.UUID | None = None,
status_filter: str | None = None,
) -> tuple[list[SendJob], int]:
"""Lista i job di invio del tenant con filtri opzionali."""
base_q = select(SendJob).where(SendJob.tenant_id == tenant_id)
if mailbox_id:
base_q = base_q.where(SendJob.mailbox_id == mailbox_id)
if status_filter:
base_q = base_q.where(SendJob.status == status_filter)
count_q = select(func.count()).select_from(base_q.subquery())
total = (await self.db.execute(count_q)).scalar_one()
items_q = (
base_q.order_by(SendJob.queued_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
result = await self.db.execute(items_q)
items = list(result.scalars().all())
return items, total
# ── Get singolo job ───────────────────────────────────────────────────────
async def get_send_job(
self,
job_id: uuid.UUID,
tenant_id: uuid.UUID,
) -> SendJob:
"""Carica un singolo SendJob verificando l'appartenenza al tenant."""
job = await self.db.get(SendJob, job_id)
if not job or job.tenant_id != tenant_id:
raise NotFoundError("job di invio")
return job
# ── Annulla job (solo se pending) ─────────────────────────────────────────
async def cancel_send_job(
self,
job_id: uuid.UUID,
tenant_id: uuid.UUID,
) -> SendJob:
"""
Annulla un job di invio se è ancora in stato 'pending'.
Non cancella il messaggio associato.
"""
job = await self.get_send_job(job_id, tenant_id)
if job.status not in ("pending", "retrying"):
raise ForbiddenError(
f"Impossibile annullare: il job è in stato '{job.status}'"
)
job.status = "failed"
job.last_error = "Annullato dall'utente"
# Aggiorna anche il messaggio
if job.message_id:
msg = await self.db.get(Message, job.message_id)
if msg and msg.state in ("queued", "draft"):
msg.state = "failed"
await self.db.flush()
return job
+6
View File
@@ -34,6 +34,12 @@ dependencies = [
# Rate limiting
"slowapi>=0.1.9",
# Job queue (client per enqueue job verso worker arq)
"arq>=0.26.1",
# SMTP async (per test connessione casella)
"aiosmtplib>=3.0.0",
# HTTP client
"httpx>=0.27.0",
Binary file not shown.
+59 -3
View File
@@ -22,6 +22,36 @@ os.environ["DATABASE_URL_SYNC"] = "sqlite:///./test_integration.db"
os.environ["APP_ENV"] = "development"
os.environ["APP_DEBUG"] = "false"
# ─── Compatibilità SQLite: tipi PostgreSQL non supportati ───────────────────
# SQLite non conosce INET, JSONB, ARRAY mappiamo a tipi base compatibili.
# Deve essere eseguito PRIMA di importare i modelli ORM.
from sqlalchemy.dialects.sqlite import base as _sqlite_base
def _visit_inet(self, type_, **kw): # noqa: ARG001
return "VARCHAR(45)"
def _visit_jsonb(self, type_, **kw): # noqa: ARG001
return "JSON"
def _visit_array(self, type_, **kw): # noqa: ARG001
# SQLite non ha array nativi; usiamo TEXT (serializzato come JSON)
return "TEXT"
def _visit_tsvector(self, type_, **kw): # noqa: ARG001
return "TEXT"
def _visit_tsquery(self, type_, **kw): # noqa: ARG001
return "TEXT"
_sqlite_base.SQLiteTypeCompiler.visit_INET = _visit_inet
_sqlite_base.SQLiteTypeCompiler.visit_JSONB = _visit_jsonb
_sqlite_base.SQLiteTypeCompiler.visit_ARRAY = _visit_array
_sqlite_base.SQLiteTypeCompiler.visit_TSVECTOR = _visit_tsvector
_sqlite_base.SQLiteTypeCompiler.visit_TSQUERY = _visit_tsquery
# ─────────────────────────────────────────────────────────────────────────────
from app.database import Base
from app.main import app
@@ -34,6 +64,26 @@ test_engine = create_async_engine(
connect_args={"check_same_thread": False},
)
# ─── Compatibilità SQLite: ARRAY come JSON in DML ────────────────────────────
# SQLite non può fare binding di Python list come parametri SQL;
# li convertiamo in JSON string prima dell'esecuzione.
import json as _json
from sqlalchemy import event as _sa_event
@_sa_event.listens_for(test_engine.sync_engine, "before_cursor_execute", retval=True)
def _sqlite_list_to_json(conn, cursor, statement, parameters, context, executemany):
"""Converte le liste Python in stringhe JSON per la compatibilità con SQLite."""
def _convert(params):
if isinstance(params, (list, tuple)):
return type(params)(_json.dumps(v) if isinstance(v, list) else v for v in params)
return params
if executemany:
return statement, [_convert(p) for p in parameters]
return statement, _convert(parameters)
# ─────────────────────────────────────────────────────────────────────────────
TestAsyncSessionLocal = async_sessionmaker(
bind=test_engine,
class_=AsyncSession,
@@ -91,12 +141,18 @@ async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
@pytest_asyncio.fixture
async def demo_tenant(db_session: AsyncSession):
"""Crea un tenant di test."""
"""
Crea un tenant di test con ID e slug univoci per ogni test.
Usiamo UUID randomici per evitare conflitti UNIQUE quando i test
vengono eseguiti nello stesso processo e il commit non viene rollbackato.
"""
from app.models.tenant import Tenant
tenant_id = uuid.uuid4()
tenant = Tenant(
id=uuid.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
slug="test-tenant",
id=tenant_id,
slug=f"test-{tenant_id.hex[:12]}",
name="Test Tenant",
plan="pro",
max_mailboxes=10,
+351
View File
@@ -0,0 +1,351 @@
"""
Test di integrazione API invio PEC (POST /send e GET /send/jobs).
Usa SQLite in-memory + mock dell'arq pool per evitare dipendenze esterne.
"""
import uuid
from unittest.mock import AsyncMock, patch
import pytest
import pytest_asyncio
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import encrypt_credential
# ─── Fixtures ─────────────────────────────────────────────────────────────────
@pytest_asyncio.fixture
async def active_mailbox(db_session: AsyncSession, demo_tenant):
"""Crea una casella PEC attiva nel tenant di test."""
from app.models.mailbox import Mailbox
mailbox = Mailbox(
tenant_id=demo_tenant.id,
email_address="test@pec.example.it",
display_name="Test PEC",
provider="test",
imap_host_enc=encrypt_credential("imap.example.it"),
imap_port_enc=encrypt_credential("993"),
imap_user_enc=encrypt_credential("test@pec.example.it"),
imap_pass_enc=encrypt_credential("secret"),
imap_use_ssl=True,
smtp_host_enc=encrypt_credential("smtp.example.it"),
smtp_port_enc=encrypt_credential("465"),
smtp_user_enc=encrypt_credential("test@pec.example.it"),
smtp_pass_enc=encrypt_credential("secret"),
smtp_use_tls=True,
status="active",
)
db_session.add(mailbox)
await db_session.commit()
await db_session.refresh(mailbox)
return mailbox
@pytest_asyncio.fixture
async def auth_headers(admin_token: str) -> dict:
"""Header Authorization con token admin."""
return {"Authorization": f"Bearer {admin_token}"}
# ─── Test POST /send ──────────────────────────────────────────────────────────
class TestCreateSendJob:
"""Test endpoint POST /api/v1/send."""
@pytest.mark.asyncio
async def test_send_requires_authentication(self, client: AsyncClient, active_mailbox):
"""Senza token → 401."""
response = await client.post(
"/api/v1/send",
json={
"mailbox_id": str(active_mailbox.id),
"to_addresses": ["dest@pec.it"],
"subject": "Test",
"body_text": "corpo",
},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_send_missing_to_addresses(
self, client: AsyncClient, auth_headers: dict, active_mailbox
):
"""Lista destinatari vuota → 422 validazione."""
with patch("app.services.send_service._get_arq_pool") as mock_pool:
mock_pool.return_value = AsyncMock()
mock_pool.return_value.enqueue_job = AsyncMock()
response = await client.post(
"/api/v1/send",
json={
"mailbox_id": str(active_mailbox.id),
"to_addresses": [],
"subject": "Test",
"body_text": "corpo",
},
headers=auth_headers,
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_send_missing_subject(
self, client: AsyncClient, auth_headers: dict, active_mailbox
):
"""Oggetto vuoto → 422 validazione."""
with patch("app.services.send_service._get_arq_pool") as mock_pool:
mock_pool.return_value = AsyncMock()
response = await client.post(
"/api/v1/send",
json={
"mailbox_id": str(active_mailbox.id),
"to_addresses": ["dest@pec.it"],
"subject": " ",
"body_text": "corpo",
},
headers=auth_headers,
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_send_mailbox_not_found(self, client: AsyncClient, auth_headers: dict):
"""Casella inesistente → 404."""
with patch("app.services.send_service._get_arq_pool") as mock_pool:
mock_pool.return_value = AsyncMock()
response = await client.post(
"/api/v1/send",
json={
"mailbox_id": str(uuid.uuid4()),
"to_addresses": ["dest@pec.it"],
"subject": "Test",
"body_text": "corpo",
},
headers=auth_headers,
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_send_success_creates_job(
self, client: AsyncClient, auth_headers: dict, active_mailbox
):
"""Invio valido → 201 con SendJobResponse."""
mock_arq = AsyncMock()
mock_arq.enqueue_job = AsyncMock(return_value=None)
with patch("app.services.send_service._get_arq_pool", return_value=mock_arq):
response = await client.post(
"/api/v1/send",
json={
"mailbox_id": str(active_mailbox.id),
"to_addresses": ["matteo1801@spidmail.it"],
"subject": "Test PecFlow Fase 4",
"body_text": "Messaggio di test inviato da PecFlow.",
},
headers=auth_headers,
)
assert response.status_code == 201
data = response.json()
assert data["status"] == "pending"
assert data["mailbox_id"] == str(active_mailbox.id)
assert data["attempt_count"] == 0
assert data["max_attempts"] == 5
assert "id" in data
# Verifica che arq sia stato chiamato
mock_arq.enqueue_job.assert_called_once()
call_args = mock_arq.enqueue_job.call_args
assert call_args[0][0] == "send_pec"
@pytest.mark.asyncio
async def test_send_with_cc(
self, client: AsyncClient, auth_headers: dict, active_mailbox
):
"""Invio con Cc → 201 con cc_addresses nel messaggio."""
mock_arq = AsyncMock()
mock_arq.enqueue_job = AsyncMock(return_value=None)
with patch("app.services.send_service._get_arq_pool", return_value=mock_arq):
response = await client.post(
"/api/v1/send",
json={
"mailbox_id": str(active_mailbox.id),
"to_addresses": ["dest@pec.it"],
"cc_addresses": ["cc@pec.it"],
"subject": "Test con Cc",
"body_text": "corpo",
},
headers=auth_headers,
)
assert response.status_code == 201
@pytest.mark.asyncio
async def test_send_arq_failure_still_returns_201(
self, client: AsyncClient, auth_headers: dict, active_mailbox
):
"""
Se arq fallisce (Redis down), il job viene comunque creato nel DB
e l'API risponde 201 (il job resta pending).
"""
mock_arq = AsyncMock()
mock_arq.enqueue_job = AsyncMock(side_effect=ConnectionError("Redis non disponibile"))
with patch("app.services.send_service._get_arq_pool", return_value=mock_arq):
response = await client.post(
"/api/v1/send",
json={
"mailbox_id": str(active_mailbox.id),
"to_addresses": ["dest@pec.it"],
"subject": "Test Redis down",
"body_text": "corpo",
},
headers=auth_headers,
)
# Il job deve essere creato anche se Redis fallisce
assert response.status_code == 201
assert response.json()["status"] == "pending"
# ─── Test GET /send/jobs ──────────────────────────────────────────────────────
class TestListSendJobs:
"""Test endpoint GET /api/v1/send/jobs."""
@pytest.mark.asyncio
async def test_list_requires_authentication(self, client: AsyncClient):
"""Senza token → 401."""
response = await client.get("/api/v1/send/jobs")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_list_returns_empty_for_new_tenant(
self, client: AsyncClient, auth_headers: dict
):
"""Tenant senza job → lista vuota."""
response = await client.get("/api/v1/send/jobs", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert "items" in data
assert "total" in data
@pytest.mark.asyncio
async def test_list_after_send(
self, client: AsyncClient, auth_headers: dict, active_mailbox
):
"""Dopo un invio, la lista deve contenere almeno un job."""
mock_arq = AsyncMock()
mock_arq.enqueue_job = AsyncMock(return_value=None)
# Crea un job
with patch("app.services.send_service._get_arq_pool", return_value=mock_arq):
await client.post(
"/api/v1/send",
json={
"mailbox_id": str(active_mailbox.id),
"to_addresses": ["dest@pec.it"],
"subject": "Job per lista",
"body_text": "corpo",
},
headers=auth_headers,
)
# Lista
response = await client.get("/api/v1/send/jobs", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["total"] >= 1
# ─── Test GET /send/jobs/{id} ─────────────────────────────────────────────────
class TestGetSendJob:
"""Test endpoint GET /api/v1/send/jobs/{job_id}."""
@pytest.mark.asyncio
async def test_get_nonexistent_job(self, client: AsyncClient, auth_headers: dict):
"""Job inesistente → 404."""
response = await client.get(
f"/api/v1/send/jobs/{uuid.uuid4()}", headers=auth_headers
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_get_existing_job(
self, client: AsyncClient, auth_headers: dict, active_mailbox
):
"""Recupera un job esistente."""
mock_arq = AsyncMock()
mock_arq.enqueue_job = AsyncMock(return_value=None)
# Crea
with patch("app.services.send_service._get_arq_pool", return_value=mock_arq):
create_resp = await client.post(
"/api/v1/send",
json={
"mailbox_id": str(active_mailbox.id),
"to_addresses": ["dest@pec.it"],
"subject": "Job da recuperare",
"body_text": "corpo",
},
headers=auth_headers,
)
assert create_resp.status_code == 201
job_id = create_resp.json()["id"]
# Recupera
response = await client.get(
f"/api/v1/send/jobs/{job_id}", headers=auth_headers
)
assert response.status_code == 200
assert response.json()["id"] == job_id
# ─── Test DELETE /send/jobs/{id} ─────────────────────────────────────────────
class TestCancelSendJob:
"""Test endpoint DELETE /api/v1/send/jobs/{job_id}."""
@pytest.mark.asyncio
async def test_cancel_pending_job(
self, client: AsyncClient, auth_headers: dict, active_mailbox
):
"""Annulla un job in stato pending → 204."""
mock_arq = AsyncMock()
mock_arq.enqueue_job = AsyncMock(return_value=None)
# Crea job
with patch("app.services.send_service._get_arq_pool", return_value=mock_arq):
create_resp = await client.post(
"/api/v1/send",
json={
"mailbox_id": str(active_mailbox.id),
"to_addresses": ["dest@pec.it"],
"subject": "Job da annullare",
"body_text": "corpo",
},
headers=auth_headers,
)
job_id = create_resp.json()["id"]
# Annulla
response = await client.delete(
f"/api/v1/send/jobs/{job_id}", headers=auth_headers
)
assert response.status_code == 204
# Verifica stato
get_resp = await client.get(
f"/api/v1/send/jobs/{job_id}", headers=auth_headers
)
assert get_resp.json()["status"] == "failed"
assert "Annullato" in get_resp.json()["last_error"]