mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
Fase 2
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
"""
|
||||
Test di integrazione IMAP con GreenMail (server IMAP mock via Docker).
|
||||
|
||||
Prerequisiti:
|
||||
- GreenMail in esecuzione: docker compose --profile greenmail up -d greenmail
|
||||
- Endpoint IMAP: localhost:3143 (plain) o localhost:3993 (SSL)
|
||||
- Credenziali test: test@example.com / secret
|
||||
|
||||
Esecuzione:
|
||||
cd worker && pytest tests/integration/test_imap_sync.py -v
|
||||
|
||||
I test sono contrassegnati con @pytest.mark.integration e saltati se
|
||||
GREENMAIL_HOST non è raggiungibile.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import socket
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ─── Skip automatico se GreenMail non disponibile ─────────────────────────────
|
||||
|
||||
GREENMAIL_HOST = os.getenv("GREENMAIL_HOST", "localhost")
|
||||
GREENMAIL_IMAP_PORT = int(os.getenv("GREENMAIL_IMAP_PORT", "3143"))
|
||||
|
||||
|
||||
def _is_greenmail_running() -> bool:
|
||||
"""Verifica se GreenMail IMAP è raggiungibile."""
|
||||
try:
|
||||
with socket.create_connection((GREENMAIL_HOST, GREENMAIL_IMAP_PORT), timeout=2):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
skip_if_no_greenmail = pytest.mark.skipif(
|
||||
not _is_greenmail_running(),
|
||||
reason=f"GreenMail non disponibile su {GREENMAIL_HOST}:{GREENMAIL_IMAP_PORT}",
|
||||
)
|
||||
|
||||
# ─── Fixture: mailbox mock ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def make_mock_mailbox(
|
||||
email_address: str = "test@example.com",
|
||||
host: str = GREENMAIL_HOST,
|
||||
port: int = GREENMAIL_IMAP_PORT,
|
||||
user: str = "test@example.com",
|
||||
password: str = "secret",
|
||||
use_ssl: bool = False,
|
||||
last_sync_uid: int | None = None,
|
||||
) -> MagicMock:
|
||||
"""Crea un mock di Mailbox con credenziali cifrate per GreenMail."""
|
||||
from app.imap.connection import _decrypt
|
||||
from app.config import get_settings
|
||||
import base64
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
settings = get_settings()
|
||||
key = settings.encryption_key_bytes
|
||||
aesgcm = AESGCM(key)
|
||||
|
||||
def encrypt(value: str) -> str:
|
||||
import os as _os
|
||||
nonce = _os.urandom(12)
|
||||
ct = aesgcm.encrypt(nonce, value.encode(), None)
|
||||
return base64.b64encode(nonce + ct).decode()
|
||||
|
||||
mailbox = MagicMock()
|
||||
mailbox.id = uuid.uuid4()
|
||||
mailbox.tenant_id = uuid.uuid4()
|
||||
mailbox.email_address = email_address
|
||||
mailbox.status = "active"
|
||||
mailbox.last_sync_uid = last_sync_uid
|
||||
mailbox.sync_error_count = 0
|
||||
mailbox.sync_error_msg = None
|
||||
|
||||
mailbox.imap_host_enc = encrypt(host)
|
||||
mailbox.imap_port_enc = encrypt(str(port))
|
||||
mailbox.imap_user_enc = encrypt(user)
|
||||
mailbox.imap_pass_enc = encrypt(password)
|
||||
mailbox.imap_use_ssl = use_ssl
|
||||
|
||||
return mailbox
|
||||
|
||||
|
||||
# ─── Test: connessione IMAP a GreenMail ──────────────────────────────────────
|
||||
|
||||
@skip_if_no_greenmail
|
||||
@pytest.mark.asyncio
|
||||
async def test_imap_connect_greenmail():
|
||||
"""Verifica che IMAPConnection si connetta correttamente a GreenMail."""
|
||||
import aioimaplib
|
||||
from app.imap.connection import IMAPConnection
|
||||
|
||||
mailbox = make_mock_mailbox()
|
||||
creds = IMAPConnection._decrypt_creds(mailbox)
|
||||
|
||||
conn = IMAPConnection.__new__(IMAPConnection)
|
||||
client = await conn._connect(creds)
|
||||
|
||||
assert client is not None
|
||||
|
||||
# Verifica che SELECT INBOX abbia funzionato
|
||||
status, _ = await client.select("INBOX")
|
||||
assert status == "OK"
|
||||
|
||||
await client.logout()
|
||||
|
||||
|
||||
@skip_if_no_greenmail
|
||||
@pytest.mark.asyncio
|
||||
async def test_imap_search_empty_inbox():
|
||||
"""SEARCH su inbox vuota restituisce lista vuota."""
|
||||
import aioimaplib
|
||||
from app.imap.connection import IMAPConnection
|
||||
|
||||
mailbox = make_mock_mailbox(last_sync_uid=0)
|
||||
creds = IMAPConnection._decrypt_creds(mailbox)
|
||||
|
||||
conn = IMAPConnection.__new__(IMAPConnection)
|
||||
client = await conn._connect(creds)
|
||||
|
||||
# Cerca UID > 0 (tutti)
|
||||
status, data = await client.uid("SEARCH", "UID", "1:*")
|
||||
# Non deve sollevare eccezione
|
||||
assert status in ("OK", "NO")
|
||||
|
||||
await client.logout()
|
||||
|
||||
|
||||
# ─── Test: sync con DB mockato ────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_new_messages_empty():
|
||||
"""sync_new_messages con inbox vuota restituisce 0."""
|
||||
from app.imap.sync import sync_new_messages
|
||||
|
||||
# Mock IMAP client che risponde con lista vuota
|
||||
mock_client = AsyncMock()
|
||||
mock_client.uid = AsyncMock(return_value=("OK", [b""]))
|
||||
|
||||
# Mock mailbox
|
||||
mailbox = MagicMock()
|
||||
mailbox.id = uuid.uuid4()
|
||||
mailbox.tenant_id = uuid.uuid4()
|
||||
mailbox.email_address = "test@example.com"
|
||||
mailbox.last_sync_uid = 0
|
||||
|
||||
# Mock DB
|
||||
mock_db = AsyncMock()
|
||||
mock_db.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=lambda: None))
|
||||
|
||||
# Mock Redis
|
||||
mock_redis = AsyncMock()
|
||||
|
||||
n = await sync_new_messages(mock_client, mailbox, mock_db, mock_redis)
|
||||
assert n == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_skips_duplicate_uid():
|
||||
"""sync_new_messages salta UID già presenti nel DB."""
|
||||
from app.imap.sync import sync_new_messages
|
||||
from sqlalchemy.engine import Result
|
||||
|
||||
existing_uid = 42
|
||||
|
||||
mock_client = AsyncMock()
|
||||
# SEARCH restituisce UID 42
|
||||
mock_client.uid = AsyncMock(return_value=("OK", [f"{existing_uid}".encode()]))
|
||||
|
||||
mailbox = MagicMock()
|
||||
mailbox.id = uuid.uuid4()
|
||||
mailbox.tenant_id = uuid.uuid4()
|
||||
mailbox.email_address = "test@example.com"
|
||||
mailbox.last_sync_uid = 41
|
||||
|
||||
# DB: UID 42 già presente
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = uuid.uuid4() # esiste già
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_db.execute = AsyncMock(return_value=mock_result)
|
||||
mock_db.flush = AsyncMock()
|
||||
mock_db.commit = AsyncMock()
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
|
||||
n = await sync_new_messages(mock_client, mailbox, mock_db, mock_redis)
|
||||
# Deve restituire 0 perché il messaggio è già in DB
|
||||
assert n == 0
|
||||
|
||||
|
||||
# ─── Test: parsing EML ────────────────────────────────────────────────────────
|
||||
|
||||
def test_parse_pec_message():
|
||||
"""Parsing di un EML PEC tipico."""
|
||||
from app.imap.sync import _parse_eml
|
||||
|
||||
raw = b"""From: mittente@aruba.pec.it
|
||||
To: destinatario@pec.it
|
||||
Subject: Comunicazione ufficiale n. 2026/001
|
||||
Date: Wed, 18 Mar 2026 09:00:00 +0100
|
||||
Message-ID: <20260318090000.1234@aruba.pec.it>
|
||||
X-Ricevuta: posta-certificata
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
Gentile destinatario,
|
||||
in riferimento alla pratica n. 2026/001...
|
||||
"""
|
||||
|
||||
parsed = _parse_eml(raw)
|
||||
assert parsed["subject"] == "Comunicazione ufficiale n. 2026/001"
|
||||
assert parsed["from_address"] == "mittente@aruba.pec.it"
|
||||
assert parsed["pec_type"] == "posta_certificata"
|
||||
assert parsed["sent_at"] is not None
|
||||
assert "destinatario@pec.it" in parsed["to_addresses"]
|
||||
|
||||
|
||||
def test_parse_ricevuta_avvenuta_consegna():
|
||||
"""Parsing di una ricevuta di avvenuta consegna."""
|
||||
from app.imap.sync import _parse_eml
|
||||
|
||||
raw = b"""From: posta-certificata@aruba.pec.it
|
||||
To: mittente@aruba.pec.it
|
||||
Subject: AVVENUTA CONSEGNA: Comunicazione n. 001
|
||||
Date: Wed, 18 Mar 2026 09:05:00 +0100
|
||||
Message-ID: <ricevuta.001@aruba.pec.it>
|
||||
X-Riferimento-Message-ID: <20260318090000.1234@aruba.pec.it>
|
||||
X-TipoRicevuta: avvenuta-consegna
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
Il messaggio e' stato consegnato.
|
||||
"""
|
||||
|
||||
parsed = _parse_eml(raw)
|
||||
assert parsed["pec_type"] == "avvenuta_consegna"
|
||||
assert "AVVENUTA CONSEGNA" in parsed["subject"]
|
||||
|
||||
|
||||
# ─── Test: backoff con connessione reale ─────────────────────────────────────
|
||||
|
||||
@skip_if_no_greenmail
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconnect_after_logout():
|
||||
"""Il sistema si riconnette dopo una disconnessione forzata."""
|
||||
from app.imap.connection import IMAPConnection
|
||||
|
||||
mailbox = make_mock_mailbox()
|
||||
creds = IMAPConnection._decrypt_creds(mailbox)
|
||||
|
||||
conn = IMAPConnection.__new__(IMAPConnection)
|
||||
|
||||
# Prima connessione
|
||||
client1 = await conn._connect(creds)
|
||||
assert client1 is not None
|
||||
|
||||
# Disconnessione forzata
|
||||
await client1.logout()
|
||||
|
||||
# Seconda connessione (simula riconnessione)
|
||||
client2 = await conn._connect(creds)
|
||||
assert client2 is not None
|
||||
|
||||
await client2.logout()
|
||||
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Unit test: ExponentialBackoff
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from app.imap.reconnect import ExponentialBackoff
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backoff_increases():
|
||||
"""Il tempo di attesa aumenta ad ogni tentativo."""
|
||||
backoff = ExponentialBackoff(label="test", jitter=False)
|
||||
|
||||
# Registra i tempi senza aspettare davvero (patch asyncio.sleep)
|
||||
waits = []
|
||||
|
||||
original_sleep = asyncio.sleep
|
||||
|
||||
async def fake_sleep(t):
|
||||
waits.append(t)
|
||||
|
||||
asyncio.sleep = fake_sleep
|
||||
try:
|
||||
await backoff.wait()
|
||||
await backoff.wait()
|
||||
await backoff.wait()
|
||||
finally:
|
||||
asyncio.sleep = original_sleep
|
||||
|
||||
assert waits[1] > waits[0], "Il secondo wait deve essere maggiore del primo"
|
||||
assert waits[2] > waits[1], "Il terzo wait deve essere maggiore del secondo"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backoff_reset():
|
||||
"""reset() riporta il contatore a zero e il wait riparte dal valore iniziale."""
|
||||
backoff = ExponentialBackoff(label="test", jitter=False)
|
||||
waits = []
|
||||
|
||||
async def fake_sleep(t):
|
||||
waits.append(t)
|
||||
|
||||
original_sleep = asyncio.sleep
|
||||
asyncio.sleep = fake_sleep
|
||||
try:
|
||||
await backoff.wait() # waits[0]: valore iniziale (es. 1.0)
|
||||
await backoff.wait() # waits[1]: valore incrementato (es. 2.0)
|
||||
backoff.reset()
|
||||
await backoff.wait() # waits[2]: deve tornare al valore iniziale
|
||||
finally:
|
||||
asyncio.sleep = original_sleep
|
||||
|
||||
# Dopo reset il wait riparte dal valore iniziale
|
||||
assert backoff.attempt == 1
|
||||
assert waits[2] == waits[0], (
|
||||
f"Dopo reset il wait deve tornare a {waits[0]}, ma era {waits[2]}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backoff_max():
|
||||
"""Il tempo di attesa non supera backoff_max_seconds."""
|
||||
from app.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
backoff = ExponentialBackoff(label="test", jitter=False)
|
||||
max_recorded = 0.0
|
||||
|
||||
async def fake_sleep(t):
|
||||
nonlocal max_recorded
|
||||
max_recorded = max(max_recorded, t)
|
||||
|
||||
original_sleep = asyncio.sleep
|
||||
asyncio.sleep = fake_sleep
|
||||
try:
|
||||
for _ in range(20):
|
||||
await backoff.wait()
|
||||
finally:
|
||||
asyncio.sleep = original_sleep
|
||||
|
||||
assert max_recorded <= settings.backoff_max_seconds + 0.1
|
||||
|
||||
|
||||
def test_backoff_attempt_count():
|
||||
"""Il contatore tentativi si incrementa correttamente."""
|
||||
import asyncio
|
||||
backoff = ExponentialBackoff(label="test", jitter=False)
|
||||
|
||||
async def run():
|
||||
async def fake_sleep(_):
|
||||
pass
|
||||
|
||||
asyncio.sleep = fake_sleep
|
||||
await backoff.wait()
|
||||
await backoff.wait()
|
||||
await backoff.wait()
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(run())
|
||||
assert backoff.attempt == 3
|
||||
@@ -0,0 +1,197 @@
|
||||
"""
|
||||
Unit test: parsing EML e classificazione tipo PEC.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.imap.sync import _decode_header, _extract_addresses, _parse_date, _parse_eml, _classify_pec_type
|
||||
import email
|
||||
|
||||
|
||||
# ─── Test _decode_header ─────────────────────────────────────────────────────
|
||||
|
||||
def test_decode_header_plain():
|
||||
assert _decode_header("Hello World") == "Hello World"
|
||||
|
||||
|
||||
def test_decode_header_none():
|
||||
assert _decode_header(None) is None
|
||||
|
||||
|
||||
def test_decode_header_encoded():
|
||||
# Header UTF-8 base64 encoded
|
||||
encoded = "=?utf-8?b?UEVDIHRlc3Q=?=" # "PEC test"
|
||||
assert _decode_header(encoded) == "PEC test"
|
||||
|
||||
|
||||
def test_decode_header_encoded_iso():
|
||||
# Header ISO-8859-1 quoted printable
|
||||
encoded = "=?iso-8859-1?q?Multa_n=2E_123?=" # "Multa n. 123"
|
||||
result = _decode_header(encoded)
|
||||
assert result is not None
|
||||
assert "Multa" in result
|
||||
|
||||
|
||||
# ─── Test _extract_addresses ──────────────────────────────────────────────────
|
||||
|
||||
def test_extract_addresses_single():
|
||||
addrs = _extract_addresses("test@example.com")
|
||||
assert "test@example.com" in addrs
|
||||
|
||||
|
||||
def test_extract_addresses_multiple():
|
||||
addrs = _extract_addresses("a@x.com, b@y.com, c@z.com")
|
||||
assert len(addrs) == 3
|
||||
assert "a@x.com" in addrs
|
||||
assert "b@y.com" in addrs
|
||||
|
||||
|
||||
def test_extract_addresses_with_display_name():
|
||||
addrs = _extract_addresses('"Mario Rossi" <mario@comune.it>')
|
||||
assert "mario@comune.it" in addrs
|
||||
|
||||
|
||||
def test_extract_addresses_empty():
|
||||
assert _extract_addresses(None) == []
|
||||
assert _extract_addresses("") == []
|
||||
|
||||
|
||||
# ─── Test _parse_date ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_parse_date_valid():
|
||||
date = _parse_date("Wed, 18 Mar 2026 14:00:00 +0100")
|
||||
assert date is not None
|
||||
assert date.year == 2026
|
||||
assert date.month == 3
|
||||
assert date.day == 18
|
||||
|
||||
|
||||
def test_parse_date_none():
|
||||
assert _parse_date(None) is None
|
||||
|
||||
|
||||
def test_parse_date_invalid():
|
||||
assert _parse_date("not-a-date") is None
|
||||
|
||||
|
||||
# ─── Test _classify_pec_type ──────────────────────────────────────────────────
|
||||
|
||||
def test_classify_pec_type_avvenuta_consegna():
|
||||
msg = email.message_from_string(
|
||||
"From: server@pec.it\r\n"
|
||||
"X-TipoRicevuta: avvenuta-consegna\r\n"
|
||||
"\r\n"
|
||||
)
|
||||
assert _classify_pec_type(msg) == "avvenuta_consegna"
|
||||
|
||||
|
||||
def test_classify_pec_type_accettazione():
|
||||
msg = email.message_from_string(
|
||||
"From: server@pec.it\r\n"
|
||||
"X-Ricevuta: accettazione\r\n"
|
||||
"\r\n"
|
||||
)
|
||||
assert _classify_pec_type(msg) == "accettazione"
|
||||
|
||||
|
||||
def test_classify_pec_type_posta_certificata():
|
||||
msg = email.message_from_string(
|
||||
"From: mittente@pec.it\r\n"
|
||||
"Subject: Messaggio PEC\r\n"
|
||||
"\r\n"
|
||||
)
|
||||
assert _classify_pec_type(msg) == "posta_certificata"
|
||||
|
||||
|
||||
def test_classify_pec_type_x_tipo_prevalente():
|
||||
"""X-TipoRicevuta ha precedenza su X-Ricevuta."""
|
||||
msg = email.message_from_string(
|
||||
"From: server@pec.it\r\n"
|
||||
"X-Ricevuta: accettazione\r\n"
|
||||
"X-TipoRicevuta: avvenuta-consegna\r\n"
|
||||
"\r\n"
|
||||
)
|
||||
assert _classify_pec_type(msg) == "avvenuta_consegna"
|
||||
|
||||
|
||||
# ─── Test _parse_eml completo ─────────────────────────────────────────────────
|
||||
|
||||
RAW_EML = b"""From: mittente@pec.it
|
||||
To: destinatario@pec.it
|
||||
Cc: copia@pec.it
|
||||
Subject: Test PEC Fase 2
|
||||
Message-ID: <test123@pec.it>
|
||||
Date: Wed, 18 Mar 2026 10:00:00 +0100
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
Corpo del messaggio di test.
|
||||
"""
|
||||
|
||||
|
||||
def test_parse_eml_subject():
|
||||
parsed = _parse_eml(RAW_EML)
|
||||
assert parsed["subject"] == "Test PEC Fase 2"
|
||||
|
||||
|
||||
def test_parse_eml_from():
|
||||
parsed = _parse_eml(RAW_EML)
|
||||
assert parsed["from_address"] == "mittente@pec.it"
|
||||
|
||||
|
||||
def test_parse_eml_to():
|
||||
parsed = _parse_eml(RAW_EML)
|
||||
assert "destinatario@pec.it" in parsed["to_addresses"]
|
||||
|
||||
|
||||
def test_parse_eml_cc():
|
||||
parsed = _parse_eml(RAW_EML)
|
||||
assert "copia@pec.it" in parsed["cc_addresses"]
|
||||
|
||||
|
||||
def test_parse_eml_message_id():
|
||||
parsed = _parse_eml(RAW_EML)
|
||||
assert parsed["message_id_header"] == "<test123@pec.it>"
|
||||
|
||||
|
||||
def test_parse_eml_body_text():
|
||||
parsed = _parse_eml(RAW_EML)
|
||||
assert "Corpo del messaggio" in (parsed.get("body_text") or "")
|
||||
|
||||
|
||||
def test_parse_eml_no_attachments():
|
||||
parsed = _parse_eml(RAW_EML)
|
||||
assert parsed["has_attachments"] is False
|
||||
|
||||
|
||||
RAW_EML_WITH_ATTACHMENT = b"""From: mittente@pec.it
|
||||
To: destinatario@pec.it
|
||||
Subject: PEC con allegato
|
||||
Date: Wed, 18 Mar 2026 10:00:00 +0100
|
||||
Content-Type: multipart/mixed; boundary="----boundary123"
|
||||
|
||||
------boundary123
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
Testo del messaggio.
|
||||
|
||||
------boundary123
|
||||
Content-Type: application/pdf; name="documento.pdf"
|
||||
Content-Disposition: attachment; filename="documento.pdf"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
JVBERi0xLjQ=
|
||||
|
||||
------boundary123--
|
||||
"""
|
||||
|
||||
|
||||
def test_parse_eml_with_attachment():
|
||||
parsed = _parse_eml(RAW_EML_WITH_ATTACHMENT)
|
||||
assert parsed["has_attachments"] is True
|
||||
assert "Testo del messaggio" in (parsed.get("body_text") or "")
|
||||
|
||||
|
||||
def test_parse_eml_empty():
|
||||
parsed = _parse_eml(b"")
|
||||
# Non deve sollevare eccezione
|
||||
assert isinstance(parsed, dict)
|
||||
Reference in New Issue
Block a user