43bcfe5653
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
571 lines
20 KiB
Python
571 lines
20 KiB
Python
"""Tests for the per-user IMAP accounts API (app/api/imap_accounts.py)."""
|
||
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
from sqlalchemy import create_engine
|
||
from sqlalchemy.orm import sessionmaker
|
||
from sqlalchemy.pool import StaticPool
|
||
|
||
from app.database import Base, get_db
|
||
from app.models import SubscriptionPlan, UserImapAccount, UserProfile
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Test data constants
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_OWNER = "test_user@example.com"
|
||
_BASIC_ACCOUNT = {
|
||
"name": "Test Mailbox",
|
||
"host": "imap.example.com",
|
||
"port": 993,
|
||
"username": "user@example.com",
|
||
"password": "s3cr3t", # noqa: S105
|
||
"use_ssl": True,
|
||
"delete_after_process": False,
|
||
"is_active": True,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Shared fixture helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.fixture()
|
||
def imap_engine():
|
||
"""In-memory SQLite engine for IMAP account tests."""
|
||
engine = create_engine(
|
||
"sqlite:///:memory:",
|
||
connect_args={"check_same_thread": False},
|
||
poolclass=StaticPool,
|
||
)
|
||
Base.metadata.create_all(bind=engine)
|
||
yield engine
|
||
Base.metadata.drop_all(bind=engine)
|
||
|
||
|
||
@pytest.fixture()
|
||
def imap_session(imap_engine):
|
||
"""DB session scoped to one test."""
|
||
Session = sessionmaker(bind=imap_engine)
|
||
session = Session()
|
||
yield session
|
||
session.close()
|
||
|
||
|
||
def _make_client(imap_engine, owner_id: str = _OWNER):
|
||
"""Return a TestClient that injects *owner_id* as the authenticated user."""
|
||
from app.api.imap_accounts import _get_owner_id
|
||
from app.main import app
|
||
|
||
def override_db():
|
||
Session = sessionmaker(bind=imap_engine)
|
||
session = Session()
|
||
try:
|
||
yield session
|
||
finally:
|
||
session.close()
|
||
|
||
def override_owner():
|
||
return owner_id
|
||
|
||
app.dependency_overrides[get_db] = override_db
|
||
app.dependency_overrides[_get_owner_id] = override_owner
|
||
return app, override_db, override_owner
|
||
|
||
|
||
def _make_anon_client(imap_engine):
|
||
"""Return a TestClient without an authenticated user (401 expected)."""
|
||
from app.main import app
|
||
|
||
def override_db():
|
||
Session = sessionmaker(bind=imap_engine)
|
||
session = Session()
|
||
try:
|
||
yield session
|
||
finally:
|
||
session.close()
|
||
|
||
app.dependency_overrides[get_db] = override_db
|
||
return app
|
||
|
||
|
||
@pytest.fixture()
|
||
def imap_client(imap_engine):
|
||
"""TestClient authenticated as _OWNER."""
|
||
from app.api.imap_accounts import _get_owner_id
|
||
from app.main import app
|
||
|
||
def override_db():
|
||
Session = sessionmaker(bind=imap_engine)
|
||
session = Session()
|
||
try:
|
||
yield session
|
||
finally:
|
||
session.close()
|
||
|
||
def override_owner():
|
||
return _OWNER
|
||
|
||
app.dependency_overrides[get_db] = override_db
|
||
app.dependency_overrides[_get_owner_id] = override_owner
|
||
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as c:
|
||
yield c
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def _make_profile(session, owner: str = _OWNER, tier: str = "starter") -> UserProfile:
|
||
"""Create a UserProfile."""
|
||
profile = UserProfile(user_id=owner, subscription_tier=tier)
|
||
session.add(profile)
|
||
session.commit()
|
||
session.refresh(profile)
|
||
return profile
|
||
|
||
|
||
def _make_plan(session, tier: str = "starter", max_mailboxes: int = 1) -> SubscriptionPlan:
|
||
"""Create a SubscriptionPlan row."""
|
||
plan = SubscriptionPlan(
|
||
plan_id=tier,
|
||
name=tier.title(),
|
||
price_monthly=2.99,
|
||
price_yearly=28.99,
|
||
max_mailboxes=max_mailboxes,
|
||
is_active=True,
|
||
)
|
||
session.add(plan)
|
||
session.commit()
|
||
session.refresh(plan)
|
||
return plan
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Unit tests – quota helper
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestGetMaxMailboxes:
|
||
"""Unit tests for the _get_max_mailboxes helper."""
|
||
|
||
def test_free_tier_returns_zero(self):
|
||
from app.api.imap_accounts import _get_max_mailboxes
|
||
|
||
assert _get_max_mailboxes({"id": "free", "max_mailboxes": 0}) == 0
|
||
|
||
def test_paid_tier_with_explicit_limit(self):
|
||
from app.api.imap_accounts import _get_max_mailboxes
|
||
|
||
assert _get_max_mailboxes({"id": "starter", "max_mailboxes": 1}) == 1
|
||
|
||
def test_paid_tier_unlimited(self):
|
||
from app.api.imap_accounts import _get_max_mailboxes
|
||
|
||
assert _get_max_mailboxes({"id": "business", "max_mailboxes": 0}) is None
|
||
|
||
def test_professional_three(self):
|
||
from app.api.imap_accounts import _get_max_mailboxes
|
||
|
||
assert _get_max_mailboxes({"id": "professional", "max_mailboxes": 3}) == 3
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Integration tests – list endpoint
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.integration
|
||
class TestListImapAccounts:
|
||
"""Tests for GET /api/imap-accounts/."""
|
||
|
||
def test_list_empty(self, imap_client, imap_session):
|
||
"""Listing accounts for a user with none returns empty list."""
|
||
_make_profile(imap_session, tier="starter")
|
||
_make_plan(imap_session, tier="starter", max_mailboxes=1)
|
||
|
||
resp = imap_client.get("/api/imap-accounts/")
|
||
assert resp.status_code == 200
|
||
assert resp.json() == []
|
||
|
||
def test_list_returns_own_accounts_only(self, imap_client, imap_session):
|
||
"""Only the current user's accounts are returned."""
|
||
_make_profile(imap_session, tier="starter")
|
||
_make_plan(imap_session, tier="starter", max_mailboxes=2)
|
||
|
||
acct = UserImapAccount(owner_id=_OWNER, name="Mine", host="h", port=993, username="u", password="p")
|
||
other = UserImapAccount(
|
||
owner_id="other@example.com",
|
||
name="Theirs",
|
||
host="h2",
|
||
port=993,
|
||
username="u",
|
||
password="p",
|
||
)
|
||
imap_session.add_all([acct, other])
|
||
imap_session.commit()
|
||
|
||
resp = imap_client.get("/api/imap-accounts/")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert len(data) == 1
|
||
assert data[0]["name"] == "Mine"
|
||
|
||
def test_list_requires_authentication(self, imap_engine):
|
||
"""Unauthenticated requests return 401."""
|
||
from fastapi import HTTPException, status
|
||
|
||
from app.api.imap_accounts import _get_owner_id
|
||
from app.main import app
|
||
|
||
def raise_401():
|
||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||
|
||
def override_db():
|
||
from sqlalchemy.orm import sessionmaker
|
||
|
||
Session = sessionmaker(bind=imap_engine)
|
||
session = Session()
|
||
try:
|
||
yield session
|
||
finally:
|
||
session.close()
|
||
|
||
app.dependency_overrides[get_db] = override_db
|
||
app.dependency_overrides[_get_owner_id] = raise_401
|
||
try:
|
||
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as c:
|
||
resp = c.get("/api/imap-accounts/")
|
||
assert resp.status_code == 401
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Integration tests – create endpoint
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.integration
|
||
class TestCreateImapAccount:
|
||
"""Tests for POST /api/imap-accounts/."""
|
||
|
||
def test_create_success(self, imap_client, imap_session):
|
||
"""A valid create request returns 201 and the new account."""
|
||
_make_profile(imap_session, tier="starter")
|
||
_make_plan(imap_session, tier="starter", max_mailboxes=2)
|
||
|
||
resp = imap_client.post("/api/imap-accounts/", json=_BASIC_ACCOUNT)
|
||
assert resp.status_code == 201
|
||
data = resp.json()
|
||
assert data["name"] == _BASIC_ACCOUNT["name"]
|
||
assert data["host"] == _BASIC_ACCOUNT["host"]
|
||
assert "password" not in data
|
||
|
||
def test_create_increments_count(self, imap_client, imap_session):
|
||
"""After creation, the count in the database increases."""
|
||
_make_profile(imap_session, tier="starter")
|
||
_make_plan(imap_session, tier="starter", max_mailboxes=2)
|
||
|
||
imap_client.post("/api/imap-accounts/", json=_BASIC_ACCOUNT)
|
||
count = imap_session.query(UserImapAccount).filter(UserImapAccount.owner_id == _OWNER).count()
|
||
assert count == 1
|
||
|
||
def test_create_blocked_on_free_tier(self, imap_client, imap_session):
|
||
"""Free-tier users cannot add any IMAP accounts."""
|
||
_make_profile(imap_session, tier="free")
|
||
_make_plan(imap_session, tier="free", max_mailboxes=0)
|
||
|
||
resp = imap_client.post("/api/imap-accounts/", json=_BASIC_ACCOUNT)
|
||
assert resp.status_code == 403
|
||
assert "plan" in resp.json()["detail"].lower()
|
||
|
||
def test_create_blocked_at_quota_limit(self, imap_client, imap_session):
|
||
"""Users at the quota limit receive a 403."""
|
||
_make_profile(imap_session, tier="starter")
|
||
_make_plan(imap_session, tier="starter", max_mailboxes=1)
|
||
|
||
existing = UserImapAccount(owner_id=_OWNER, name="Existing", host="h", port=993, username="u", password="p")
|
||
imap_session.add(existing)
|
||
imap_session.commit()
|
||
|
||
resp = imap_client.post("/api/imap-accounts/", json=_BASIC_ACCOUNT)
|
||
assert resp.status_code == 403
|
||
|
||
def test_create_unlimited_on_power_tier(self, imap_engine, imap_session):
|
||
"""Power-tier users can add multiple accounts (unlimited)."""
|
||
from app.api.imap_accounts import _get_owner_id
|
||
from app.main import app
|
||
|
||
def override_db():
|
||
from sqlalchemy.orm import sessionmaker
|
||
|
||
Session = sessionmaker(bind=imap_engine)
|
||
session = Session()
|
||
try:
|
||
yield session
|
||
finally:
|
||
session.close()
|
||
|
||
def override_owner():
|
||
return _OWNER
|
||
|
||
_make_profile(imap_session, tier="business")
|
||
_make_plan(imap_session, tier="business", max_mailboxes=0)
|
||
|
||
app.dependency_overrides[get_db] = override_db
|
||
app.dependency_overrides[_get_owner_id] = override_owner
|
||
try:
|
||
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as c:
|
||
resp1 = c.post("/api/imap-accounts/", json=_BASIC_ACCOUNT)
|
||
resp2 = c.post("/api/imap-accounts/", json={**_BASIC_ACCOUNT, "name": "Second"})
|
||
assert resp1.status_code == 201
|
||
assert resp2.status_code == 201
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Integration tests – update endpoint
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.integration
|
||
class TestUpdateImapAccount:
|
||
"""Tests for PUT /api/imap-accounts/{id}."""
|
||
|
||
def _create_account(self, session, owner: str = _OWNER) -> UserImapAccount:
|
||
acct = UserImapAccount(owner_id=owner, name="Original", host="h", port=993, username="u", password="p")
|
||
session.add(acct)
|
||
session.commit()
|
||
session.refresh(acct)
|
||
return acct
|
||
|
||
def test_update_name(self, imap_client, imap_session):
|
||
"""Updating name only changes the name."""
|
||
_make_profile(imap_session, tier="starter")
|
||
_make_plan(imap_session, tier="starter", max_mailboxes=1)
|
||
acct = self._create_account(imap_session)
|
||
|
||
resp = imap_client.put(f"/api/imap-accounts/{acct.id}", json={"name": "Updated"})
|
||
assert resp.status_code == 200
|
||
assert resp.json()["name"] == "Updated"
|
||
assert resp.json()["host"] == "h"
|
||
|
||
def test_update_not_found(self, imap_client, imap_session):
|
||
"""Updating a non-existent account returns 404."""
|
||
resp = imap_client.put("/api/imap-accounts/9999", json={"name": "x"})
|
||
assert resp.status_code == 404
|
||
|
||
def test_cannot_update_other_users_account(self, imap_client, imap_session):
|
||
"""A user cannot update another user's account."""
|
||
other_acct = self._create_account(imap_session, owner="other@example.com")
|
||
resp = imap_client.put(f"/api/imap-accounts/{other_acct.id}", json={"name": "Hijacked"})
|
||
assert resp.status_code == 404
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Integration tests – delete endpoint
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.integration
|
||
class TestDeleteImapAccount:
|
||
"""Tests for DELETE /api/imap-accounts/{id}."""
|
||
|
||
def _create_account(self, session) -> UserImapAccount:
|
||
acct = UserImapAccount(owner_id=_OWNER, name="ToDelete", host="h", port=993, username="u", password="p")
|
||
session.add(acct)
|
||
session.commit()
|
||
session.refresh(acct)
|
||
return acct
|
||
|
||
def test_delete_success(self, imap_client, imap_session):
|
||
"""Deleting an account returns 204 and removes the DB row."""
|
||
acct = self._create_account(imap_session)
|
||
|
||
resp = imap_client.delete(f"/api/imap-accounts/{acct.id}")
|
||
assert resp.status_code == 204
|
||
|
||
remaining = imap_session.query(UserImapAccount).filter(UserImapAccount.id == acct.id).first()
|
||
assert remaining is None
|
||
|
||
def test_delete_not_found(self, imap_client, imap_session):
|
||
"""Deleting a non-existent account returns 404."""
|
||
resp = imap_client.delete("/api/imap-accounts/9999")
|
||
assert resp.status_code == 404
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Integration tests – test-connection endpoints
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.integration
|
||
class TestImapConnectionTest:
|
||
"""Tests for POST /api/imap-accounts/test and /{id}/test."""
|
||
|
||
def test_test_connection_success(self, imap_client, imap_session):
|
||
"""A successful IMAP login returns success=True."""
|
||
with patch("app.api.imap_accounts._test_imap_connection") as mock_test:
|
||
mock_test.return_value = {"success": True, "message": "Connection successful"}
|
||
resp = imap_client.post(
|
||
"/api/imap-accounts/test",
|
||
json={
|
||
"host": "imap.example.com",
|
||
"port": 993,
|
||
"username": "u@example.com",
|
||
"password": "pass", # noqa: S106
|
||
"use_ssl": True,
|
||
},
|
||
)
|
||
assert resp.status_code == 200
|
||
assert resp.json()["success"] is True
|
||
|
||
def test_test_connection_failure(self, imap_client, imap_session):
|
||
"""A failed IMAP login returns success=False with a message."""
|
||
with patch("app.api.imap_accounts._test_imap_connection") as mock_test:
|
||
mock_test.return_value = {
|
||
"success": False,
|
||
"message": "IMAP error: authentication failed",
|
||
}
|
||
resp = imap_client.post(
|
||
"/api/imap-accounts/test",
|
||
json={
|
||
"host": "imap.example.com",
|
||
"port": 993,
|
||
"username": "u@example.com",
|
||
"password": "wrong", # noqa: S106
|
||
"use_ssl": True,
|
||
},
|
||
)
|
||
assert resp.status_code == 200
|
||
assert resp.json()["success"] is False
|
||
|
||
def test_test_saved_account(self, imap_client, imap_session):
|
||
"""Testing a saved account calls the connection helper with stored credentials."""
|
||
acct = UserImapAccount(
|
||
owner_id=_OWNER,
|
||
name="Saved",
|
||
host="imap.test.com",
|
||
port=993,
|
||
username="u",
|
||
password="p",
|
||
)
|
||
imap_session.add(acct)
|
||
imap_session.commit()
|
||
imap_session.refresh(acct)
|
||
|
||
with patch("app.api.imap_accounts._test_imap_connection") as mock_test:
|
||
mock_test.return_value = {"success": True, "message": "Connection successful"}
|
||
resp = imap_client.post(f"/api/imap-accounts/{acct.id}/test")
|
||
|
||
assert resp.status_code == 200
|
||
assert resp.json()["success"] is True
|
||
mock_test.assert_called_once_with("imap.test.com", 993, "u", "p", True)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Integration tests – quota endpoint
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.integration
|
||
class TestImapQuota:
|
||
"""Tests for GET /api/imap-accounts/quota/."""
|
||
|
||
def test_quota_free_tier(self, imap_client, imap_session):
|
||
"""Free-tier user reports max_mailboxes=0 and can_add=False."""
|
||
_make_profile(imap_session, tier="free")
|
||
_make_plan(imap_session, tier="free", max_mailboxes=0)
|
||
|
||
resp = imap_client.get("/api/imap-accounts/quota/")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["max_mailboxes"] == 0
|
||
assert data["can_add"] is False
|
||
|
||
def test_quota_starter_tier(self, imap_client, imap_session):
|
||
"""Starter-tier user with no accounts reports can_add=True."""
|
||
_make_profile(imap_session, tier="starter")
|
||
_make_plan(imap_session, tier="starter", max_mailboxes=1)
|
||
|
||
resp = imap_client.get("/api/imap-accounts/quota/")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["max_mailboxes"] == 1
|
||
assert data["can_add"] is True
|
||
assert data["current_count"] == 0
|
||
|
||
def test_quota_unlimited(self, imap_client, imap_session):
|
||
"""Power-tier user reports max_mailboxes=None and can_add=True."""
|
||
_make_profile(imap_session, tier="business")
|
||
_make_plan(imap_session, tier="business", max_mailboxes=0)
|
||
|
||
resp = imap_client.get("/api/imap-accounts/quota/")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["max_mailboxes"] is None
|
||
assert data["can_add"] is True
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Unit tests – _test_imap_connection helper
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestTestImapConnection:
|
||
"""Unit tests for the _test_imap_connection helper (no network calls)."""
|
||
|
||
def test_success(self):
|
||
"""Successful login returns success=True."""
|
||
from app.api.imap_accounts import _test_imap_connection
|
||
|
||
mock_mail = MagicMock()
|
||
with patch("imaplib.IMAP4_SSL", return_value=mock_mail):
|
||
result = _test_imap_connection(
|
||
"imap.example.com",
|
||
993,
|
||
"user",
|
||
"pass",
|
||
use_ssl=True, # noqa: S106
|
||
)
|
||
|
||
assert result["success"] is True
|
||
mock_mail.login.assert_called_once_with("user", "pass")
|
||
mock_mail.logout.assert_called_once()
|
||
|
||
def test_auth_error(self):
|
||
"""An exception raised by IMAP4_SSL returns success=False."""
|
||
from app.api.imap_accounts import _test_imap_connection
|
||
|
||
with patch("imaplib.IMAP4_SSL", side_effect=Exception("auth failed")):
|
||
result = _test_imap_connection(
|
||
"imap.example.com",
|
||
993,
|
||
"user",
|
||
"badpass",
|
||
use_ssl=True, # noqa: S106
|
||
)
|
||
|
||
assert result["success"] is False
|
||
assert "auth failed" in result["message"]
|
||
|
||
def test_network_error(self):
|
||
"""An OSError returns success=False with a network error message."""
|
||
from app.api.imap_accounts import _test_imap_connection
|
||
|
||
with patch("imaplib.IMAP4", side_effect=OSError("connection refused")):
|
||
result = _test_imap_connection(
|
||
"bad-host",
|
||
143,
|
||
"user",
|
||
"pass",
|
||
use_ssl=False, # noqa: S106
|
||
)
|
||
|
||
assert result["success"] is False
|
||
assert "connection refused" in result["message"].lower()
|