Files
PecHub/worker/tests/integration/test_imap_sync.py
T
2026-03-18 17:30:13 +01:00

271 lines
8.4 KiB
Python

"""
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()