mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
fase 4
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user