65318b8c1b
Comprehensive test coverage for backend/app/api/v1/endpoints/mail_accounts.py: - CRUD operations (create, list, get, update, delete) - Subscription limit enforcement and superuser bypass - Account toggle with ERROR→ACTIVE status reset - Pull-now with disabled account guard - Test connection (new and existing) with decrypt failure handling - Auto-detect mail settings - Paginated processing runs and logs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
775 lines
27 KiB
Python
775 lines
27 KiB
Python
"""
|
|
Unit tests for mail account endpoints (backend/app/api/v1/endpoints/mail_accounts.py).
|
|
|
|
All tests mock the database session and auth dependencies so no real
|
|
PostgreSQL instance is required.
|
|
"""
|
|
|
|
import pytest
|
|
from datetime import datetime, timezone
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from httpx import AsyncClient, ASGITransport
|
|
|
|
from app.main import create_application
|
|
from app.core.database import get_db
|
|
from app.core.deps import get_current_active_user
|
|
from app.models.database_models import (
|
|
User,
|
|
MailAccount,
|
|
ProcessingRun,
|
|
ProcessingLog,
|
|
SubscriptionPlan,
|
|
SubscriptionTier,
|
|
AccountStatus,
|
|
)
|
|
|
|
BASE = "/api/v1/mail-accounts"
|
|
|
|
# ── helpers ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _make_user(**overrides) -> MagicMock:
|
|
"""Return a MagicMock that behaves like a User ORM instance."""
|
|
defaults = dict(
|
|
id=1,
|
|
email="user@example.com",
|
|
hashed_password="hashed",
|
|
full_name="Test User",
|
|
is_active=True,
|
|
is_superuser=False,
|
|
subscription_tier=SubscriptionTier.FREE,
|
|
subscription_status="active",
|
|
google_id=None,
|
|
oauth_provider=None,
|
|
last_login_at=None,
|
|
created_at=datetime.now(timezone.utc),
|
|
updated_at=datetime.now(timezone.utc),
|
|
stripe_customer_id=None,
|
|
stripe_subscription_id=None,
|
|
subscription_expires_at=None,
|
|
)
|
|
defaults.update(overrides)
|
|
user = MagicMock(spec=User)
|
|
for k, v in defaults.items():
|
|
setattr(user, k, v)
|
|
return user
|
|
|
|
|
|
def _make_superuser(**overrides) -> MagicMock:
|
|
return _make_user(is_superuser=True, **overrides)
|
|
|
|
|
|
def _make_account(**overrides) -> MagicMock:
|
|
"""Return a MagicMock that behaves like a MailAccount ORM instance."""
|
|
defaults = dict(
|
|
id=10,
|
|
user_id=1,
|
|
name="Test Account",
|
|
email_address="test@example.com",
|
|
protocol="pop3_ssl",
|
|
host="pop.example.com",
|
|
port=995,
|
|
use_ssl=True,
|
|
use_tls=False,
|
|
username="test@example.com",
|
|
encrypted_password="encrypted_pass",
|
|
forward_to="me@gmail.com",
|
|
delivery_method="gmail_api",
|
|
status="active",
|
|
is_enabled=True,
|
|
check_interval_minutes=5,
|
|
max_emails_per_check=50,
|
|
delete_after_forward=True,
|
|
provider_name="Gmail",
|
|
auto_detected=False,
|
|
total_emails_processed=100,
|
|
total_emails_failed=2,
|
|
last_check_at=datetime.now(timezone.utc),
|
|
last_successful_check_at=datetime.now(timezone.utc),
|
|
last_error_at=None,
|
|
last_error_message=None,
|
|
created_at=datetime.now(timezone.utc),
|
|
updated_at=datetime.now(timezone.utc),
|
|
)
|
|
defaults.update(overrides)
|
|
account = MagicMock(spec=MailAccount)
|
|
for k, v in defaults.items():
|
|
setattr(account, k, v)
|
|
return account
|
|
|
|
|
|
def _make_run(**overrides) -> MagicMock:
|
|
"""Return a MagicMock that behaves like a ProcessingRun ORM instance."""
|
|
defaults = dict(
|
|
id=100,
|
|
mail_account_id=10,
|
|
started_at=datetime.now(timezone.utc),
|
|
completed_at=datetime.now(timezone.utc),
|
|
duration_seconds=1.5,
|
|
emails_fetched=5,
|
|
emails_forwarded=4,
|
|
emails_failed=1,
|
|
status="completed",
|
|
error_message=None,
|
|
)
|
|
defaults.update(overrides)
|
|
run = MagicMock(spec=ProcessingRun)
|
|
for k, v in defaults.items():
|
|
setattr(run, k, v)
|
|
return run
|
|
|
|
|
|
def _make_log(**overrides) -> MagicMock:
|
|
"""Return a MagicMock that behaves like a ProcessingLog ORM instance."""
|
|
defaults = dict(
|
|
id=200,
|
|
user_id=1,
|
|
mail_account_id=10,
|
|
processing_run_id=100,
|
|
timestamp=datetime.now(timezone.utc),
|
|
level="INFO",
|
|
message="Processed email",
|
|
email_subject="Hello",
|
|
email_from="sender@example.com",
|
|
email_size_bytes=1024,
|
|
success=True,
|
|
error_details=None,
|
|
)
|
|
defaults.update(overrides)
|
|
log = MagicMock(spec=ProcessingLog)
|
|
for k, v in defaults.items():
|
|
setattr(log, k, v)
|
|
return log
|
|
|
|
|
|
def _scalar_one_or_none(value):
|
|
"""Create a mock result whose .scalar_one_or_none() returns *value*."""
|
|
result = MagicMock()
|
|
result.scalar_one_or_none.return_value = value
|
|
return result
|
|
|
|
|
|
def _scalar_one(value):
|
|
result = MagicMock()
|
|
result.scalar_one.return_value = value
|
|
return result
|
|
|
|
|
|
def _scalars_all(values):
|
|
result = MagicMock()
|
|
scalars = MagicMock()
|
|
scalars.all.return_value = values
|
|
result.scalars.return_value = scalars
|
|
return result
|
|
|
|
|
|
# ── fixtures ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.fixture
|
|
def app():
|
|
return create_application()
|
|
|
|
|
|
@pytest.fixture
|
|
def regular_user():
|
|
return _make_user()
|
|
|
|
|
|
@pytest.fixture
|
|
def superuser():
|
|
return _make_superuser()
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_db():
|
|
db = AsyncMock()
|
|
db.commit = AsyncMock()
|
|
db.refresh = AsyncMock()
|
|
db.delete = AsyncMock()
|
|
db.add = MagicMock()
|
|
return db
|
|
|
|
|
|
@pytest.fixture
|
|
async def client(app, regular_user, mock_db):
|
|
"""AsyncClient where the caller is a regular user and db is mocked."""
|
|
|
|
async def _override_user():
|
|
return regular_user
|
|
|
|
async def _override_db():
|
|
yield mock_db
|
|
|
|
app.dependency_overrides[get_current_active_user] = _override_user
|
|
app.dependency_overrides[get_db] = _override_db
|
|
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
|
yield c
|
|
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
@pytest.fixture
|
|
async def superuser_client(app, superuser, mock_db):
|
|
"""AsyncClient where the caller is a superuser and db is mocked."""
|
|
|
|
async def _override_user():
|
|
return superuser
|
|
|
|
async def _override_db():
|
|
yield mock_db
|
|
|
|
app.dependency_overrides[get_current_active_user] = _override_user
|
|
app.dependency_overrides[get_db] = _override_db
|
|
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
|
yield c
|
|
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
# ── account payload helper ───────────────────────────────────────────────
|
|
|
|
VALID_ACCOUNT_PAYLOAD = dict(
|
|
name="My Account",
|
|
email_address="inbox@example.com",
|
|
protocol="pop3_ssl",
|
|
host="pop.example.com",
|
|
port=995,
|
|
use_ssl=True,
|
|
use_tls=False,
|
|
username="inbox@example.com",
|
|
password="secret",
|
|
forward_to="me@gmail.com",
|
|
delivery_method="gmail_api",
|
|
is_enabled=True,
|
|
check_interval_minutes=5,
|
|
max_emails_per_check=50,
|
|
delete_after_forward=True,
|
|
provider_name="Gmail",
|
|
)
|
|
|
|
|
|
# ── tests: create mail account ──────────────────────────────────────────
|
|
|
|
|
|
class TestCreateMailAccount:
|
|
"""POST /api/v1/mail-accounts"""
|
|
|
|
@patch("app.api.v1.endpoints.mail_accounts.encrypt_credential", return_value="enc")
|
|
async def test_create_superuser_bypasses_limit(
|
|
self, mock_encrypt, superuser_client, mock_db
|
|
):
|
|
"""Superusers skip the subscription-limit check entirely."""
|
|
mock_db.refresh = AsyncMock(side_effect=lambda obj: None)
|
|
created = {}
|
|
|
|
def capture_add(obj):
|
|
created["obj"] = obj
|
|
# Give the added object all the response fields
|
|
for k, v in {
|
|
"id": 10,
|
|
"user_id": 1,
|
|
"status": "active",
|
|
"auto_detected": False,
|
|
"total_emails_processed": 0,
|
|
"total_emails_failed": 0,
|
|
"last_check_at": None,
|
|
"last_successful_check_at": None,
|
|
"last_error_at": None,
|
|
"last_error_message": None,
|
|
"created_at": datetime.now(timezone.utc),
|
|
"updated_at": datetime.now(timezone.utc),
|
|
}.items():
|
|
setattr(obj, k, v)
|
|
|
|
mock_db.add = MagicMock(side_effect=capture_add)
|
|
|
|
resp = await superuser_client.post(BASE, json=VALID_ACCOUNT_PAYLOAD)
|
|
assert resp.status_code == 201
|
|
data = resp.json()
|
|
assert data["name"] == "My Account"
|
|
assert data["email_address"] == "inbox@example.com"
|
|
mock_encrypt.assert_called_once_with("secret")
|
|
mock_db.commit.assert_called_once()
|
|
|
|
@patch("app.api.v1.endpoints.mail_accounts.encrypt_credential", return_value="enc")
|
|
async def test_create_regular_user_within_limit(
|
|
self, mock_encrypt, client, mock_db
|
|
):
|
|
"""Regular user under their plan limit can create an account."""
|
|
# 1st execute: count existing accounts (returns 0 accounts)
|
|
# 2nd execute: fetch subscription plan
|
|
plan = MagicMock(spec=SubscriptionPlan)
|
|
plan.max_mail_accounts = 5
|
|
|
|
mock_db.execute = AsyncMock(
|
|
side_effect=[
|
|
_scalars_all([]), # existing accounts
|
|
_scalar_one_or_none(plan), # subscription plan
|
|
]
|
|
)
|
|
|
|
def capture_add(obj):
|
|
for k, v in {
|
|
"id": 10,
|
|
"user_id": 1,
|
|
"status": "active",
|
|
"auto_detected": False,
|
|
"total_emails_processed": 0,
|
|
"total_emails_failed": 0,
|
|
"last_check_at": None,
|
|
"last_successful_check_at": None,
|
|
"last_error_at": None,
|
|
"last_error_message": None,
|
|
"created_at": datetime.now(timezone.utc),
|
|
"updated_at": datetime.now(timezone.utc),
|
|
}.items():
|
|
setattr(obj, k, v)
|
|
|
|
mock_db.add = MagicMock(side_effect=capture_add)
|
|
|
|
resp = await client.post(BASE, json=VALID_ACCOUNT_PAYLOAD)
|
|
assert resp.status_code == 201
|
|
mock_encrypt.assert_called_once_with("secret")
|
|
|
|
@patch("app.api.v1.endpoints.mail_accounts.encrypt_credential", return_value="enc")
|
|
async def test_create_subscription_limit_reached(
|
|
self, mock_encrypt, client, mock_db
|
|
):
|
|
"""402 when account limit is reached."""
|
|
existing = [_make_account(id=i) for i in range(1)]
|
|
plan = MagicMock(spec=SubscriptionPlan)
|
|
plan.max_mail_accounts = 1
|
|
|
|
mock_db.execute = AsyncMock(
|
|
side_effect=[
|
|
_scalars_all(existing), # existing accounts (1 already)
|
|
_scalar_one_or_none(plan), # plan says max=1
|
|
]
|
|
)
|
|
|
|
resp = await client.post(BASE, json=VALID_ACCOUNT_PAYLOAD)
|
|
assert resp.status_code == 402
|
|
assert "limit" in resp.json()["detail"].lower()
|
|
|
|
@patch("app.api.v1.endpoints.mail_accounts.encrypt_credential", return_value="enc")
|
|
async def test_create_uses_db_plan_limit(self, mock_encrypt, client, mock_db):
|
|
"""When a SubscriptionPlan exists in the DB, use its max_mail_accounts."""
|
|
plan = MagicMock(spec=SubscriptionPlan)
|
|
plan.max_mail_accounts = 3
|
|
|
|
existing = [_make_account(id=i) for i in range(3)]
|
|
mock_db.execute = AsyncMock(
|
|
side_effect=[
|
|
_scalars_all(existing), # 3 existing
|
|
_scalar_one_or_none(plan), # plan limit = 3
|
|
]
|
|
)
|
|
|
|
resp = await client.post(BASE, json=VALID_ACCOUNT_PAYLOAD)
|
|
assert resp.status_code == 402
|
|
|
|
@patch("app.api.v1.endpoints.mail_accounts.encrypt_credential", return_value="enc")
|
|
async def test_create_fallback_tier_limit_when_no_plan(
|
|
self, mock_encrypt, client, mock_db
|
|
):
|
|
"""When no SubscriptionPlan row exists, falls back to settings tier limits."""
|
|
mock_db.execute = AsyncMock(
|
|
side_effect=[
|
|
_scalars_all([]), # 0 existing accounts
|
|
_scalar_one_or_none(None), # no plan in DB → fallback
|
|
]
|
|
)
|
|
|
|
def capture_add(obj):
|
|
for k, v in {
|
|
"id": 10,
|
|
"user_id": 1,
|
|
"status": "active",
|
|
"auto_detected": False,
|
|
"total_emails_processed": 0,
|
|
"total_emails_failed": 0,
|
|
"last_check_at": None,
|
|
"last_successful_check_at": None,
|
|
"last_error_at": None,
|
|
"last_error_message": None,
|
|
"created_at": datetime.now(timezone.utc),
|
|
"updated_at": datetime.now(timezone.utc),
|
|
}.items():
|
|
setattr(obj, k, v)
|
|
|
|
mock_db.add = MagicMock(side_effect=capture_add)
|
|
|
|
resp = await client.post(BASE, json=VALID_ACCOUNT_PAYLOAD)
|
|
# free tier default is 1, and 0 existing → should succeed
|
|
assert resp.status_code == 201
|
|
|
|
|
|
# ── tests: list mail accounts ───────────────────────────────────────────
|
|
|
|
|
|
class TestListMailAccounts:
|
|
"""GET /api/v1/mail-accounts"""
|
|
|
|
async def test_list_accounts(self, client, mock_db):
|
|
accounts = [_make_account(id=1), _make_account(id=2)]
|
|
mock_db.execute = AsyncMock(return_value=_scalars_all(accounts))
|
|
|
|
resp = await client.get(BASE)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert len(data) == 2
|
|
|
|
|
|
# ── tests: get mail account ─────────────────────────────────────────────
|
|
|
|
|
|
class TestGetMailAccount:
|
|
"""GET /api/v1/mail-accounts/{account_id}"""
|
|
|
|
async def test_get_account_success(self, client, mock_db):
|
|
account = _make_account()
|
|
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(account))
|
|
|
|
resp = await client.get(f"{BASE}/10")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["id"] == 10
|
|
|
|
async def test_get_account_not_found(self, client, mock_db):
|
|
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
|
|
|
resp = await client.get(f"{BASE}/999")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
# ── tests: update mail account ──────────────────────────────────────────
|
|
|
|
|
|
class TestUpdateMailAccount:
|
|
"""PUT /api/v1/mail-accounts/{account_id}"""
|
|
|
|
async def test_update_account_success(self, client, mock_db):
|
|
account = _make_account()
|
|
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(account))
|
|
|
|
resp = await client.put(f"{BASE}/10", json={"name": "Updated Name"})
|
|
assert resp.status_code == 200
|
|
mock_db.commit.assert_called()
|
|
|
|
@patch(
|
|
"app.api.v1.endpoints.mail_accounts.encrypt_credential", return_value="new_enc"
|
|
)
|
|
async def test_update_account_with_password(self, mock_encrypt, client, mock_db):
|
|
account = _make_account()
|
|
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(account))
|
|
|
|
resp = await client.put(f"{BASE}/10", json={"password": "newpass"})
|
|
assert resp.status_code == 200
|
|
mock_encrypt.assert_called_once_with("newpass")
|
|
# Verify encrypted_password was set on the account
|
|
assert account.encrypted_password == "new_enc"
|
|
|
|
async def test_update_account_not_found(self, client, mock_db):
|
|
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
|
|
|
resp = await client.put(f"{BASE}/999", json={"name": "X"})
|
|
assert resp.status_code == 404
|
|
|
|
|
|
# ── tests: delete mail account ──────────────────────────────────────────
|
|
|
|
|
|
class TestDeleteMailAccount:
|
|
"""DELETE /api/v1/mail-accounts/{account_id}"""
|
|
|
|
async def test_delete_account_success(self, client, mock_db):
|
|
account = _make_account()
|
|
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(account))
|
|
|
|
resp = await client.delete(f"{BASE}/10")
|
|
assert resp.status_code == 204
|
|
mock_db.delete.assert_called_once_with(account)
|
|
mock_db.commit.assert_called()
|
|
|
|
async def test_delete_account_not_found(self, client, mock_db):
|
|
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
|
|
|
resp = await client.delete(f"{BASE}/999")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
# ── tests: toggle mail account ──────────────────────────────────────────
|
|
|
|
|
|
class TestToggleMailAccount:
|
|
"""PATCH /api/v1/mail-accounts/{account_id}/toggle"""
|
|
|
|
async def test_toggle_enable_resets_error(self, client, mock_db):
|
|
"""Toggling an ERROR account to enabled resets status to ACTIVE."""
|
|
account = _make_account(is_enabled=False, status=AccountStatus.ERROR)
|
|
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(account))
|
|
|
|
resp = await client.patch(f"{BASE}/10/toggle")
|
|
assert resp.status_code == 200
|
|
# After toggle: is_enabled=True and status reset from ERROR → ACTIVE
|
|
assert account.is_enabled is True
|
|
assert account.status == AccountStatus.ACTIVE
|
|
mock_db.commit.assert_called()
|
|
|
|
async def test_toggle_disable(self, client, mock_db):
|
|
"""Toggling an enabled account disables it."""
|
|
account = _make_account(is_enabled=True, status=AccountStatus.ACTIVE)
|
|
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(account))
|
|
|
|
resp = await client.patch(f"{BASE}/10/toggle")
|
|
assert resp.status_code == 200
|
|
assert account.is_enabled is False
|
|
|
|
async def test_toggle_not_found(self, client, mock_db):
|
|
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
|
|
|
resp = await client.patch(f"{BASE}/999/toggle")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
# ── tests: pull now ─────────────────────────────────────────────────────
|
|
|
|
|
|
class TestPullNow:
|
|
"""POST /api/v1/mail-accounts/{account_id}/pull-now"""
|
|
|
|
@patch("app.api.v1.endpoints.mail_accounts.process_mail_account_task")
|
|
async def test_pull_now_success(self, mock_task, client, mock_db):
|
|
account = _make_account(is_enabled=True)
|
|
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(account))
|
|
|
|
resp = await client.post(f"{BASE}/10/pull-now")
|
|
assert resp.status_code == 202
|
|
assert "queued" in resp.json()["message"].lower()
|
|
mock_task.delay.assert_called_once_with(10)
|
|
|
|
@patch("app.api.v1.endpoints.mail_accounts.process_mail_account_task")
|
|
async def test_pull_now_disabled_account(self, mock_task, client, mock_db):
|
|
account = _make_account(is_enabled=False)
|
|
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(account))
|
|
|
|
resp = await client.post(f"{BASE}/10/pull-now")
|
|
assert resp.status_code == 409
|
|
assert "disabled" in resp.json()["detail"].lower()
|
|
mock_task.delay.assert_not_called()
|
|
|
|
@patch("app.api.v1.endpoints.mail_accounts.process_mail_account_task")
|
|
async def test_pull_now_not_found(self, mock_task, client, mock_db):
|
|
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
|
|
|
resp = await client.post(f"{BASE}/999/pull-now")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
# ── tests: test connection (new) ────────────────────────────────────────
|
|
|
|
|
|
class TestTestConnection:
|
|
"""POST /api/v1/mail-accounts/test"""
|
|
|
|
@patch("app.api.v1.endpoints.mail_accounts.MailProcessor")
|
|
async def test_connection_success(self, mock_processor_cls, client):
|
|
instance = mock_processor_cls.return_value
|
|
instance.test_connection = AsyncMock(
|
|
return_value=(True, "Connection successful")
|
|
)
|
|
|
|
payload = dict(
|
|
host="pop.example.com",
|
|
port=995,
|
|
protocol="pop3_ssl",
|
|
username="user@example.com",
|
|
password="pass",
|
|
use_ssl=True,
|
|
use_tls=False,
|
|
)
|
|
resp = await client.post(f"{BASE}/test", json=payload)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["success"] is True
|
|
assert data["message"] == "Connection successful"
|
|
|
|
@patch("app.api.v1.endpoints.mail_accounts.MailProcessor")
|
|
async def test_connection_failure(self, mock_processor_cls, client):
|
|
instance = mock_processor_cls.return_value
|
|
instance.test_connection = AsyncMock(return_value=(False, "Connection refused"))
|
|
|
|
payload = dict(
|
|
host="pop.example.com",
|
|
port=995,
|
|
protocol="pop3_ssl",
|
|
username="user@example.com",
|
|
password="pass",
|
|
use_ssl=True,
|
|
use_tls=False,
|
|
)
|
|
resp = await client.post(f"{BASE}/test", json=payload)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["success"] is False
|
|
assert data["message"] == "Connection refused"
|
|
|
|
|
|
# ── tests: test existing connection ─────────────────────────────────────
|
|
|
|
|
|
class TestTestExistingConnection:
|
|
"""POST /api/v1/mail-accounts/{account_id}/test"""
|
|
|
|
@patch("app.api.v1.endpoints.mail_accounts.MailProcessor")
|
|
@patch(
|
|
"app.api.v1.endpoints.mail_accounts.decrypt_credential",
|
|
return_value="decrypted_pass",
|
|
)
|
|
async def test_existing_connection_success(
|
|
self, mock_decrypt, mock_processor_cls, client, mock_db
|
|
):
|
|
account = _make_account()
|
|
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(account))
|
|
|
|
instance = mock_processor_cls.return_value
|
|
instance.test_connection = AsyncMock(return_value=(True, "Connected"))
|
|
|
|
resp = await client.post(f"{BASE}/10/test")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["success"] is True
|
|
mock_decrypt.assert_called_once_with("encrypted_pass")
|
|
mock_processor_cls.assert_called_once_with(account, "decrypted_pass")
|
|
|
|
async def test_existing_connection_not_found(self, client, mock_db):
|
|
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
|
|
|
resp = await client.post(f"{BASE}/999/test")
|
|
assert resp.status_code == 404
|
|
|
|
@patch("app.api.v1.endpoints.mail_accounts.MailProcessor")
|
|
@patch(
|
|
"app.api.v1.endpoints.mail_accounts.decrypt_credential",
|
|
side_effect=Exception("Decryption failed"),
|
|
)
|
|
async def test_existing_connection_decrypt_failure(
|
|
self, mock_decrypt, mock_processor_cls, client, mock_db
|
|
):
|
|
account = _make_account()
|
|
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(account))
|
|
|
|
resp = await client.post(f"{BASE}/10/test")
|
|
assert resp.status_code == 500
|
|
assert "decrypt" in resp.json()["detail"].lower()
|
|
|
|
|
|
# ── tests: auto-detect ──────────────────────────────────────────────────
|
|
|
|
|
|
class TestAutoDetect:
|
|
"""POST /api/v1/mail-accounts/auto-detect"""
|
|
|
|
@patch("app.api.v1.endpoints.mail_accounts.MailServerAutoDetect")
|
|
async def test_auto_detect_success(self, mock_auto_cls, client):
|
|
mock_auto_cls.detect.return_value = [
|
|
{"host": "pop.gmail.com", "port": 995, "protocol": "pop3_ssl"}
|
|
]
|
|
|
|
resp = await client.post(
|
|
f"{BASE}/auto-detect",
|
|
json={"email_address": "user@gmail.com"},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["success"] is True
|
|
assert len(data["suggestions"]) == 1
|
|
mock_auto_cls.detect.assert_called_once_with("user@gmail.com")
|
|
|
|
@patch("app.api.v1.endpoints.mail_accounts.MailServerAutoDetect")
|
|
async def test_auto_detect_no_suggestions(self, mock_auto_cls, client):
|
|
mock_auto_cls.detect.return_value = []
|
|
|
|
resp = await client.post(
|
|
f"{BASE}/auto-detect",
|
|
json={"email_address": "user@unknown-domain.xyz"},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["success"] is False
|
|
assert data["suggestions"] == []
|
|
|
|
|
|
# ── tests: processing runs ──────────────────────────────────────────────
|
|
|
|
|
|
class TestListProcessingRuns:
|
|
"""GET /api/v1/mail-accounts/{account_id}/processing-runs"""
|
|
|
|
async def test_list_runs_success(self, client, mock_db):
|
|
account = _make_account()
|
|
run = _make_run()
|
|
|
|
mock_db.execute = AsyncMock(
|
|
side_effect=[
|
|
_scalar_one_or_none(account), # ownership check
|
|
_scalar_one(1), # count
|
|
_scalars_all([run]), # run data
|
|
]
|
|
)
|
|
|
|
resp = await client.get(f"{BASE}/10/processing-runs")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["total"] == 1
|
|
assert data["page"] == 1
|
|
assert len(data["items"]) == 1
|
|
assert data["items"][0]["id"] == 100
|
|
assert data["items"][0]["account_name"] == "Test Account"
|
|
assert data["items"][0]["account_email"] == "test@example.com"
|
|
|
|
async def test_list_runs_account_not_found(self, client, mock_db):
|
|
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
|
|
|
resp = await client.get(f"{BASE}/999/processing-runs")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
# ── tests: processing logs ──────────────────────────────────────────────
|
|
|
|
|
|
class TestListProcessingLogs:
|
|
"""GET /api/v1/mail-accounts/{account_id}/logs"""
|
|
|
|
async def test_list_logs_success(self, client, mock_db):
|
|
account = _make_account()
|
|
log = _make_log()
|
|
|
|
mock_db.execute = AsyncMock(
|
|
side_effect=[
|
|
_scalar_one_or_none(account), # ownership check
|
|
_scalar_one(1), # count
|
|
_scalars_all([log]), # log data
|
|
]
|
|
)
|
|
|
|
resp = await client.get(f"{BASE}/10/logs")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["total"] == 1
|
|
assert len(data["items"]) == 1
|
|
assert data["items"][0]["id"] == 200
|
|
assert data["items"][0]["level"] == "INFO"
|
|
assert data["items"][0]["message"] == "Processed email"
|
|
|
|
async def test_list_logs_account_not_found(self, client, mock_db):
|
|
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
|
|
|
resp = await client.get(f"{BASE}/999/logs")
|
|
assert resp.status_code == 404
|